Compare commits

..

1 Commits

Author SHA1 Message Date
mamoodi 9ee704a25a Release 0.53.0 2025-08-14 07:06:06 -04:00
38 changed files with 872 additions and 6951 deletions
-223
View File
@@ -1,223 +0,0 @@
name: End-to-End Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- develop
workflow_dispatch:
jobs:
e2e-tests:
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v3
with:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Setup environment for end-to-end tests
run: |
# Create test results directory
mkdir -p test-results
# Create downloads directory for OpenHands (use a directory in the home folder)
mkdir -p $HOME/downloads
sudo chown -R $USER:$USER $HOME/downloads
sudo chmod -R 755 $HOME/downloads
- name: Build OpenHands
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
INSTALL_DOCKER: 1
RUNTIME: docker
FRONTEND_PORT: 12000
FRONTEND_HOST: 0.0.0.0
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 3000
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Build OpenHands using make build
echo "Running make build..."
make build
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
echo "$BROWSER_CHECK"
exit 1
else
echo "Playwright browsers are properly installed for e2e tests."
fi
# Docker runtime will handle workspace directory creation
# Start the application using make run with custom parameters and reduced logging
echo "Starting OpenHands using make run..."
# Set environment variables to reduce logging verbosity
export PYTHONUNBUFFERED=1
export LOG_LEVEL=WARNING
export UVICORN_LOG_LEVEL=warning
export OPENHANDS_LOG_LEVEL=WARNING
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
# Store the PID of the make run process
MAKE_PID=$!
echo "OpenHands started with PID: $MAKE_PID"
# Wait for the application to start
echo "Waiting for OpenHands to start..."
max_attempts=15
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
# Check if the process is still running
if ! ps -p $MAKE_PID > /dev/null; then
echo "ERROR: OpenHands process has terminated unexpectedly"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Check if frontend port is open
if nc -z localhost 12000; then
# Verify we can get HTML content
if curl -s http://localhost:12000 | grep -q "<html"; then
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
break
else
echo "Port 12000 is open but not serving HTML content yet"
fi
else
echo "Frontend port 12000 is not open yet"
fi
# Show log output on each attempt
echo "Recent log output:"
tail -n 20 /tmp/openhands-e2e-test.log
# Wait before next attempt
echo "Waiting 10 seconds before next check..."
sleep 10
attempt=$((attempt + 1))
# Exit if we've reached the maximum number of attempts
if [ $attempt -gt $max_attempts ]; then
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
done
# Final verification that the app is running
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
echo "ERROR: OpenHands is not running properly on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Print success message
echo "OpenHands is running successfully on port 12000"
- name: Run end-to-end tests
env:
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
# Check if the application is running
if ! nc -z localhost 12000; then
echo "ERROR: OpenHands is not running on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest test_e2e_workflow.py::test_github_token_configuration test_e2e_workflow.py::test_conversation_start -v --no-header --capture=no --timeout=600
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/test-results/
retention-days: 30
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v4
with:
name: openhands-logs
path: |
/tmp/openhands-e2e-test.log
/tmp/openhands-e2e-build.log
/tmp/openhands-backend.log
/tmp/openhands-frontend.log
/tmp/backend-health-check.log
/tmp/frontend-check.log
/tmp/vite-config.log
/tmp/makefile-contents.log
retention-days: 30
- name: Cleanup
if: always()
run: |
# Stop OpenHands processes
echo "Stopping OpenHands processes..."
pkill -f "python -m openhands.server" || true
pkill -f "npm run dev" || true
pkill -f "make run" || true
# Print process status for debugging
echo "Checking if any OpenHands processes are still running:"
ps aux | grep -E "openhands|npm run dev" || true
+2
View File
@@ -51,6 +51,8 @@ jobs:
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
- name: Run E2E Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -svv tests/e2e
# Run specific Windows python tests
test-on-windows:
-8
View File
@@ -254,11 +254,3 @@ containers/runtime/Dockerfile
containers/runtime/project.tar.gz
containers/runtime/code
**/node_modules/
# VSCode extension test files
openhands/integrations/vscode/.vscode-test/
openhands/integrations/vscode/out/
openhands/integrations/vscode/node_modules/
# test results
test-results
-5
View File
@@ -3,9 +3,4 @@
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
}
-54
View File
@@ -6114,60 +6114,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
-46
View File
@@ -31,52 +31,6 @@ export default defineConfig(({ mode }) => {
svgr(),
tailwindcss(),
],
optimizeDeps: {
include: [
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
// These are discovered during initial app load:
"react-redux",
"posthog-js",
"@tanstack/react-query",
"react-hot-toast",
"@reduxjs/toolkit",
"i18next",
"i18next-http-backend",
"i18next-browser-languagedetector",
"react-i18next",
"axios",
"date-fns",
"@uidotdev/usehooks",
"react-icons/fa6",
"react-icons/fa",
"clsx",
"tailwind-merge",
"@heroui/react",
"lucide-react",
"react-select",
"react-select/async",
"@microlink/react-json-view",
"socket.io-client",
// These are discovered when launching conversations:
"react-icons/vsc",
"react-icons/lu",
"react-icons/di",
"react-icons/io5",
"react-icons/io", // Added to prevent runtime optimization
"@monaco-editor/react",
"react-textarea-autosize",
"react-markdown",
"remark-gfm",
"remark-breaks",
"react-syntax-highlighter",
"react-syntax-highlighter/dist/esm/styles/prism",
"react-syntax-highlighter/dist/esm/styles/hljs",
// Terminal dependencies - added to prevent runtime optimization
"@xterm/addon-fit",
"@xterm/xterm",
"@xterm/xterm/css/xterm.css",
],
},
server: {
port: FE_PORT,
host: true,
+3 -3
View File
@@ -241,7 +241,7 @@ async def modify_llm_settings_basic(
provider_list = [p for p in provider_list if p not in verified_providers]
provider_list = verified_providers + provider_list
provider_completer = FuzzyWordCompleter(provider_list, WORD=True)
provider_completer = FuzzyWordCompleter(provider_list)
session = PromptSession(key_bindings=kb_cancel())
current_provider, current_model, current_api_key = (
@@ -392,7 +392,7 @@ async def modify_llm_settings_basic(
)
if change_model:
model_completer = FuzzyWordCompleter(provider_models, WORD=True)
model_completer = FuzzyWordCompleter(provider_models)
# Define a validator function that allows custom models but shows a warning
def model_validator(x):
@@ -528,7 +528,7 @@ async def modify_llm_settings_advanced(
)
agent_list = Agent.list_agents()
agent_completer = FuzzyWordCompleter(agent_list, WORD=True)
agent_completer = FuzzyWordCompleter(agent_list)
agent = await get_validated_input(
session,
'(Step 4/6) Agent (TAB for options, CTRL-c to cancel): ',
+1
View File
@@ -906,6 +906,7 @@ def cli_confirm(
layout=layout,
key_bindings=kb,
style=style,
mouse_support=True,
full_screen=False,
)
@@ -496,15 +496,15 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
"""Get branches for a repository"""
url = f'{self.BASE_URL}/repos/{repository}/branches'
# Set maximum branches to fetch (100 per page)
MAX_BRANCHES = 5_000
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while len(all_branches) < MAX_BRANCHES:
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
+4 -42
View File
@@ -1,52 +1,14 @@
# OpenHands VS Code Extension
A unified VS Code extension that provides both launcher and runtime capabilities for OpenHands:
- **Launcher**: Start OpenHands conversations directly from VS Code with your current file or selected text
- **Runtime**: Execute OpenHands actions directly within VS Code (file operations, editor commands, etc.)
The official OpenHands companion extension for Visual Studio Code.
## What it does
This extension seamlessly integrates OpenHands into your VSCode workflow, allowing you to start coding sessions with your AI agent directly from your editor.
### Launcher Features
- **Start conversation**: Opens OpenHands in a terminal (safely reuses idle terminals or creates new ones)
- **Send current file**: Starts OpenHands with your active file
- **Send selection**: Starts OpenHands with selected text
- **Safe terminal management**: Never interrupts running processes; creates new terminals when needed
Access launcher commands via Command Palette (Ctrl+Shift+P) or right-click menu.
### Runtime Features
- **Backend Communication**: Connects to OpenHands backend via WebSocket for real-time action execution
- **File Operations**: Execute file read/write operations directly in VS Code
- **Editor Commands**: Perform editor actions like opening files, navigating to lines, etc.
- **Automatic Connection**: Connects to OpenHands backend when available, gracefully handles offline state
![OpenHands VSCode Extension Demo](https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/assets/images/vscode-extension-demo.gif)
## Features
### Safe Terminal Management
- **Non-Intrusive**: Never interrupts running processes in existing terminals
- **Smart Reuse**: Only reuses terminals that have completed OpenHands commands
- **Safe Fallback**: Creates new terminals when existing ones may be busy
- **Shell Integration**: Uses VS Code's Shell Integration API when available for better command tracking
- **Conservative Approach**: When in doubt, creates a new terminal to avoid conflicts
### Virtual Environment Support
- **Auto-Detection**: Automatically finds and activates Python virtual environments
- **Multiple Patterns**: Supports `.venv`, `venv`, and `.virtualenv` directories
- **Cross-Platform**: Works on Windows, macOS, and Linux
### Runtime Configuration
- **Server URL**: Configure OpenHands backend URL via VS Code settings (`openhands.serverUrl`)
- **On-Demand Connection**: Connects to backend only when OpenHands is configured to use VSCode as runtime
- **Graceful Fallback**: Works offline when backend is not available
## Setup
1. Install OpenHands: `pip install openhands`
2. Install the VS Code extension (extension installs automatically when you run `openhands`)
3. **Optional**: Configure OpenHands backend URL in VS Code settings:
- Open VS Code Settings (Ctrl+,)
- Search for "openhands"
- Set "OpenHands: Server URL" (default: `http://localhost:3000`)
- **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.
File diff suppressed because it is too large Load Diff
+10 -31
View File
@@ -1,7 +1,7 @@
{
"name": "openhands-vscode",
"displayName": "OpenHands Integration",
"description": "Integrates OpenHands with VS Code for conversation starting, context passing, and runtime execution.",
"description": "Integrates OpenHands with VS Code for easy conversation starting and context passing.",
"version": "0.0.1",
"publisher": "openhands",
"license": "MIT",
@@ -16,9 +16,7 @@
"activationEvents": [
"onCommand:openhands.startConversation",
"onCommand:openhands.startConversationWithFileContext",
"onCommand:openhands.startConversationWithSelectionContext",
"onCommand:openhands.testConnection",
"onStartupFinished"
"onCommand:openhands.startConversationWithSelectionContext"
],
"main": "./out/extension.js",
"contributes": {
@@ -37,11 +35,6 @@
"command": "openhands.startConversationWithSelectionContext",
"title": "Start with Selected Text",
"category": "OpenHands"
},
{
"command": "openhands.testConnection",
"title": "Test Connection",
"category": "OpenHands"
}
],
"submenus": [
@@ -82,16 +75,6 @@
"when": "editorHasSelection"
}
]
},
"configuration": {
"title": "OpenHands",
"properties": {
"openhands.serverUrl": {
"type": "string",
"default": "http://localhost:3000",
"description": "URL of the OpenHands backend server for runtime connection."
}
}
}
},
"scripts": {
@@ -105,14 +88,16 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/glob": "^8.1.0",
"@types/mocha": "^10.0.6",
"@types/node": "^20.12.12",
"@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",
"@vscode/test-electron": "^2.3.9",
"@vscode/vsce": "^3.5.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -120,12 +105,6 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-unused-imports": "^4.1.4",
"mocha": "^10.4.0",
"prettier": "^3.5.3",
"typescript": "^5.0.0"
},
"dependencies": {
"openhands-types": "git+https://github.com/enyst/openhands-types.git",
"socket.io-client": "^4.8.1"
"prettier": "^3.5.3"
}
}
+4 -167
View File
@@ -1,27 +1,10 @@
import * as vscode from "vscode";
import * as fs from "fs";
import * as path from "path";
import { SocketService } from "./services/socket-service";
import { VSCodeRuntimeActionHandler } from "./services/runtime-action-handler";
// Create output channel for debug logging
const outputChannel = vscode.window.createOutputChannel("OpenHands Debug");
// Runtime services - initialized lazily when needed
let socketService: SocketService | null = null;
let runtimeActionHandler: VSCodeRuntimeActionHandler | null = null;
// Connection status tracking
enum ConnectionStatus {
DISCONNECTED = "disconnected",
CONNECTING = "connecting",
CONNECTED = "connected",
ERROR = "error",
}
let connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED;
let connectionError: string | null = null;
/**
* This implementation uses VSCode's Shell Integration API.
*
@@ -297,119 +280,7 @@ function startOpenHandsInTerminal(options: {
}
}
// Old initializeRuntime function removed - replaced with lazy connection via ensureConnected()
/**
* Lazy connection to OpenHands backend - only connects when needed
*/
async function ensureConnected(): Promise<boolean> {
// If already connected, return true
if (connectionStatus === ConnectionStatus.CONNECTED && socketService) {
return true;
}
// If currently connecting, don't start another connection attempt
if (connectionStatus === ConnectionStatus.CONNECTING) {
vscode.window.showInformationMessage(
"⏳ Already connecting to OpenHands...",
);
return false;
}
// Attempt to connect
connectionStatus = ConnectionStatus.CONNECTING;
connectionError = null;
try {
// Get server URL from configuration
const config = vscode.workspace.getConfiguration("openhands");
const serverUrl = config.get<string>("serverUrl", "http://localhost:3000");
outputChannel.appendLine(
`DEBUG: Connecting to OpenHands backend at: ${serverUrl}`,
);
// Initialize services if not already done
if (!socketService) {
socketService = new SocketService(serverUrl);
}
if (!runtimeActionHandler) {
runtimeActionHandler = new VSCodeRuntimeActionHandler();
runtimeActionHandler.setSocketService(socketService);
// Set up event listener for incoming actions
socketService.onEvent((event) => {
if (runtimeActionHandler) {
runtimeActionHandler.handleAction(event);
}
});
}
// Attempt connection
await socketService.connect();
connectionStatus = ConnectionStatus.CONNECTED;
outputChannel.appendLine(
"DEBUG: Successfully connected to OpenHands backend",
);
vscode.window.showInformationMessage(
"✅ Connected to OpenHands - ready to execute actions in VSCode",
);
return true;
} catch (error) {
connectionStatus = ConnectionStatus.ERROR;
connectionError = error instanceof Error ? error.message : String(error);
outputChannel.appendLine(
`ERROR: Failed to connect to OpenHands backend: ${connectionError}`,
);
// Show user-friendly error message
const errorMsg = `❌ Cannot connect to OpenHands server. Is OpenHands running?\n\nError: ${connectionError}`;
const result = await vscode.window.showErrorMessage(
errorMsg,
"Retry Connection",
"Check Configuration",
);
if (result === "Retry Connection") {
// Reset status and try again
connectionStatus = ConnectionStatus.DISCONNECTED;
return ensureConnected();
}
if (result === "Check Configuration") {
// Open settings
vscode.commands.executeCommand(
"workbench.action.openSettings",
"openhands.serverUrl",
);
}
return false;
}
}
/**
* Clean up runtime services
*/
function cleanupRuntime(): void {
if (socketService) {
socketService.disconnect();
socketService = null;
}
runtimeActionHandler = null;
connectionStatus = ConnectionStatus.DISCONNECTED;
connectionError = null;
outputChannel.appendLine("DEBUG: OpenHands runtime services cleaned up");
}
export function activate(context: vscode.ExtensionContext) {
// Note: Runtime services are now initialized lazily when user runs commands
outputChannel.appendLine(
"DEBUG: OpenHands extension activated - runtime will connect on-demand",
);
// Clean up terminal tracking when terminals are closed
const terminalCloseDisposable = vscode.window.onDidCloseTerminal(
(terminal) => {
@@ -421,12 +292,8 @@ export function activate(context: vscode.ExtensionContext) {
// Command: Start New Conversation
const startConversationDisposable = vscode.commands.registerCommand(
"openhands.startConversation",
async () => {
// Ensure connection before starting conversation
const connected = await ensureConnected();
if (connected) {
startOpenHandsInTerminal({});
}
() => {
startOpenHandsInTerminal({});
},
);
context.subscriptions.push(startConversationDisposable);
@@ -434,12 +301,7 @@ export function activate(context: vscode.ExtensionContext) {
// Command: Start Conversation with Active File Content
const startWithFileContextDisposable = vscode.commands.registerCommand(
"openhands.startConversationWithFileContext",
async () => {
// Ensure connection before starting conversation
const connected = await ensureConnected();
if (!connected) {
return;
}
() => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
// No active editor, start conversation without task
@@ -474,12 +336,7 @@ export function activate(context: vscode.ExtensionContext) {
// Command: Start Conversation with Selected Text
const startWithSelectionContextDisposable = vscode.commands.registerCommand(
"openhands.startConversationWithSelectionContext",
async () => {
// Ensure connection before starting conversation
const connected = await ensureConnected();
if (!connected) {
return;
}
() => {
outputChannel.appendLine(
"DEBUG: startConversationWithSelectionContext command triggered!",
);
@@ -515,29 +372,9 @@ export function activate(context: vscode.ExtensionContext) {
},
);
context.subscriptions.push(startWithSelectionContextDisposable);
// Command: Test Connection to OpenHands
const testConnectionDisposable = vscode.commands.registerCommand(
"openhands.testConnection",
async () => {
outputChannel.appendLine(
"DEBUG: Testing connection to OpenHands backend...",
);
const connected = await ensureConnected();
if (connected) {
vscode.window.showInformationMessage(
"✅ OpenHands connection successful!",
);
}
// Error handling is done in ensureConnected()
},
);
context.subscriptions.push(testConnectionDisposable);
}
export function deactivate() {
// Clean up runtime services
cleanupRuntime();
// Clean up resources if needed, though for this simple extension,
// VS Code handles terminal disposal.
}
@@ -1,317 +0,0 @@
import * as vscode from "vscode";
import {
OpenHandsEventType,
OpenHandsObservationEvent,
OpenHandsParsedEvent,
isOpenHandsAction,
} from "openhands-types";
import { SocketService } from "./socket-service";
export class VSCodeRuntimeActionHandler {
private workspacePath: string | undefined;
private socketService: SocketService | null = null;
constructor() {
// Determine the workspace path for security restrictions
const { workspaceFolders } = vscode.workspace;
if (workspaceFolders && workspaceFolders.length > 0) {
this.workspacePath = workspaceFolders[0].uri.fsPath;
console.log(`Workspace path set to: ${this.workspacePath}`);
} else {
console.warn(
"No workspace folder found. File operations will be restricted.",
);
}
}
setSocketService(socketService: SocketService): void {
this.socketService = socketService;
console.log("SocketService set for VSCodeRuntimeActionHandler");
}
private sanitizePath(filePath: string): string | null {
if (!this.workspacePath) {
console.error(
"No workspace path defined. Blocking file operation for security.",
);
return null;
}
// Handle absolute and relative paths
let resolvedPath = filePath;
if (!filePath.startsWith("/")) {
resolvedPath = `${this.workspacePath}/${filePath}`;
}
// Basic check to prevent path traversal
if (!resolvedPath.startsWith(this.workspacePath)) {
console.error(
`Path traversal attempt detected. Path ${resolvedPath} is outside workspace ${this.workspacePath}.`,
);
return null;
}
return resolvedPath;
}
private static async openOrFocusFile(filePath: string): Promise<void> {
try {
const uri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(document);
} catch (error) {
console.error(`Failed to open file ${filePath}:`, error);
}
}
handleAction(event: OpenHandsParsedEvent): void {
if (!isOpenHandsAction(event) || !event.args) {
console.error("Invalid event received for action handling:", event);
return;
}
console.log(`Handling action: ${event.action} with args:`, event.args);
switch (event.action) {
case "run":
this.handleRunAction(event);
break;
case "read":
this.handleReadAction(event);
break;
case "write":
this.handleWriteAction(event);
break;
case "edit":
this.handleEditAction(event);
break;
default:
console.warn(`Unsupported action received: ${event.action}`);
this.sendErrorObservation(event, `Unsupported action: ${event.action}`);
}
}
private sendObservation(
event: OpenHandsParsedEvent,
observationType: string,
content: string,
extras: Record<string, unknown> = {},
error: boolean = false,
): void {
const observationEvent: OpenHandsObservationEvent<OpenHandsEventType> = {
id: Date.now(),
observation: observationType as OpenHandsEventType,
content,
extras,
message: error
? `Error during ${observationType} operation`
: `VSCode executed ${observationType} operation`,
source: "environment",
cause: -1,
timestamp: new Date().toISOString(),
};
if ("id" in event && typeof event.id === "number") {
observationEvent.cause = event.id;
}
if (this.socketService) {
this.socketService.sendEvent(
observationEvent as unknown as OpenHandsParsedEvent,
);
} else {
console.error("Cannot send observation: SocketService is not set");
console.log("Observation that would have been sent:", observationEvent);
}
}
private sendErrorObservation(
event: OpenHandsParsedEvent,
errorMessage: string,
): void {
this.sendObservation(
event,
"action" in event ? event.action || "unknown" : "unknown",
errorMessage,
{},
true,
);
}
private handleRunAction(event: OpenHandsParsedEvent): void {
if (!isOpenHandsAction(event) || event.action !== "run") {
this.sendErrorObservation(event, "Invalid event type for run action");
return;
}
const args = event.args as Record<string, unknown>;
const command = args.command as string | undefined;
if (!command) {
this.sendErrorObservation(event, "No command provided for run action");
return;
}
// Create or get a terminal for OpenHands commands
const terminalName = "OpenHands Runtime";
let terminal = vscode.window.terminals.find((t) => t.name === terminalName);
if (!terminal) {
terminal = vscode.window.createTerminal(terminalName);
}
terminal.show(true); // Show the terminal but preserve focus on editor
// Send the command to the terminal
terminal.sendText(command);
// For now, we can't reliably capture terminal output programmatically
// So we'll send a placeholder observation
this.sendObservation(
event,
"run",
`Command '${command}' sent to terminal. Output will be visible in the '${terminalName}' terminal.`,
{ command, exit_code: 0 },
);
}
private async handleReadAction(event: OpenHandsParsedEvent): Promise<void> {
if (!isOpenHandsAction(event) || event.action !== "read") {
this.sendErrorObservation(event, "Invalid event type for read action");
return;
}
const args = event.args as { path?: string };
const filePath = args.path;
if (!filePath) {
this.sendErrorObservation(event, "No path provided for read action");
return;
}
const sanitizedPath = this.sanitizePath(filePath);
if (!sanitizedPath) {
this.sendErrorObservation(
event,
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
);
return;
}
try {
const uri = vscode.Uri.file(sanitizedPath);
const contentBuffer = await vscode.workspace.fs.readFile(uri);
const content = contentBuffer.toString();
this.sendObservation(event, "read", content, { path: filePath });
// Optionally open the file in the editor for viewing
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
} catch (error) {
console.error(`Error reading file ${sanitizedPath}:`, error);
this.sendErrorObservation(
event,
`Error reading file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async handleWriteAction(event: OpenHandsParsedEvent): Promise<void> {
if (!isOpenHandsAction(event) || event.action !== "write") {
this.sendErrorObservation(event, "Invalid event type for write action");
return;
}
const args = event.args as { path: string; content: string };
const filePath = args.path;
const { content } = args;
if (!filePath || content === undefined) {
this.sendErrorObservation(
event,
"Missing path or content for write action",
);
return;
}
const sanitizedPath = this.sanitizePath(filePath);
if (!sanitizedPath) {
this.sendErrorObservation(
event,
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
);
return;
}
try {
const uri = vscode.Uri.file(sanitizedPath);
const contentBuffer = new TextEncoder().encode(content);
await vscode.workspace.fs.writeFile(uri, contentBuffer);
this.sendObservation(
event,
"write",
`File ${filePath} written successfully`,
{ path: filePath },
);
// Open the file in the editor for viewing
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
} catch (error) {
console.error(`Error writing to file ${sanitizedPath}:`, error);
this.sendErrorObservation(
event,
`Error writing to file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async handleEditAction(event: OpenHandsParsedEvent): Promise<void> {
if (!isOpenHandsAction(event) || event.action !== "edit") {
this.sendErrorObservation(event, "Invalid event type for edit action");
return;
}
const args = event.args as { path: string; content: string };
const filePath = args.path;
const newContent = args.content;
if (!filePath || newContent === undefined) {
this.sendErrorObservation(
event,
"Missing path or content for edit action",
);
return;
}
const sanitizedPath = this.sanitizePath(filePath);
if (!sanitizedPath) {
this.sendErrorObservation(
event,
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
);
return;
}
try {
const uri = vscode.Uri.file(sanitizedPath);
// Read the current content to potentially show a diff
let oldContent = "";
try {
const currentContentBuffer = await vscode.workspace.fs.readFile(uri);
oldContent = currentContentBuffer.toString();
} catch (error) {
console.warn(
`Could not read current content of ${filePath} for diff, file might not exist yet.`,
error,
);
}
// Write the new content
const contentBuffer = new TextEncoder().encode(newContent);
await vscode.workspace.fs.writeFile(uri, contentBuffer);
// Open or focus the file to show changes
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
this.sendObservation(
event,
"edit",
`File ${filePath} edited successfully`,
{ path: filePath, old_content: oldContent, new_content: newContent },
);
} catch (error) {
console.error(`Error editing file ${sanitizedPath}:`, error);
this.sendErrorObservation(
event,
`Error editing file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -1,245 +0,0 @@
import { io, Socket } from "socket.io-client";
import { OpenHandsParsedEvent } from "openhands-types";
import * as vscode from "vscode";
import * as path from "path";
export class SocketService {
private socket: Socket | null = null;
private serverUrl: string;
private conversationId: string | null = null;
private connectionId: string | null = null;
private eventListeners: Array<(event: OpenHandsParsedEvent) => void> = [];
private heartbeatInterval: NodeJS.Timeout | null = null;
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
}
async connect(): Promise<void> {
try {
// Step 1: Register this VSCode instance with the server
await this.registerVSCodeInstance();
// Step 2: Initialize a conversation via HTTP API
const response = await fetch(`${this.serverUrl}/api/conversations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ initial_user_msg: "VSCode Runtime Connection" }),
});
if (!response.ok) {
throw new Error(
`Failed to initialize conversation: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
// TODO: Type check, do this better
this.conversationId = (
data as { conversation_id: string }
).conversation_id;
// Now connect via Socket.IO
this.socket = io(this.serverUrl, {
query: {
conversation_id: this.conversationId,
latest_event_id: "-1",
},
});
this.socket.on("connect", () => {
console.log("Connected to OpenHands backend via Socket.IO");
});
this.socket.on("oh_event", (event: OpenHandsParsedEvent) => {
console.log("Received event:", event);
this.eventListeners.forEach((listener) => listener(event));
});
this.socket.on("disconnect", () => {
console.log("Disconnected from OpenHands backend");
});
this.socket.on("error", (error: unknown) => {
console.error("Socket.IO error:", error);
});
this.socket.on("connect_error", (error: unknown) => {
console.error("Socket.IO connection error:", error);
});
// Step 3: Start heartbeat to keep registration alive
this.startHeartbeat();
} catch (error) {
console.error("Error connecting to OpenHands backend:", error);
throw error;
}
}
disconnect(): void {
// Stop heartbeat
this.stopHeartbeat();
// Unregister from VSCode registry
if (this.connectionId) {
this.unregisterVSCodeInstance().catch((error) => {
console.error("Failed to unregister VSCode instance:", error);
});
}
// Disconnect socket
if (this.socket) {
this.socket.disconnect();
this.socket = null;
console.log("Socket.IO connection closed");
}
// Reset state
this.conversationId = null;
this.connectionId = null;
}
onEvent(listener: (event: OpenHandsParsedEvent) => void): void {
this.eventListeners.push(listener);
}
sendEvent(event: OpenHandsParsedEvent): void {
if (this.socket && this.socket.connected) {
this.socket.emit("oh_event", event);
console.log("Sent event:", event);
} else {
console.error("Cannot send event: Socket is not connected");
}
}
getConnectionId(): string | null {
return this.connectionId;
}
private async registerVSCodeInstance(): Promise<void> {
try {
// Get workspace information
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workspacePath = workspaceFolder?.uri.fsPath || "";
const workspaceName =
workspaceFolder?.name ||
path.basename(workspacePath) ||
"Unknown Workspace";
// Get VSCode version
const vscodeVersion = vscode.version;
// Get extension version (from package.json)
const extensionVersion =
vscode.extensions.getExtension("openhands.openhands-vscode")
?.packageJSON?.version || "0.0.1";
// Define capabilities
const capabilities = [
"file_operations",
"text_editing",
"workspace_navigation",
"terminal_access",
];
const registrationData = {
workspace_path: workspacePath,
workspace_name: workspaceName,
vscode_version: vscodeVersion,
extension_version: extensionVersion,
capabilities,
};
console.log("Registering VSCode instance:", registrationData);
const response = await fetch(`${this.serverUrl}/api/vscode/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(registrationData),
});
if (!response.ok) {
throw new Error(
`Failed to register VSCode instance: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
this.connectionId = (data as { connection_id: string }).connection_id;
console.log(
`VSCode instance registered with connection ID: ${this.connectionId}`,
);
} catch (error) {
console.error("Error registering VSCode instance:", error);
throw error;
}
}
private async unregisterVSCodeInstance(): Promise<void> {
if (!this.connectionId) {
return;
}
try {
const response = await fetch(
`${this.serverUrl}/api/vscode/unregister/${this.connectionId}`,
{
method: "DELETE",
},
);
if (!response.ok) {
console.warn(
`Failed to unregister VSCode instance: ${response.status} ${response.statusText}`,
);
} else {
console.log(`VSCode instance unregistered: ${this.connectionId}`);
}
} catch (error) {
console.error("Error unregistering VSCode instance:", error);
}
}
private startHeartbeat(): void {
if (!this.connectionId) {
return;
}
// Send heartbeat every 30 seconds
this.heartbeatInterval = setInterval(async () => {
try {
const response = await fetch(
`${this.serverUrl}/api/vscode/heartbeat/${this.connectionId}`,
{
method: "POST",
},
);
if (!response.ok) {
console.warn(
`Heartbeat failed: ${response.status} ${response.statusText}`,
);
}
} catch (error) {
console.error("Heartbeat error:", error);
}
}, 30000); // 30 seconds
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
}
@@ -1,6 +1,6 @@
import * as path from "path";
import Mocha = require("mocha"); // Changed import style
import { glob } from "glob"; // Use named import for modern glob
import glob = require("glob"); // Changed import style
export function run(): Promise<void> {
// Create the mocha test
@@ -14,10 +14,14 @@ export function run(): Promise<void> {
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
return new Promise((c, e) => {
(async () => {
try {
// Use glob to find all test files (ending with .test.js in the compiled output)
const files = await glob("**/**.test.js", { cwd: testsRoot });
// 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)));
@@ -35,10 +39,7 @@ export function run(): Promise<void> {
console.error(err);
e(err);
}
} catch (err) {
console.error("Error finding test files:", err);
e(err);
}
})();
},
);
});
}
@@ -1,164 +0,0 @@
import * as assert from "assert";
import * as vscode from "vscode";
import { VSCodeRuntimeActionHandler } from "../../services/runtime-action-handler";
import { SocketService } from "../../services/socket-service";
suite("VSCodeRuntimeActionHandler Test Suite", () => {
let handler: VSCodeRuntimeActionHandler;
let mockSocketService: SocketService;
let originalWorkspaceFolders: PropertyDescriptor | undefined;
setup(() => {
// Create handler instance
handler = new VSCodeRuntimeActionHandler();
// Create mock socket service
mockSocketService = {
onEvent: () => {},
sendEvent: () => {},
connect: () => Promise.resolve(),
disconnect: () => {},
getConnectionId: () => null,
} as any;
// Store original workspace folders for restoration
originalWorkspaceFolders = Object.getOwnPropertyDescriptor(
vscode.workspace,
"workspaceFolders",
);
});
teardown(() => {
// Restore original workspace folders
if (originalWorkspaceFolders) {
Object.defineProperty(
vscode.workspace,
"workspaceFolders",
originalWorkspaceFolders,
);
}
});
suite("Constructor and Initialization", () => {
test("should initialize without workspace", () => {
// Mock no workspace folders
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => undefined,
configurable: true,
});
const handlerNoWorkspace = new VSCodeRuntimeActionHandler();
assert.ok(
handlerNoWorkspace,
"Handler should be created even without workspace",
);
});
test("should initialize with workspace", () => {
// Mock workspace folders
const mockWorkspaceFolder = {
uri: vscode.Uri.file("/test/workspace"),
name: "test-workspace",
index: 0,
};
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => [mockWorkspaceFolder],
configurable: true,
});
const handlerWithWorkspace = new VSCodeRuntimeActionHandler();
assert.ok(
handlerWithWorkspace,
"Handler should be created with workspace",
);
});
test("should handle multiple workspace folders", () => {
// Mock multiple workspace folders
const mockWorkspaceFolders = [
{
uri: vscode.Uri.file("/test/workspace1"),
name: "workspace1",
index: 0,
},
{
uri: vscode.Uri.file("/test/workspace2"),
name: "workspace2",
index: 1,
},
];
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => mockWorkspaceFolders,
configurable: true,
});
const handlerMultiWorkspace = new VSCodeRuntimeActionHandler();
assert.ok(
handlerMultiWorkspace,
"Handler should be created with multiple workspaces",
);
});
});
suite("SocketService Integration", () => {
test("should accept socket service", () => {
handler.setSocketService(mockSocketService);
assert.ok(true, "Should accept socket service without error");
});
test("should handle socket service events", () => {
let eventListenerAdded = false;
const mockSocketWithEventTracking = {
onEvent: (listener: any) => {
eventListenerAdded = true;
assert.ok(
typeof listener === "function",
"Event listener should be a function",
);
},
sendEvent: () => {},
connect: () => Promise.resolve(),
disconnect: () => {},
getConnectionId: () => null,
} as any;
handler.setSocketService(mockSocketWithEventTracking);
assert.ok(
eventListenerAdded,
"Should add event listener to socket service",
);
});
});
suite("Action Validation", () => {
test("should validate action structure", () => {
// Test with valid action-like object
const validAction = {
event_type: "action",
action: "run",
args: { command: "echo test" },
};
// We can't directly test isOpenHandsAction without importing it,
// but we can test that the handler doesn't throw with valid structure
assert.ok(validAction.event_type, "Valid action should have event_type");
assert.ok(validAction.action, "Valid action should have action");
});
test("should handle invalid action structure", () => {
// Test with invalid action-like object
const invalidAction = {
// Missing required fields
some_field: "value",
};
// Handler should be able to process this without throwing
assert.ok(
typeof invalidAction === "object",
"Should handle object input",
);
});
});
});
@@ -1,305 +0,0 @@
import * as assert from "assert";
import { SocketService } from "../../services/socket-service";
// Mock Socket.IO client (for future use if needed)
// const mockSocket = {
// on: () => {},
// emit: () => {},
// disconnect: () => {},
// connected: true,
// id: "mock-socket-id",
// };
// Mock fetch globally
const originalFetch = global.fetch;
suite("SocketService Test Suite", () => {
let socketService: SocketService;
let mockFetch: any;
setup(() => {
// Create service instance
socketService = new SocketService("http://localhost:3000");
// Reset fetch mock
mockFetch = null;
});
teardown(() => {
// Restore original fetch
if (originalFetch) {
global.fetch = originalFetch;
}
// Clean up service
if (socketService) {
socketService.disconnect();
}
});
suite("Constructor and Initialization", () => {
test("should initialize with server URL", () => {
const service = new SocketService("http://test-server:8080");
assert.ok(service, "SocketService should be created");
});
test("should store server URL correctly", () => {
const serverUrl = "http://custom-server:9000";
const service = new SocketService(serverUrl);
// We can't directly access private properties, but we can test behavior
assert.ok(service, "Service should be initialized with custom URL");
});
test("should have null connection ID initially", () => {
const connectionId = socketService.getConnectionId();
assert.strictEqual(
connectionId,
null,
"Connection ID should be null initially",
);
});
});
suite("Event Handling Interface", () => {
test("should allow adding event listeners", () => {
const listener = (event: any) => {
console.log("Event received:", event);
};
// This tests the public interface
socketService.onEvent(listener);
assert.ok(true, "Should allow adding event listeners without error");
});
test("should allow sending events when not connected", () => {
const mockEvent = {
id: "test-event-id",
timestamp: new Date().toISOString(),
source: "vscode",
message: "test message",
event_type: "test",
} as any;
// This should not throw even if not connected
socketService.sendEvent(mockEvent);
assert.ok(
true,
"Should allow sending events without error when disconnected",
);
});
});
suite("Registration Workflow", () => {
test("should prepare correct registration data", async () => {
let registrationCalled = false;
let registrationData: any = null;
// Mock successful registration
mockFetch = (url: string, options?: any) => {
if (url.includes("/api/vscode/register")) {
registrationCalled = true;
registrationData = JSON.parse(options.body);
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
connection_id: "test-connection-id",
status: "registered",
}),
});
}
if (url.includes("/api/conversations")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
conversation_id: "test-conversation-id",
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
};
global.fetch = mockFetch as any;
try {
await socketService.connect();
} catch (error) {
// Expected to fail due to Socket.IO mocking limitations
}
assert.ok(registrationCalled, "Registration should be called");
assert.ok(registrationData, "Registration data should be captured");
assert.ok(
registrationData.workspace_path !== undefined,
"Should include workspace path",
);
assert.ok(
registrationData.vscode_version,
"Should include VSCode version",
);
assert.ok(
registrationData.extension_version,
"Should include extension version",
);
assert.ok(
Array.isArray(registrationData.capabilities),
"Should include capabilities array",
);
assert.ok(
registrationData.capabilities.includes("file_operations"),
"Should include file_operations capability",
);
});
test("should handle registration failure", async () => {
mockFetch = (url: string) => {
if (url.includes("/api/vscode/register")) {
return Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
}
return Promise.resolve({ ok: false, status: 404 });
};
global.fetch = mockFetch as any;
try {
await socketService.connect();
assert.fail("Should have thrown an error for registration failure");
} catch (error) {
assert.ok(error instanceof Error, "Should throw an Error");
assert.ok(
(error as Error).message.includes(
"Failed to register VSCode instance",
),
"Should have descriptive error message",
);
}
});
test("should handle network errors during registration", async () => {
mockFetch = () => Promise.reject(new Error("Network error"));
global.fetch = mockFetch as any;
try {
await socketService.connect();
assert.fail("Should have thrown an error for network failure");
} catch (error) {
assert.ok(error instanceof Error, "Should throw an Error");
assert.ok(
(error as Error).message.includes("Network error"),
"Should propagate network error",
);
}
});
});
suite("Conversation Creation", () => {
test("should create conversation after successful registration", async () => {
let conversationCalled = false;
let conversationData: any = null;
mockFetch = (url: string, options?: any) => {
if (url.includes("/api/vscode/register")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
connection_id: "test-connection-id",
status: "registered",
}),
});
}
if (url.includes("/api/conversations")) {
conversationCalled = true;
conversationData = JSON.parse(options.body);
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
conversation_id: "test-conversation-id",
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
};
global.fetch = mockFetch as any;
try {
await socketService.connect();
} catch (error) {
// Expected to fail due to Socket.IO mocking limitations
}
assert.ok(conversationCalled, "Conversation creation should be called");
assert.ok(conversationData, "Conversation data should be captured");
assert.strictEqual(
conversationData.initial_user_msg,
"VSCode Runtime Connection",
"Should have correct initial message",
);
});
test("should handle conversation creation failure", async () => {
mockFetch = (url: string) => {
if (url.includes("/api/vscode/register")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
connection_id: "test-connection-id",
status: "registered",
}),
});
}
if (url.includes("/api/conversations")) {
return Promise.resolve({
ok: false,
status: 400,
statusText: "Bad Request",
});
}
return Promise.resolve({ ok: false, status: 404 });
};
global.fetch = mockFetch as any;
try {
await socketService.connect();
assert.fail(
"Should have thrown an error for conversation creation failure",
);
} catch (error) {
assert.ok(error instanceof Error, "Should throw an Error");
assert.ok(
(error as Error).message.includes(
"Failed to initialize conversation",
),
"Should have descriptive error message",
);
}
});
});
suite("Disconnection and Cleanup", () => {
test("should handle disconnection gracefully when not connected", () => {
try {
socketService.disconnect();
assert.ok(true, "Should handle disconnection without error");
} catch (error) {
assert.fail("Disconnection should not throw error when not connected");
}
});
test("should handle multiple disconnects safely", () => {
// Test that disconnect doesn't throw and cleans up properly
socketService.disconnect();
// Try to disconnect again - should not throw
socketService.disconnect();
assert.ok(true, "Multiple disconnects should be safe");
});
});
});
-3
View File
@@ -8,7 +8,6 @@ from openhands.runtime.impl.docker.docker_runtime import (
from openhands.runtime.impl.kubernetes.kubernetes_runtime import KubernetesRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.vscode.vscode_runtime import VsCodeRuntime
from openhands.utils.import_utils import get_impl
# mypy: disable-error-code="type-abstract"
@@ -19,7 +18,6 @@ _DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
'local': LocalRuntime,
'kubernetes': KubernetesRuntime,
'cli': CLIRuntime,
'vscode': VsCodeRuntime,
}
# Try to import third-party runtimes if available
@@ -112,7 +110,6 @@ __all__ = [
'DockerRuntime',
'KubernetesRuntime',
'CLIRuntime',
'VsCodeRuntime',
'LocalRuntime',
'get_runtime_cls',
]
-3
View File
@@ -1,3 +0,0 @@
from .vscode_runtime import VsCodeRuntime
__all__ = ['VsCodeRuntime']
-419
View File
@@ -1,419 +0,0 @@
import asyncio
import uuid
from pathlib import Path
from typing import Callable
import aiohttp
import socketio # Added for type hinting
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
MCPAction,
)
from openhands.events.observation import (
ErrorObservation,
Observation,
)
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStream
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
# GLOBAL_SOCKET_IO_CLIENT = None # Removed
class VsCodeRuntime(Runtime):
"""
A runtime that delegates action execution to a VS Code extension.
This class sends actions to the VS Code extension via the main Socket.IO server
and receives observations in return.
"""
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
user_id: str | None = None,
# VSCode-specific parameters (optional for testing/injection)
sio_server: socketio.AsyncServer | None = None,
socket_connection_id: str | None = None,
):
super().__init__(
config=config,
event_stream=event_stream,
sid=sid,
plugins=plugins,
env_vars=env_vars,
status_callback=status_callback,
attach_to_existing=attach_to_existing,
headless_mode=headless_mode,
user_id=user_id,
)
self.sid = sid
self.plugins = plugins or []
self.env_vars = env_vars or {}
self.status_callback = status_callback
self.attach_to_existing = attach_to_existing
self.headless_mode = headless_mode
self.user_id = user_id
# VSCode-specific attributes
self.sio_server = sio_server # Will be set from shared.py if None
self.socket_connection_id = socket_connection_id # Will be discovered if None
self._running_actions: dict[str, asyncio.Future[Observation]] = {}
self._server_url = 'http://localhost:3000' # Default OpenHands server port
logger.info(f'VsCodeRuntime initialized with sid={sid}')
async def _get_available_vscode_instances(self) -> list[dict]:
"""Query the server registry for available VSCode instances."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f'{self._server_url}/api/vscode/instances'
) as response:
if response.status == 200:
instances = await response.json()
if not isinstance(instances, list):
logger.error(
'Unexpected response shape for /api/vscode/instances; expected a list'
)
return []
logger.info(
f'Found {len(instances)} available VSCode instances'
)
return instances
else:
logger.error(
f'Failed to get VSCode instances: HTTP {response.status}'
)
return []
except Exception as e:
logger.error(f'Error querying VSCode instances: {e}')
return []
async def _validate_vscode_connection(self, connection_id: str) -> bool:
"""Validate that a VSCode connection is still active."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f'{self._server_url}/api/vscode/instance/{connection_id}'
) as response:
if response.status == 200:
data = await response.json()
status = data.get('status', 'unknown')
logger.debug(
f'VSCode connection {connection_id} status: {status}'
)
return status == 'active'
else:
logger.warning(
f'VSCode connection {connection_id} validation failed: HTTP {response.status}'
)
return False
except Exception as e:
logger.error(f'Error validating VSCode connection {connection_id}: {e}')
return False
async def _discover_and_connect(self) -> bool:
"""Discover available VSCode instances and establish connection."""
# Get sio_server from shared.py if not provided
if self.sio_server is None:
try:
from openhands.server.shared import sio
self.sio_server = sio
logger.info('Retrieved Socket.IO server from shared.py')
except ImportError as e:
logger.error(f'Failed to import Socket.IO server from shared.py: {e}')
return False
# If socket_connection_id is already set (e.g., for testing), validate it
if self.socket_connection_id:
if await self._validate_vscode_connection(self.socket_connection_id):
logger.info(
f'Using existing VSCode connection: {self.socket_connection_id}'
)
return True
else:
logger.warning(
f'Existing connection {self.socket_connection_id} is no longer valid'
)
self.socket_connection_id = None
# Discover available VSCode instances
instances = await self._get_available_vscode_instances()
if not instances:
logger.error('No VSCode instances are currently registered with OpenHands')
return False
# Filter for active instances
active_instances = [
inst for inst in instances if inst.get('status') == 'active'
]
if not active_instances:
logger.error('No active VSCode instances found')
return False
# Use the first active instance (could be enhanced to let user choose)
selected_instance = active_instances[0]
self.socket_connection_id = selected_instance['connection_id']
logger.info(f'Connected to VSCode instance: {self.socket_connection_id}')
logger.info(f'Workspace: {selected_instance.get("workspace_path", "Unknown")}')
logger.info(f'Capabilities: {selected_instance.get("capabilities", [])}')
return True
async def _send_action_to_vscode(self, action: Action) -> Observation:
# Ensure we have a valid connection
if self.sio_server is None or self.socket_connection_id is None:
logger.info('No VSCode connection established, attempting discovery...')
if not await self._discover_and_connect():
return ErrorObservation(
content='No VSCode instances available. Please ensure VSCode with OpenHands extension is running and connected.'
)
# Validate connection is still active before sending action
if self.socket_connection_id and not await self._validate_vscode_connection(
self.socket_connection_id
):
logger.warning(
'VSCode connection became inactive, attempting to reconnect...'
)
self.socket_connection_id = None # Force rediscovery
if not await self._discover_and_connect():
return ErrorObservation(
content='VSCode connection lost and no alternative instances available.'
)
event_id = str(uuid.uuid4())
# Use proper serialization to create event payload for VSCode
oh_event_payload = event_to_dict(action)
oh_event_payload['event_id'] = event_id
oh_event_payload['message'] = getattr(
action, 'message', f'Delegating {type(action).__name__} to VSCode'
)
future: asyncio.Future[Observation] = asyncio.get_event_loop().create_future()
self._running_actions[event_id] = future
logger.info(
f'Sending action to VSCode (event_id: {event_id}, socket_id: {self.socket_connection_id}): {type(action)}'
)
logger.debug(f'Action details: {oh_event_payload}')
try:
if self.sio_server is None or not hasattr(self.sio_server, 'emit'):
logger.error("sio_server is None or does not have an 'emit' method.")
# Clean up future before returning
self._running_actions.pop(event_id, None)
future.cancel() # Ensure future is not left pending
return ErrorObservation(
content='sio_server is misconfigured for VsCodeRuntime.'
)
await self.sio_server.emit(
'oh_event', oh_event_payload, to=self.socket_connection_id
)
logger.debug(
f'Action emitted to socket_connection_id: {self.socket_connection_id}'
)
except Exception as e:
logger.error(
f'Error emitting action to VSCode (socket_id: {self.socket_connection_id}): {e}'
)
# Clean up future before returning
self._running_actions.pop(event_id, None)
if not future.done(): # Check if future is already resolved/cancelled
future.set_exception(
e
) # Propagate exception to the future if not already done
return ErrorObservation(
content=f'Failed to send action to VS Code extension: {e}'
)
try:
observation = await asyncio.wait_for(
future, timeout=self.config.sandbox.timeout
)
logger.info(
f'Received observation for event_id {event_id} from socket_id: {self.socket_connection_id}'
)
return observation
except asyncio.TimeoutError:
logger.error(
f'Timeout waiting for observation for event_id {event_id} from socket_id: {self.socket_connection_id}'
)
# The future is automatically cancelled by wait_for on timeout.
# We just need to ensure it's removed from _running_actions, which finally does.
return ErrorObservation(
content=f'Timeout waiting for VS Code extension response for action: {type(action)}'
)
except asyncio.CancelledError:
logger.info(f'Action {event_id} was cancelled while awaiting observation.')
return ErrorObservation(content=f'Action {type(action)} was cancelled.')
finally:
self._running_actions.pop(event_id, None)
def handle_observation_from_vscode(self, observation_event: dict):
cause_event_id = observation_event.get('cause')
if not cause_event_id:
logger.error(
f"Received observation event from VSCode without a 'cause' ID: {observation_event}"
)
return
if cause_event_id in self._running_actions:
future = self._running_actions[cause_event_id]
try:
# Use proper deserialization to convert observation event back to Observation object
observation = event_from_dict(observation_event)
assert isinstance(observation, Observation)
except Exception as e:
logger.error(
f'Failed to deserialize observation from VSCode for cause {cause_event_id}: {e}'
)
observation = ErrorObservation(
content=f'Failed to deserialize observation from VSCode: {e}. Raw event: {observation_event}'
)
if not future.done():
future.set_result(observation)
else:
logger.warning(
f'Future for event_id {cause_event_id} was already done.'
)
else:
logger.warning(
f'Received observation for unknown event_id or already handled: {cause_event_id}'
)
def _run_async_action(self, action) -> Observation:
"""Helper to run async action in sync context."""
try:
# Try to get the current event loop
asyncio.get_running_loop()
# If we're already in an async context, we need to use a different approach
# Create a new task and run it
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, self._send_action_to_vscode(action)
)
return future.result()
except RuntimeError:
# No event loop running, safe to use asyncio.run
return asyncio.run(self._send_action_to_vscode(action))
def run(self, action: CmdRunAction) -> Observation:
"""Execute a shell command via VSCode."""
return self._run_async_action(action)
def read(self, action: FileReadAction) -> Observation:
"""Read a file via VSCode."""
return self._run_async_action(action)
def write(self, action: FileWriteAction) -> Observation:
"""Write to a file via VSCode."""
return self._run_async_action(action)
def edit(self, action: FileEditAction) -> Observation:
"""Edit a file via VSCode."""
return self._run_async_action(action)
def browse(self, action: BrowseURLAction) -> Observation:
"""Browse a URL via VSCode."""
return self._run_async_action(action)
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
"""Browse interactively via VSCode."""
return self._run_async_action(action)
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
"""Execute Python code via VSCode."""
return self._run_async_action(action)
async def call_tool_mcp(self, action: MCPAction) -> Observation:
"""Call MCP tool via VSCode."""
return await self._send_action_to_vscode(action)
async def connect(self):
"""Connect to VSCode extension via Socket.IO.
This method discovers available VSCode instances and establishes connection.
"""
logger.info('VsCodeRuntime connecting to available VSCode instances...')
if await self._discover_and_connect():
logger.info('VsCodeRuntime successfully connected to VSCode extension')
else:
logger.error('VsCodeRuntime failed to connect to any VSCode extension')
raise RuntimeError(
'No VSCode instances available. Please ensure VSCode with OpenHands extension is running and connected to OpenHands server.'
)
def copy_from(self, path: str) -> Path:
"""Copy files from the VSCode workspace to the host.
For VSCode runtime, file operations are handled through the extension,
so files are already accessible on the host. Return the path as-is.
"""
logger.debug(f'VSCode Runtime: copy_from {path} (no-op)')
return Path(path)
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
"""Copy files from the host to the VSCode workspace.
For VSCode runtime, file operations are handled through the extension,
so this is a no-op as files are already accessible on the host.
"""
logger.debug(
f'VSCode Runtime: copy_to {host_src} -> {sandbox_dest} (no-op, recursive={recursive})'
)
def get_mcp_config(self, extra_stdio_servers: list | None = None):
"""Get MCP configuration for this runtime.
Returns the MCP configuration from the runtime config.
"""
return self.config.mcp
def list_files(self, path: str | None = None) -> list[str]:
"""List files in the given path.
For VSCode runtime, we delegate file listing to the extension.
This is a synchronous wrapper around the async file listing operation.
"""
# For now, return empty list as file operations should go through VSCode extension
logger.debug(f'VSCode Runtime: list_files {path} (delegated to extension)')
return []
async def close(self):
logger.info('Closing VsCodeRuntime. Outstanding actions will be cancelled.')
for event_id, future in self._running_actions.items():
if not future.done():
future.cancel()
logger.info(f'Cancelled pending action: {event_id}')
self._running_actions.clear()
logger.info('VsCodeRuntime closed.')
-2
View File
@@ -28,7 +28,6 @@ from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.routes.vscode import app as vscode_api_router
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
@@ -72,6 +71,5 @@ app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(vscode_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)
-290
View File
@@ -1,290 +0,0 @@
"""VSCode Integration API Routes
Provides endpoints for VSCode extension registration, discovery, and management.
Implements the server-side registry for the Lazy Connection Pattern.
"""
import time
import uuid
from fastapi import APIRouter, HTTPException
from fastapi import status as http_status
from pydantic import BaseModel, Field
from openhands.core.logger import openhands_logger as logger
from openhands.server.dependencies import get_dependencies
app = APIRouter(prefix='/api/vscode', dependencies=get_dependencies())
# Global VSCode instance registry
# In production, this could be moved to a persistent store
_vscode_registry: dict[str, 'VSCodeInstance'] = {}
class VSCodeInstance(BaseModel):
"""Information about a registered VSCode instance"""
connection_id: str
workspace_path: str
workspace_name: str
vscode_version: str
extension_version: str
capabilities: list[str]
registered_at: float
last_heartbeat: float
status: str = 'active' # active, idle, disconnected
class VSCodeRegistrationRequest(BaseModel):
"""Request payload for VSCode instance registration"""
workspace_path: str = Field(
..., min_length=1, description='Path to the workspace directory'
)
workspace_name: str = Field(..., min_length=1, description='Name of the workspace')
vscode_version: str = Field(..., min_length=1, description='VSCode version')
extension_version: str = Field(..., min_length=1, description='Extension version')
capabilities: list[str] = Field(
default=[], description='List of capabilities supported by this instance'
)
class VSCodeRegistrationResponse(BaseModel):
"""Response for successful VSCode registration"""
connection_id: str
message: str
class VSCodeInstanceInfo(BaseModel):
"""Public information about a VSCode instance"""
connection_id: str
workspace_name: str
workspace_path: str
status: str
registered_at: float
last_heartbeat: float
@app.post('/register', response_model=VSCodeRegistrationResponse)
async def register_vscode_instance(request: VSCodeRegistrationRequest):
"""Register a new VSCode instance with the server
This endpoint is called by the VSCode extension when it connects to OpenHands.
It creates a unique connection_id and stores the instance information.
"""
try:
# Generate unique connection ID
connection_id = str(uuid.uuid4())
current_time = time.time()
# Create VSCode instance record
instance = VSCodeInstance(
connection_id=connection_id,
workspace_path=request.workspace_path,
workspace_name=request.workspace_name,
vscode_version=request.vscode_version,
extension_version=request.extension_version,
capabilities=request.capabilities,
registered_at=current_time,
last_heartbeat=current_time,
status='active',
)
# Store in registry
_vscode_registry[connection_id] = instance
logger.info(
f"Registered VSCode instance: {connection_id} for workspace '{request.workspace_name}'"
)
return VSCodeRegistrationResponse(
connection_id=connection_id,
message=f"Successfully registered VSCode instance for workspace '{request.workspace_name}'",
)
except Exception as e:
logger.error(f'Failed to register VSCode instance: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Registration failed: {str(e)}',
)
@app.get('/instances', response_model=list[VSCodeInstanceInfo])
async def get_vscode_instances():
"""Get list of all registered VSCode instances
This endpoint is used by VsCodeRuntime to discover available VSCode instances.
Returns public information about each registered instance.
"""
try:
# Clean up stale instances (no heartbeat for > 5 minutes)
current_time = time.time()
stale_threshold = 5 * 60 # 5 minutes
stale_ids = [
conn_id
for conn_id, instance in _vscode_registry.items()
if current_time - instance.last_heartbeat > stale_threshold
]
for conn_id in stale_ids:
logger.info(f'Removing stale VSCode instance: {conn_id}')
del _vscode_registry[conn_id]
# Return active instances
instances = [
VSCodeInstanceInfo(
connection_id=instance.connection_id,
workspace_name=instance.workspace_name,
workspace_path=instance.workspace_path,
status=instance.status,
registered_at=instance.registered_at,
last_heartbeat=instance.last_heartbeat,
)
for instance in _vscode_registry.values()
]
logger.debug(f'Returning {len(instances)} VSCode instances')
return instances
except Exception as e:
logger.error(f'Failed to get VSCode instances: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to retrieve instances: {str(e)}',
)
@app.post('/heartbeat/{connection_id}')
async def vscode_heartbeat(connection_id: str):
"""Update heartbeat for a VSCode instance
This endpoint should be called periodically by VSCode extensions
to indicate they are still active and connected.
"""
try:
if connection_id not in _vscode_registry:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail=f'VSCode instance {connection_id} not found',
)
# Update heartbeat timestamp
_vscode_registry[connection_id].last_heartbeat = time.time()
_vscode_registry[connection_id].status = 'active'
logger.debug(f'Updated heartbeat for VSCode instance: {connection_id}')
return {'message': 'Heartbeat updated'}
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to update heartbeat for {connection_id}: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Heartbeat update failed: {str(e)}',
)
@app.delete('/unregister/{connection_id}')
async def unregister_vscode_instance(connection_id: str):
"""Unregister a VSCode instance
This endpoint is called when a VSCode instance disconnects
or is no longer available.
"""
try:
if connection_id not in _vscode_registry:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail=f'VSCode instance {connection_id} not found',
)
instance = _vscode_registry[connection_id]
del _vscode_registry[connection_id]
logger.info(
f"Unregistered VSCode instance: {connection_id} for workspace '{instance.workspace_name}'"
)
return {'message': f'Successfully unregistered VSCode instance {connection_id}'}
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to unregister VSCode instance {connection_id}: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Unregistration failed: {str(e)}',
)
@app.get('/instance/{connection_id}', response_model=VSCodeInstanceInfo)
async def get_vscode_instance(connection_id: str):
"""Get information about a specific VSCode instance"""
try:
if connection_id not in _vscode_registry:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail=f'VSCode instance {connection_id} not found',
)
instance = _vscode_registry[connection_id]
return VSCodeInstanceInfo(
connection_id=instance.connection_id,
workspace_name=instance.workspace_name,
workspace_path=instance.workspace_path,
status=instance.status,
registered_at=instance.registered_at,
last_heartbeat=instance.last_heartbeat,
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to get VSCode instance {connection_id}: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to retrieve instance: {str(e)}',
)
@app.get('/registry/stats')
async def get_registry_stats():
"""Get statistics about the VSCode registry
Useful for monitoring and debugging.
"""
try:
current_time = time.time()
total_instances = len(_vscode_registry)
# Count by status
status_counts: dict[str, int] = {}
for instance in _vscode_registry.values():
status = instance.status
status_counts[status] = status_counts.get(status, 0) + 1
# Count recent activity (last 5 minutes)
recent_threshold = 5 * 60 # 5 minutes
recent_activity = sum(
1
for instance in _vscode_registry.values()
if current_time - instance.last_heartbeat < recent_threshold
)
return {
'total_instances': total_instances,
'status_counts': status_counts,
'recent_activity': recent_activity,
'registry_size': len(_vscode_registry),
}
except Exception as e:
logger.error(f'Failed to get registry stats: {e}')
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to retrieve stats: {str(e)}',
)
Generated
+14 -96
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -404,7 +404,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"},
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"},
@@ -2997,8 +2997,8 @@ files = [
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
proto-plus = [
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -3020,8 +3020,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -3239,8 +3239,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -3462,6 +3462,7 @@ files = [
{file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"},
{file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"},
]
markers = {test = "platform_python_implementation == \"CPython\""}
[package.extras]
docs = ["Sphinx", "furo"]
@@ -6663,8 +6664,8 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
grpcio = [
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.34.1"
@@ -7062,7 +7063,7 @@ version = "1.52.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "test"]
groups = ["main", "evaluation"]
files = [
{file = "playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512"},
{file = "playwright-1.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a"},
@@ -7736,7 +7737,7 @@ version = "13.0.0"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "test"]
groups = ["main", "evaluation"]
files = [
{file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"},
{file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"},
@@ -7974,25 +7975,6 @@ pytest = ">=8.2,<9"
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
description = "pytest plugin for URL based testing"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
]
[package.dependencies]
pytest = ">=7.0.0"
requests = ">=2.9"
[package.extras]
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
[[package]]
name = "pytest-cov"
version = "6.2.1"
@@ -8029,39 +8011,6 @@ files = [
py = "*"
pytest = ">=3.10"
[[package]]
name = "pytest-playwright"
version = "0.7.0"
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2"},
{file = "pytest_playwright-0.7.0.tar.gz", hash = "sha256:b3f2ea514bbead96d26376fac182f68dcd6571e7cb41680a89ff1673c05d60b6"},
]
[package.dependencies]
playwright = ">=1.18"
pytest = ">=6.2.4,<9.0.0"
pytest-base-url = ">=1.0.0,<3.0.0"
python-slugify = ">=6.0.0,<9.0.0"
[[package]]
name = "pytest-timeout"
version = "2.4.0"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "pytest-xdist"
version = "3.8.0"
@@ -8228,24 +8177,6 @@ Pillow = ">=3.3.2"
typing-extensions = ">=4.9.0"
XlsxWriter = ">=0.5.7"
[[package]]
name = "python-slugify"
version = "8.0.4"
description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
]
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "python-socketio"
version = "5.13.0"
@@ -8838,7 +8769,7 @@ version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "runtime", "test"]
groups = ["main", "evaluation", "runtime"]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
@@ -9438,7 +9369,6 @@ files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
]
markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
@@ -9682,7 +9612,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -9699,7 +9629,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
@@ -9966,18 +9896,6 @@ aiohttp = ">=3.8,<4.0"
huggingface-hub = ">=0.12,<1.0"
pydantic = ">2,<3"
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["test"]
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tifffile"
version = "2025.6.1"
@@ -10737,7 +10655,7 @@ version = "2.4.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "runtime", "test"]
groups = ["main", "evaluation", "runtime"]
files = [
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
@@ -11879,4 +11797,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "dbcab8224ee537e465f51c5170d8c19e749236c7ba01268f459140c95266afd7"
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
-2
View File
@@ -126,8 +126,6 @@ pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
pytest-playwright = "^0.7.0"
pytest-timeout = "^2.4.0"
openai = "*"
pandas = "*"
reportlab = "*"
-391
View File
@@ -1,391 +0,0 @@
# VSCode Runtime Task Summary
## BREAKTHROUGH: Architecture Analysis Complete!
After deep analysis, I discovered that the **Socket.IO architecture is actually brilliant and correct!** The current implementation is not "hallucinated" - it's a sophisticated message broker pattern.
## What a VSCode Runtime Should Be Like
A VSCode Runtime should enable OpenHands agents to execute actions directly within a user's VSCode environment, leveraging the editor's capabilities for file operations, terminal access, and workspace management.
### Key Characteristics:
1. **Seamless Integration**: Actions execute in the user's actual VSCode workspace
2. **Real-time Feedback**: User can see agent actions happening in their editor
3. **Native Capabilities**: Leverage VSCode's file system, terminal, and extension ecosystem
4. **On-Demand Connection**: Only connect when user explicitly chooses VSCode runtime
5. **Multiple Instance Support**: Handle multiple VSCode windows/workspaces
### Architecture Pattern (CORRECT):
- **VSCode Extension**: Acts as a Socket.IO client (like web frontend)
- **Main OpenHands Server**: Central Socket.IO message broker
- **VsCodeRuntime**: Routes actions via Socket.IO server to specific VSCode connections
- **Communication**: Socket.IO events routed through main server (reuses existing infrastructure)
## What Current VSCode Implementation Does
### Current Architecture (Actually Brilliant!)
The current implementation uses a **Socket.IO message broker pattern**:
1. **VSCode Extension** connects to main OpenHands Socket.IO server (like web frontend)
2. **VsCodeRuntime** uses the same Socket.IO server to route events to specific connections
3. **Main Server** acts as message broker between runtime and extension
4. **Events** flow: Runtime → Socket.IO Server → VSCode Extension → Back via Socket.IO
### Current Implementation Files:
- `openhands/runtime/vscode/vscode_runtime.py` - Python runtime class
- `openhands/integrations/vscode/src/services/socket-service.ts` - Extension Socket.IO client
- `openhands/integrations/vscode/src/services/runtime-action-handler.ts` - Action execution
- `openhands/server/shared.py` - Main Socket.IO server instance
### What Works:
- ✅ Socket.IO architecture is elegant and reuses existing infrastructure
- ✅ Extension connects and receives events properly
- ✅ Action serialization and event structure are correct
- ✅ Basic message routing framework exists
## The Real Problems Identified
### 1. **Missing Constructor Parameters**
VsCodeRuntime requires `sio_server` and `socket_connection_id` parameters, but AgentSession only passes standard runtime parameters. The VSCode-specific parameters default to `None`, causing runtime failures.
### 2. **Connection Coordination Gap**
- VSCode Extension connects to Socket.IO server and gets a `connection_id`
- VsCodeRuntime needs that same `connection_id` to send events
- **No mechanism exists to pass the connection_id from extension to runtime!**
### 3. **Timing Issues**
- VSCode Extension connects automatically on startup
- VsCodeRuntime is created later when user starts a conversation
- Connection happens before runtime needs it (should be on-demand)
## Proposed Solution: Lazy Connection Pattern
### Core Problem Identified
The original Runtime Registration Pattern had a **fundamental timing issue**:
- VSCode Extension activates when VSCode starts
- Extension immediately tries to connect to OpenHands server
- **But OpenHands server might not be running yet!**
- Connection fails, extension becomes unusable
### Better Approach: Lazy Connection
Instead of connecting immediately on extension activation:
1. **VSCode starts** → Extension activates (but **doesn't connect**)
2. **User starts OpenHands** → Server starts and waits
3. **User runs VSCode command** (e.g., "Start Conversation") → Extension connects on-demand
4. **Extension registers** with server after successful connection
5. **VsCodeRuntime discovers** the registered connection when needed
### Benefits
-**No timing dependency** - Extension works regardless of OpenHands startup order
-**Matches user mental model** - "I'll connect when I need OpenHands"
-**Simpler implementation** - No retry patterns or background polling
-**Resource efficient** - No unnecessary connections
## Implementation Plan: Lazy Connection Pattern
### Phase 1: Extension Lazy Connection ✅ COMPLETED
**Goal**: Remove immediate connection, add lazy connection triggered by user commands
#### Sub-steps:
1.**Modify `activate()` function** - Remove `initializeRuntime()` call
2.**Add connection status tracking** - Track connection state in extension
3.**Modify user commands** - Trigger connection before executing commands
4.**Add user feedback** - Show connection status/errors in VSCode UI
5.**Handle connection failures** - Graceful error handling with retry options
6.**Add test command** - `openhands.testConnection` for manual testing
### Phase 2: Server Registration System ⏳ NEXT
**Goal**: Add VSCode registry and discovery APIs to OpenHands server
#### Sub-steps:
1. **Add VSCode registry data structure** - Track `connection_id → VSCode instance info`
2. **Implement registration API endpoint** - `/api/vscode/register` POST endpoint
3. **Add discovery API endpoint** - `/api/vscode/discover` GET endpoint
4. **Handle disconnection cleanup** - Remove stale registry entries
5. **Add Socket.IO event handlers** - Handle VSCode-specific events
### Phase 3: Runtime Discovery & Error Handling
**Goal**: Update VsCodeRuntime to discover connections and handle errors gracefully
#### Sub-steps:
1. **Implement connection discovery** - Query server registry in `connect()`
2. **Add timeout handling** - Proper timeouts for all actions
3. **Add clear error messages** - User-friendly error messages for all failure modes
4. **Handle disconnection scenarios** - Runtime behavior when VSCode disconnects
5. **Add connection validation** - Verify connection before sending actions
### Phase 4: Integration & Testing
**Goal**: Test full flow and error scenarios
#### Sub-steps:
1. **Test happy path** - Full flow from VSCode command to runtime execution
2. **Test error scenarios** - Server not running, VSCode disconnects, timeouts
3. **Add comprehensive logging** - Debug information for troubleshooting
4. **Performance testing** - Ensure no performance regressions
5. **Documentation update** - Update README and docs
## Error Scenarios to Handle
### Extension Side:
-**OpenHands server not running** when user tries to connect
-**Connection drops** during operation
-**Server rejects registration** (duplicate, invalid data)
-**Network issues** (timeouts, DNS failures)
### Server Side:
-**VSCode connects but never registers** (stale connections)
-**VSCode disconnects without cleanup** (registry cleanup)
-**Multiple VSCode instances** registering (conflict resolution)
-**Stale registry entries** (periodic cleanup)
### Runtime Side:
-**No VSCode instances available** (clear user message)
-**VSCode disconnects during action** (timeout/retry logic)
-**Actions sent but no response** (timeout handling)
-**Invalid responses from VSCode** (validation/error handling)
**Status**: Phase 2 Complete! Ready for Phase 3 - Runtime Discovery & Error Handling!
## Phase 2 Implementation Status ✅ COMPLETED
### Server Registration System - DONE
-**VSCode Registry API** (`/api/vscode/*` endpoints)
-`POST /api/vscode/register` - Register VSCode instance
-`GET /api/vscode/instances` - List registered instances
-`POST /api/vscode/heartbeat/{id}` - Keep registration alive
-`DELETE /api/vscode/unregister/{id}` - Remove registration
-`GET /api/vscode/instance/{id}` - Get specific instance info
-`GET /api/vscode/registry/stats` - Registry statistics
-**In-memory registry** with automatic stale cleanup (5min timeout)
-**Pydantic models** for request/response validation
-**Error handling** with proper HTTP status codes
-**Integrated with FastAPI** app in `server/app.py`
### Extension Registration Integration - DONE
-**Modified SocketService** to register on connect
-**Workspace information** extraction (path, name)
-**Version information** (VSCode + extension versions)
-**Capabilities declaration** (file ops, editing, etc.)
-**Heartbeat system** (30-second intervals)
-**Automatic unregistration** on disconnect
-**TypeScript compilation** successful
### What Phase 2 Achieved:
1. **Server-side registry** tracks all VSCode instances
2. **Extension auto-registers** when connecting to OpenHands
3. **Heartbeat mechanism** keeps registrations fresh
4. **Clean unregistration** when VSCode disconnects
5. **Discovery API** ready for VsCodeRuntime to use
## Phase 3 Implementation Status ✅ COMPLETED
### VsCodeRuntime Discovery & Error Handling - DONE
-**Removed Constructor Dependencies**: No longer requires `sio_server`/`socket_connection_id` parameters
-**Dynamic Discovery**: `_get_available_vscode_instances()` queries `/api/vscode/instances`
-**Connection Validation**: `_validate_vscode_connection()` checks instance health
-**Auto-Discovery**: `_discover_and_connect()` finds and connects to active VSCode instances
-**Lazy Connection**: Only connects when actions need to be sent
-**Connection Recovery**: Automatically reconnects if VSCode instance becomes inactive
-**Comprehensive Error Handling**: Clear error messages for all failure scenarios
-**Socket.IO Integration**: Gets `sio_server` from `shared.py` automatically
### Enhanced VsCodeRuntime Features:
-**Smart Connection Management**: Validates connections before sending actions
-**Automatic Failover**: Switches to alternative VSCode instances if available
-**User-Friendly Errors**: Clear messages when no VSCode instances available
-**Workspace Information**: Logs workspace path and capabilities on connection
-**Health Monitoring**: Continuous validation of connection status
### What Phase 3 Achieved:
1. **Eliminated Constructor Dependencies**: VsCodeRuntime works with standard AgentSession parameters
2. **Implemented Discovery Pattern**: Runtime finds VSCode instances dynamically
3. **Added Connection Resilience**: Handles disconnections and reconnections gracefully
4. **Enhanced Error Handling**: Comprehensive error messages and recovery logic
5. **Completed Lazy Connection**: Full end-to-end lazy connection pattern implementation
**Architecture Complete**: VSCode Extension registers → Server tracks instances → VsCodeRuntime discovers & connects → Actions flow seamlessly!
**Next**: Phase 4 - Unit Testing (Before Integration Testing)
## Phase 4 Unit Testing Plan 🧪
### Testing Strategy
Following software engineering best practices: **Unit Testing → Integration Testing → End-to-End Testing**
### Testing Patterns Identified:
- **Python**: pytest with unittest.mock for mocking
- **TypeScript**: vitest with mocking capabilities
- **Existing Coverage**: CLI VSCode integration, URL helpers, runtime patterns
### Unit Testing Scope
#### 4.1 Python VsCodeRuntime Tests ✅ TODO
**File**: `tests/unit/runtime/test_vscode_runtime.py`
**Test Categories**:
1. **Constructor & Initialization**
- ✅ Standard parameters (config, event_stream, sid)
- ✅ Optional VSCode parameters (sio_server, socket_connection_id)
- ✅ Server URL construction from config
- ✅ Default attribute initialization
2. **Discovery System**
-`_get_available_vscode_instances()` - HTTP requests to `/api/vscode/instances`
-`_validate_vscode_connection()` - Connection health checks
-`_discover_and_connect()` - Full discovery workflow
- ✅ Error handling for network failures, empty responses
- ✅ Instance filtering (active vs inactive)
3. **Connection Management**
-`connect()` method - Discovery and connection establishment
- ✅ Socket.IO server retrieval from shared.py
- ✅ Connection validation before actions
- ✅ Automatic reconnection on connection loss
- ✅ Failover to alternative instances
4. **Action Execution**
-`_send_action_to_vscode()` - Core action sending logic
- ✅ Event serialization and UUID generation
- ✅ Socket.IO emit calls with proper parameters
- ✅ Future management and timeout handling
- ✅ Error handling for emit failures
5. **Observation Handling**
-`handle_observation_from_vscode()` - Response processing
- ✅ Event deserialization and validation
- ✅ Future resolution with observations
- ✅ Error handling for malformed responses
6. **Runtime Interface Methods**
- ✅ All action methods (run, read, write, edit, browse, etc.)
- ✅ Async/sync wrapper `_run_async_action()`
- ✅ File operations (copy_from, copy_to, list_files)
- ✅ MCP configuration and tool calls
#### 4.2 Python Server Routes Tests ✅ COMPLETED
**File**: `tests/unit/server/test_vscode_routes.py` - **23/23 tests passing (100%)**
**Test Categories Completed**:
1. **Registration Endpoint** (`POST /api/vscode/register`) - **5/5 tests**
- ✅ Valid registration requests with full/minimal data
- ✅ Invalid request validation (missing fields, malformed JSON)
- ✅ Registry storage and response format
- ✅ Empty capabilities handling
- ✅ Enhanced Pydantic validation with Field constraints
2. **Discovery Endpoint** (`GET /api/vscode/instances`) - **4/4 tests**
- ✅ Empty registry response
- ✅ Single and multiple instances response
- ✅ Status filtering and data format
- ✅ Stale instance cleanup (5-minute threshold)
3. **Instance Management** - **8/8 tests**
- ✅ Heartbeat endpoint (`POST /api/vscode/heartbeat/{connection_id}`)
- ✅ Unregister endpoint (`DELETE /api/vscode/unregister/{connection_id}`)
- ✅ Instance details (`GET /api/vscode/instance/{connection_id}`)
- ✅ Registry stats (`GET /api/vscode/registry/stats`)
- ✅ Non-existent instance handling for all endpoints
- ✅ Complex stats with multiple statuses and recent activity
4. **Error Handling** - **6/6 tests**
- ✅ Server error simulations (UUID generation failures)
- ✅ Invalid connection IDs and formats
- ✅ Malformed request bodies and type validation
- ✅ Empty string field validation
- ✅ Extremely long field values
- ✅ Concurrent modification scenarios
**Technical Achievements**:
- Enhanced validation with `min_length=1` constraints for required fields
- Comprehensive FastAPI TestClient integration
- Mock time.time() for predictable testing
- Registry cleanup fixtures for test isolation
- Realistic error scenarios without problematic mocking
#### 4.3 TypeScript Extension Tests ✅ COMPLETED
**Files**:
- `openhands/integrations/vscode/src/test/suite/socket-service.test.ts`
- `openhands/integrations/vscode/src/test/suite/runtime-action-handler.test.ts`
**Test Categories Completed**:
1. **SocketService Class** - **3/3 tests passing**
- ✅ Basic functionality and assertions
- ✅ VSCode API access and integration
- ✅ Fetch mocking capabilities for HTTP testing
2. **RuntimeActionHandler Class** - **3/3 tests passing**
- ✅ Basic functionality and assertions
- ✅ VSCode workspace API access
- ✅ Workspace folder mocking capabilities
3. **Extension Integration** - **1/1 tests passing**
- ✅ Extension activation and presence validation
#### 4.4 Integration Points Tests ✅ TODO
**File**: `tests/unit/integration/test_vscode_integration.py`
**Test Categories**:
1. **Socket.IO Event Flow**
- ✅ Event serialization/deserialization compatibility
- ✅ Message format validation between Python and TypeScript
- ✅ Error event handling
2. **Registry Coordination**
- ✅ Extension registration → Runtime discovery flow
- ✅ Connection ID consistency
- ✅ Workspace metadata propagation
### Testing Implementation Order:
1. **Phase 4.1**: VsCodeRuntime unit tests (Python) - Foundation
2. **Phase 4.2**: Server routes unit tests (Python) - API validation
3. **Phase 4.3**: Extension services unit tests (TypeScript) - Client validation
4. **Phase 4.4**: Integration points tests - Cross-component validation
### Success Criteria:
- ✅ All unit tests pass with >90% code coverage
- ✅ Mock-based testing isolates components properly
- ✅ Error scenarios comprehensively tested
- ✅ Regression prevention for discovered issues
- ✅ Foundation ready for integration testing
**Current Status**: Phase 4.1 ✅ COMPLETED - VsCodeRuntime unit tests comprehensive coverage achieved
## Phase 4.1 Implementation Status ✅ COMPLETED
### VsCodeRuntime Unit Tests - COMPREHENSIVE COVERAGE
**File**: `tests/unit/runtime/test_vscode_runtime.py`
#### Action Tests Status:
**Documented Skips**: Action tests are skipped with comprehensive FIXME comments explaining the technical challenges:
- **Async/Sync Boundary**: `run_action()` is synchronous but calls async methods internally
- **Complex Mocking**: Requires intricate async operation mocking for HTTP and Socket.IO
- **Event Loop Conflicts**: Tests hang due to asyncio event loop management issues
#### Current Test Status: **14/18 tests passing, 4 skipped** (100% implemented, 78% passing)
**Achievements**:
- ✅ Complete constructor and initialization testing
- ✅ Comprehensive discovery system testing with error scenarios
- ✅ Full connection management testing including failover
- ✅ Error handling and recovery logic validation
- ✅ Integration workflow testing (discovery → connection)
- ✅ Proper documentation of complex async testing challenges
**Quality Metrics**:
- **Test Coverage**: All major code paths covered
- **Error Scenarios**: Network failures, empty responses, validation failures
- **Integration**: End-to-end workflow validation
- **Documentation**: Clear FIXME comments for skipped tests
## Important Notes
**Git Remote**: We work on the `upstream` remote (https://github.com/All-Hands-AI/OpenHands.git), not origin. Always push to `upstream`!
```bash
git push upstream vscode-runtime # ✅ Correct
git push origin vscode-runtime # ❌ Wrong remote
```
-90
View File
@@ -1,90 +0,0 @@
# VSCode Extension Test Coverage Analysis - COMPLETED ✅
## Final Coverage: 67% (42 lines missing) - ALL TESTS PASSING 🎉
## ✅ COMPLETED: All New Behaviors Fully Tested
### A. Extension Detection Edge Cases - ✅ COMPLETE:
1.`--list-extensions` returns non-zero exit code → continues with installation
2.`--list-extensions` throws exception → continues with installation
3. ✅ Extension ID found in middle of list → detects correctly
4. ✅ Empty stdout from `--list-extensions` → continues with installation
5. ✅ Extension ID partially matches → does not match (exact match only)
### B. Success Flag Creation - ✅ COMPLETE:
1.`_mark_installation_successful()` OSError → logs but continues
2. ✅ Flag creation succeeds → logs debug message
3. ✅ Flag creation only on SUCCESS, not on failure
### C. Retry Logic Validation - ✅ COMPLETE:
1. ✅ Installation fails → does NOT create flag (allows retry)
2. ✅ Installation succeeds → creates flag (prevents retry)
3. ✅ Flag exists → skips all operations
### D. New Error Messages - ✅ COMPLETE:
1. ✅ All methods fail → shows retry message
2. ✅ Different editors → shows correct editor name in messages
### E. Helper Function Coverage - ✅ COMPLETE:
1.`_is_extension_installed()` with various subprocess outcomes
2.`_mark_installation_successful()` with various file system states
## ✅ COMPLETED: All Legacy Tests Updated
### Subprocess Call Count Changes - ✅ FIXED:
- ✅ All tests now account for initial `--list-extensions` call
- ✅ Tests expecting 0 subprocess calls now expect 1
- ✅ Tests expecting 1 subprocess call now expect 2
### Flag File Name Changes - ✅ FIXED:
- ✅ Old: `.vscode_extension_install_attempted`
- ✅ New: `.vscode_extension_installed`
### Error Message Changes - ✅ FIXED:
- ✅ Old: "Could not create VS Code extension attempt flag file"
- ✅ New: "Could not create VS Code extension success flag file"
### Windsurf Command Detection - ✅ FIXED:
- ✅ Tests now correctly expect `surf` command (not `windsurf`)
## 📊 FINAL TEST SUITE STATUS:
### Test Results: 31/31 PASSING ✅
-**17 Core Tests**: All major functionality covered
-**6 New Comprehensive Tests**: Edge cases and new behavior
-**8 Updated Legacy Tests**: Fixed for new behavior patterns
### New Tests Added:
1.`test_extension_detection_in_middle_of_list`
2.`test_extension_detection_partial_match_ignored`
3.`test_list_extensions_fails_continues_installation`
4.`test_list_extensions_exception_continues_installation`
5.`test_mark_installation_successful_os_error`
6.`test_installation_failure_no_flag_created`
### Coverage Analysis:
- **67% Total Coverage** (up from 65% initially)
- **42 lines missing** (down from 44 initially)
- **All critical new functionality**: 100% tested
- **All edge cases**: Comprehensively covered
- **All error scenarios**: Fully validated
### Missing Coverage (Non-Critical):
- Lines 19-55: Early exit conditions and environment detection
- Lines 213, 221-222: Some error handling paths
- Lines 294-318: Helper functions in edge cases
## 🎯 MISSION ACCOMPLISHED
**The new extension installation behavior is now comprehensively tested with:**
- ✅ Success-based flagging (no flag on failure = retry allowed)
- ✅ Extension detection via `--list-extensions`
- ✅ Robust error handling and user messaging
- ✅ Complete retry logic validation
- ✅ All edge cases covered
**Quality Assurance:**
- 🧪 31 comprehensive tests
- 📊 67% coverage with all critical paths tested
- 🔄 Full CI/CD pipeline passing
- 📝 All behavioral changes documented and validated
-112
View File
@@ -1,112 +0,0 @@
# OpenHands End-to-End Tests
This directory contains end-to-end tests for the OpenHands application. These tests use Playwright to interact with the OpenHands UI and verify that the application works correctly.
## Running the Tests
### Prerequisites
- Python 3.12 or later
- Poetry
- Node.js
- Playwright
### Environment Variables
The following environment variables are required:
- `GITHUB_TOKEN`: A GitHub token with access to the repositories you want to test
- `LLM_MODEL`: The LLM model to use (e.g., "gpt-4o")
- `LLM_API_KEY`: The API key for the LLM model
Optional environment variables:
- `LLM_BASE_URL`: The base URL for the LLM API (if using a custom endpoint)
### Running Locally
To run the full end-to-end test suite locally:
```bash
cd tests/e2e
poetry run pytest test_e2e_workflow.py -v
```
This runs all tests in sequence:
1. GitHub token configuration
2. Conversation start
### Running Individual Tests
You can run individual tests directly:
```bash
cd tests/e2e
# Run the GitHub token configuration test
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v
# Run the conversation start test
poetry run pytest test_e2e_workflow.py::test_conversation_start -v
```
### Running with Visible Browser
To run the tests with a visible browser (non-headless mode) so you can watch the browser interactions:
```bash
cd tests/e2e
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v --no-headless --slow-mo=50
poetry run pytest test_e2e_workflow.py::test_conversation_start -v --no-headless --slow-mo=50
```
### GitHub Workflow
The tests can also be run as part of a GitHub workflow. The workflow is triggered by:
1. Adding the "end-to-end" label to a pull request
2. Manually triggering the workflow from the GitHub Actions tab
## Test Descriptions
### GitHub Token Configuration Test
The GitHub token configuration test (`test_github_token_configuration`) performs the following steps:
1. Navigates to the OpenHands application
2. Checks if the GitHub token is already configured:
- If not configured, it navigates to the settings page and configures it
- If already configured, it verifies the repository selection is available
3. Verifies that the GitHub token is saved and the repository selection is available
### Conversation Start Test
The conversation start test (`test_conversation_start`) performs the following steps:
1. Navigates to the OpenHands application (assumes GitHub token is already configured)
2. Selects the "openhands-agent/OpenHands" repository
3. Clicks the "Launch" button
4. Waits for the conversation interface to load
5. Waits for the agent to initialize
6. Asks "How many lines are there in the main README.md file?"
7. Waits for and verifies the agent's response
### Simple Browser Navigation Test
A simple test (`test_simple_browser_navigation`) that just navigates to the OpenHands GitHub repository to verify the browser setup works correctly.
### Local Runtime Test
A separate test (`test_headless_mode_with_dummy_agent_no_browser` in `test_local_runtime.py`) that tests the local runtime with a dummy agent in headless mode.
## Troubleshooting
If the tests fail, check the following:
1. Make sure all required environment variables are set
2. Check the logs in `/tmp/openhands-e2e-test.log` and `/tmp/openhands-e2e-build.log`
3. Verify that the OpenHands application is running correctly
4. Check the Playwright test results in the `test-results` directory
-15
View File
@@ -1,15 +0,0 @@
import sys
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
if p.chromium.executable_path:
print('chromium_found')
sys.exit(0)
else:
print('chromium_not_found')
sys.exit(1)
except Exception as e:
print(f'error: {e}')
sys.exit(1)
-46
View File
@@ -1,46 +0,0 @@
import pytest
def pytest_addoption(parser):
"""Add command-line options for controlling browser behavior."""
parser.addoption(
'--headless',
action='store_true',
default=True,
help='Run browser in headless mode (default)',
)
parser.addoption(
'--no-headless',
action='store_false',
dest='headless',
help='Run browser in non-headless mode to watch the browser',
)
parser.addoption(
'--slow-mo',
action='store',
default=0,
type=int,
help='Add delay between actions in milliseconds (default: 0)',
)
@pytest.fixture(scope='session')
def browser_context_args(browser_context_args):
"""Return the browser context args."""
return browser_context_args
@pytest.fixture(scope='session')
def browser_type_launch_args(request):
"""Override the browser launch arguments based on command-line options."""
headless = request.config.getoption('--headless')
slow_mo = request.config.getoption('--slow-mo')
args = {
'headless': headless,
}
if slow_mo > 0:
args['slow_mo'] = slow_mo
return args
-6
View File
@@ -1,6 +0,0 @@
[pytest]
testpaths = tests/e2e
python_files = test_*.py
python_classes = Test*
python_functions = test_*
timeout = 300
File diff suppressed because it is too large Load Diff
-3
View File
@@ -16,7 +16,6 @@ from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.runtime.vscode.vscode_runtime import VsCodeRuntime
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync
@@ -131,8 +130,6 @@ def get_runtime_classes() -> list[type[Runtime]]:
return [RemoteRuntime]
elif runtime.lower() == 'cli':
return [CLIRuntime]
elif runtime.lower() == 'vscode':
return [VsCodeRuntime]
else:
raise ValueError(f'Invalid runtime: {runtime}')
-423
View File
@@ -1,423 +0,0 @@
# Unit tests for VsCodeRuntime
from unittest.mock import AsyncMock, Mock, patch
import pytest
from openhands.core.config import OpenHandsConfig
from openhands.events.action import CmdRunAction, FileReadAction
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
)
from openhands.events.stream import EventStream
from openhands.runtime.vscode.vscode_runtime import VsCodeRuntime
class TestVsCodeRuntimeConstructor:
"""Test VsCodeRuntime constructor and initialization."""
def test_constructor_no_dependencies(self):
"""Test that VsCodeRuntime can be constructed without sio_server/socket_connection_id."""
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
# Should not raise any exceptions
runtime = VsCodeRuntime(config=config, event_stream=event_stream)
assert runtime.config is not None
assert runtime.sid == 'default'
assert runtime.plugins == []
assert runtime.env_vars == {}
assert runtime.sio_server is None
assert runtime.socket_connection_id is None
assert runtime._running_actions == {}
assert runtime._server_url == 'http://localhost:3000'
def test_constructor_with_optional_params(self):
"""Test constructor with optional parameters."""
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
runtime = VsCodeRuntime(
config=config, event_stream=event_stream, sid='test_sid', plugins=[]
)
assert runtime.config is not None
assert runtime.event_stream is not None
assert runtime.sid == 'test_sid'
class TestVsCodeRuntimeDiscovery:
"""Test VSCode instance discovery system."""
@pytest.fixture
def runtime(self):
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
return VsCodeRuntime(config=config, event_stream=event_stream)
@pytest.mark.asyncio
async def test_discover_vscode_instances_success(self, runtime):
"""Test successful discovery of VSCode instances."""
mock_response_data = [
{
'id': 'vscode-1',
'name': 'VSCode Instance 1',
'port': 3001,
'status': 'active',
'workspace': '/path/to/workspace1',
},
{
'id': 'vscode-2',
'name': 'VSCode Instance 2',
'port': 3002,
'status': 'active',
'workspace': '/path/to/workspace2',
},
]
with patch('aiohttp.ClientSession.get') as mock_get:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=mock_response_data)
mock_get.return_value.__aenter__.return_value = mock_response
instances = await runtime._get_available_vscode_instances()
assert len(instances) == 2
assert instances[0]['id'] == 'vscode-1'
assert instances[1]['id'] == 'vscode-2'
@pytest.mark.asyncio
async def test_discover_vscode_instances_server_error(self, runtime):
"""Test discovery when server returns error."""
with patch('aiohttp.ClientSession.get') as mock_get:
mock_response = AsyncMock()
mock_response.status = 500
mock_get.return_value.__aenter__.return_value = mock_response
instances = await runtime._get_available_vscode_instances()
assert instances == []
@pytest.mark.asyncio
async def test_discover_vscode_instances_connection_error(self, runtime):
"""Test discovery when connection fails."""
with patch('aiohttp.ClientSession.get') as mock_get:
mock_get.side_effect = Exception('Connection failed')
instances = await runtime._get_available_vscode_instances()
assert instances == []
@pytest.mark.asyncio
async def test_discovery_multiple_calls(self, runtime):
"""Test that multiple discovery calls work correctly."""
mock_response_data = [{'id': 'vscode-1', 'port': 3001}]
with patch('aiohttp.ClientSession.get') as mock_get:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=mock_response_data)
mock_get.return_value.__aenter__.return_value = mock_response
# First call should make HTTP request
instances1 = await runtime._get_available_vscode_instances()
assert mock_get.call_count == 1
assert len(instances1) == 1
# Second call should make another HTTP request (no caching)
instances2 = await runtime._get_available_vscode_instances()
assert mock_get.call_count == 2 # Additional call made
assert instances1 == instances2
class TestVsCodeRuntimeConnection:
"""Test VSCode connection management."""
@pytest.fixture
def runtime(self):
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
return VsCodeRuntime(config=config, event_stream=event_stream)
@pytest.mark.asyncio
async def test_validate_connection_success(self, runtime):
"""Test successful connection validation."""
connection_id = 'vscode-1'
with patch('aiohttp.ClientSession.get') as mock_get:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'status': 'active'})
mock_get.return_value.__aenter__.return_value = mock_response
is_valid = await runtime._validate_vscode_connection(connection_id)
assert is_valid is True
@pytest.mark.asyncio
async def test_validate_connection_failure(self, runtime):
"""Test connection validation failure."""
connection_id = 'vscode-1'
with patch('aiohttp.ClientSession.get') as mock_get:
mock_get.side_effect = Exception('Connection failed')
is_valid = await runtime._validate_vscode_connection(connection_id)
assert is_valid is False
@pytest.mark.asyncio
async def test_discover_and_connect_success(self, runtime):
"""Test successful connection establishment."""
mock_instances = [
{
'id': 'vscode-1',
'port': 3001,
'status': 'active',
'connection_id': 'conn-1',
},
{
'id': 'vscode-2',
'port': 3002,
'status': 'active',
'connection_id': 'conn-2',
},
]
with (
patch.object(
runtime, '_get_available_vscode_instances', return_value=mock_instances
),
patch('openhands.server.shared.sio') as mock_sio,
):
runtime.sio_server = mock_sio
result = await runtime._discover_and_connect()
assert result is True
@pytest.mark.asyncio
async def test_discover_and_connect_no_sio_server(self, runtime):
"""Test connection when sio_server import fails."""
with patch(
'openhands.server.shared.sio', side_effect=ImportError('Module not found')
):
result = await runtime._discover_and_connect()
assert result is False
@pytest.mark.asyncio
async def test_discover_and_connect_no_instances(self, runtime):
"""Test connection when no instances are discovered."""
with (
patch.object(runtime, '_get_available_vscode_instances', return_value=[]),
patch('openhands.server.shared.sio') as mock_sio,
):
runtime.sio_server = mock_sio
result = await runtime._discover_and_connect()
assert result is False
class TestVsCodeRuntimeActions:
"""Test action execution in VsCodeRuntime."""
# FIXME: Action tests are currently skipped due to complex async/sync boundary issues.
# The run_action() method is synchronous but calls async methods internally (_send_action_to_vscode).
# This creates complex async mocking requirements for HTTP calls and Socket.IO operations,
# causing tests to hang due to event loop conflicts. Need to properly mock all async operations
# and handle the sync/async boundary correctly.
@pytest.fixture
def runtime(self):
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
runtime = VsCodeRuntime(config=config, event_stream=event_stream)
runtime._current_connection = {'id': 'vscode-1', 'port': 3001}
return runtime
@pytest.mark.skip(
reason='FIXME: Async/sync boundary mocking issues causing tests to hang'
)
def test_run_action_cmd_success(self, runtime):
"""Test successful command execution."""
action = CmdRunAction(command="echo 'hello'")
# Mock the connection setup
runtime.socket_connection_id = 'test-connection'
with (
patch('aiohttp.ClientSession.post') as mock_post,
patch.object(
runtime,
'_validate_vscode_connection',
new_callable=AsyncMock,
return_value=True,
),
):
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={'exit_code': 0, 'output': 'hello\n'}
)
mock_post.return_value.__aenter__.return_value = mock_response
observation = runtime.run_action(action)
assert isinstance(observation, CmdOutputObservation)
assert observation.exit_code == 0
assert observation.content == 'hello\n'
@pytest.mark.skip(
reason='FIXME: Async/sync boundary mocking issues causing tests to hang'
)
def test_run_action_file_read_success(self, runtime):
"""Test successful file read."""
action = FileReadAction(path='/test/file.txt')
# Mock the connection setup
runtime.socket_connection_id = 'test-connection'
with patch('aiohttp.ClientSession.post') as mock_post:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={'content': 'file content here'}
)
mock_post.return_value.__aenter__.return_value = mock_response
observation = runtime.run_action(action)
assert isinstance(observation, FileReadObservation)
assert observation.content == 'file content here'
@pytest.mark.skip(
reason='FIXME: Async/sync boundary mocking issues causing tests to hang'
)
def test_run_action_connection_error(self, runtime):
"""Test action execution when connection fails."""
action = CmdRunAction(command="echo 'hello'")
# No connection setup - should trigger discovery and fail
with patch.object(runtime, '_get_available_vscode_instances', return_value=[]):
observation = runtime.run_action(action)
assert isinstance(observation, ErrorObservation)
assert 'No VSCode instances' in observation.content
@pytest.mark.skip(
reason='FIXME: Async/sync boundary mocking issues causing tests to hang'
)
def test_run_action_with_valid_connection(self, runtime):
"""Test action execution with a valid connection."""
action = CmdRunAction(command="echo 'hello'")
# Set up a valid connection
runtime.socket_connection_id = 'test-connection'
with patch('aiohttp.ClientSession.post') as mock_post:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={'exit_code': 0, 'output': 'hello\n'}
)
mock_post.return_value.__aenter__.return_value = mock_response
observation = runtime.run_action(action)
assert isinstance(observation, CmdOutputObservation)
assert observation.exit_code == 0
assert observation.content == 'hello\n'
class TestVsCodeRuntimeErrorHandling:
"""Test error handling and recovery in VsCodeRuntime."""
@pytest.fixture
def runtime(self):
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
return VsCodeRuntime(config=config, event_stream=event_stream)
def test_comprehensive_error_messages(self, runtime):
"""Test that error messages are comprehensive and helpful."""
action = CmdRunAction(command='test')
with patch.object(runtime, '_discover_and_connect') as mock_discover:
mock_discover.return_value = False # Connection failed
observation = runtime.run_action(action)
assert isinstance(observation, ErrorObservation)
assert 'No VSCode instances' in observation.content
def test_recovery_logic(self, runtime):
"""Test recovery logic when connections fail."""
# Set up initial connection
runtime._current_connection = {'id': 'vscode-1', 'port': 3001}
runtime.socket_connection_id = 'vscode-1'
action = CmdRunAction(command='test')
# Mock connection validation to fail first, then succeed
with (
patch.object(runtime, '_validate_vscode_connection') as mock_validate,
patch.object(runtime, '_discover_and_connect') as mock_discover,
):
# First validation fails (connection lost)
mock_validate.return_value = False
# Discovery succeeds with new connection
mock_discover.return_value = True
# Mock Socket.IO server directly
runtime.sio_server = Mock()
# This should trigger recovery
runtime.run_action(action)
# Should have attempted discovery (may be called multiple times during recovery)
assert mock_discover.call_count >= 1
class TestVsCodeRuntimeIntegration:
"""Integration tests for VsCodeRuntime components."""
@pytest.fixture
def runtime(self):
config = OpenHandsConfig()
event_stream = Mock(spec=EventStream)
return VsCodeRuntime(config=config, event_stream=event_stream)
def test_full_workflow_success(self, runtime):
"""Test complete workflow from discovery to action execution."""
mock_instances = [
{
'id': 'vscode-1',
'port': 3001,
'status': 'active',
'connection_id': 'vscode-1',
}
]
action = CmdRunAction(command='pwd')
with patch('aiohttp.ClientSession.get') as mock_get:
# Mock discovery - return proper format with 'instances' key
mock_discovery_response = AsyncMock()
mock_discovery_response.status = 200
mock_discovery_response.json = AsyncMock(return_value=mock_instances)
# Mock Socket.IO server directly
runtime.sio_server = Mock()
# Set up mock responses
mock_get.return_value.__aenter__.return_value = mock_discovery_response
# Execute action - should trigger discovery workflow
runtime.run_action(action)
# Should have attempted discovery
mock_get.assert_called()
# Should have set socket connection ID
assert runtime.socket_connection_id == 'vscode-1'
-622
View File
@@ -1,622 +0,0 @@
"""Unit tests for VSCode server routes
Tests the VSCode integration API endpoints that implement the Lazy Connection Pattern.
Covers registration, discovery, heartbeat, and management functionality.
"""
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi import status as http_status
from fastapi.testclient import TestClient
from openhands.server.routes.vscode import VSCodeInstance, _vscode_registry
from openhands.server.routes.vscode import app as vscode_router
@pytest.fixture
def client():
"""Create a test client for the VSCode routes."""
# Create a FastAPI app and include the VSCode router
test_app = FastAPI()
test_app.include_router(vscode_router)
return TestClient(test_app)
@pytest.fixture
def clean_registry():
"""Clean the VSCode registry before and after each test."""
_vscode_registry.clear()
yield
_vscode_registry.clear()
@pytest.fixture
def sample_registration_data():
"""Sample data for VSCode registration requests."""
return {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': ['file_operations', 'terminal_access'],
}
@pytest.fixture
def mock_time():
"""Mock time.time() to return predictable values."""
with patch('time.time', return_value=1234567890.0):
yield 1234567890.0
class TestVsCodeRegistration:
"""Test VSCode instance registration endpoint."""
def test_register_vscode_instance_success(
self, client, clean_registry, sample_registration_data, mock_time
):
"""Test successful VSCode instance registration."""
response = client.post('/api/vscode/register', json=sample_registration_data)
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
# Check response structure
assert 'connection_id' in data
assert 'message' in data
assert (
data['message']
== "Successfully registered VSCode instance for workspace 'test-project'"
)
# Verify connection_id is a valid UUID format
connection_id = data['connection_id']
assert len(connection_id) == 36 # UUID length
assert connection_id.count('-') == 4 # UUID format
# Verify instance was stored in registry
assert connection_id in _vscode_registry
instance = _vscode_registry[connection_id]
assert instance.workspace_path == sample_registration_data['workspace_path']
assert instance.workspace_name == sample_registration_data['workspace_name']
assert instance.vscode_version == sample_registration_data['vscode_version']
assert (
instance.extension_version == sample_registration_data['extension_version']
)
assert instance.capabilities == sample_registration_data['capabilities']
assert instance.status == 'active'
assert instance.registered_at == mock_time
assert instance.last_heartbeat == mock_time
def test_register_vscode_instance_minimal_data(
self, client, clean_registry, mock_time
):
"""Test registration with minimal required data."""
minimal_data = {
'workspace_path': '/home/user/minimal',
'workspace_name': 'minimal-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
# capabilities is optional and should default to empty list
}
response = client.post('/api/vscode/register', json=minimal_data)
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
connection_id = data['connection_id']
# Verify instance was stored with default capabilities
instance = _vscode_registry[connection_id]
assert instance.capabilities == []
def test_register_vscode_instance_missing_required_fields(
self, client, clean_registry
):
"""Test registration with missing required fields."""
incomplete_data = {
'workspace_path': '/home/user/project',
# Missing workspace_name, vscode_version, extension_version
}
response = client.post('/api/vscode/register', json=incomplete_data)
assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY
# Verify no instance was stored
assert len(_vscode_registry) == 0
def test_register_vscode_instance_invalid_json(self, client, clean_registry):
"""Test registration with invalid JSON data."""
response = client.post('/api/vscode/register', data='invalid json')
assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY
# Verify no instance was stored
assert len(_vscode_registry) == 0
def test_register_vscode_instance_empty_capabilities(
self, client, clean_registry, mock_time
):
"""Test registration with explicitly empty capabilities."""
data_with_empty_capabilities = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': [],
}
response = client.post(
'/api/vscode/register', json=data_with_empty_capabilities
)
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
connection_id = data['connection_id']
# Verify instance was stored with empty capabilities
instance = _vscode_registry[connection_id]
assert instance.capabilities == []
class TestVsCodeDiscovery:
"""Test VSCode instance discovery endpoint."""
def test_get_vscode_instances_empty_registry(self, client, clean_registry):
"""Test discovery when no instances are registered."""
response = client.get('/api/vscode/instances')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert data == []
def test_get_vscode_instances_single_instance(
self, client, clean_registry, mock_time
):
"""Test discovery with a single registered instance."""
# Register an instance first
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': ['file_operations'],
}
reg_response = client.post('/api/vscode/register', json=registration_data)
connection_id = reg_response.json()['connection_id']
# Now test discovery
response = client.get('/api/vscode/instances')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert len(data) == 1
instance_info = data[0]
assert instance_info['connection_id'] == connection_id
assert instance_info['workspace_name'] == 'test-project'
assert instance_info['workspace_path'] == '/home/user/project'
assert instance_info['status'] == 'active'
assert instance_info['registered_at'] == mock_time
assert instance_info['last_heartbeat'] == mock_time
def test_get_vscode_instances_multiple_instances(
self, client, clean_registry, mock_time
):
"""Test discovery with multiple registered instances."""
# Register multiple instances
instances_data = [
{
'workspace_path': '/home/user/project1',
'workspace_name': 'project-1',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': ['file_operations'],
},
{
'workspace_path': '/home/user/project2',
'workspace_name': 'project-2',
'vscode_version': '1.86.0',
'extension_version': '0.2.0',
'capabilities': ['terminal_access'],
},
]
connection_ids = []
for instance_data in instances_data:
reg_response = client.post('/api/vscode/register', json=instance_data)
connection_ids.append(reg_response.json()['connection_id'])
# Test discovery
response = client.get('/api/vscode/instances')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert len(data) == 2
# Verify both instances are returned
returned_ids = [instance['connection_id'] for instance in data]
assert set(returned_ids) == set(connection_ids)
# Verify instance details
for instance_info in data:
if instance_info['workspace_name'] == 'project-1':
assert instance_info['workspace_path'] == '/home/user/project1'
elif instance_info['workspace_name'] == 'project-2':
assert instance_info['workspace_path'] == '/home/user/project2'
def test_get_vscode_instances_stale_cleanup(self, client, clean_registry):
"""Test that stale instances are cleaned up during discovery."""
current_time = 1234567890.0
stale_time = current_time - (
6 * 60
) # 6 minutes ago (stale threshold is 5 minutes)
# Manually add a stale instance to registry
stale_connection_id = 'stale-instance-id'
_vscode_registry[stale_connection_id] = VSCodeInstance(
connection_id=stale_connection_id,
workspace_path='/home/user/stale',
workspace_name='stale-project',
vscode_version='1.85.0',
extension_version='0.1.0',
capabilities=[],
registered_at=stale_time,
last_heartbeat=stale_time,
status='active',
)
# Add a fresh instance
with patch('time.time', return_value=current_time):
registration_data = {
'workspace_path': '/home/user/fresh',
'workspace_name': 'fresh-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
reg_response = client.post('/api/vscode/register', json=registration_data)
fresh_connection_id = reg_response.json()['connection_id']
# Verify both instances are in registry before discovery
assert len(_vscode_registry) == 2
assert stale_connection_id in _vscode_registry
assert fresh_connection_id in _vscode_registry
# Test discovery - should clean up stale instance
with patch('time.time', return_value=current_time):
response = client.get('/api/vscode/instances')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
# Only fresh instance should be returned
assert len(data) == 1
assert data[0]['connection_id'] == fresh_connection_id
assert data[0]['workspace_name'] == 'fresh-project'
# Verify stale instance was removed from registry
assert len(_vscode_registry) == 1
assert stale_connection_id not in _vscode_registry
assert fresh_connection_id in _vscode_registry
class TestVsCodeInstanceManagement:
"""Test VSCode instance management endpoints."""
def test_heartbeat_success(self, client, clean_registry, mock_time):
"""Test successful heartbeat update."""
# Register an instance first
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
reg_response = client.post('/api/vscode/register', json=registration_data)
connection_id = reg_response.json()['connection_id']
# Update heartbeat with a later time
later_time = mock_time + 60 # 1 minute later
with patch('time.time', return_value=later_time):
response = client.post(f'/api/vscode/heartbeat/{connection_id}')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert data['message'] == 'Heartbeat updated'
# Verify heartbeat was updated in registry
instance = _vscode_registry[connection_id]
assert instance.last_heartbeat == later_time
assert instance.status == 'active'
def test_heartbeat_nonexistent_instance(self, client, clean_registry):
"""Test heartbeat for non-existent instance."""
fake_connection_id = 'non-existent-id'
response = client.post(f'/api/vscode/heartbeat/{fake_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
data = response.json()
assert 'not found' in data['detail'].lower()
def test_unregister_success(self, client, clean_registry, mock_time):
"""Test successful instance unregistration."""
# Register an instance first
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
reg_response = client.post('/api/vscode/register', json=registration_data)
connection_id = reg_response.json()['connection_id']
# Verify instance exists
assert connection_id in _vscode_registry
# Unregister the instance
response = client.delete(f'/api/vscode/unregister/{connection_id}')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert connection_id in data['message']
assert 'Successfully unregistered' in data['message']
# Verify instance was removed from registry
assert connection_id not in _vscode_registry
def test_unregister_nonexistent_instance(self, client, clean_registry):
"""Test unregistration of non-existent instance."""
fake_connection_id = 'non-existent-id'
response = client.delete(f'/api/vscode/unregister/{fake_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
data = response.json()
assert 'not found' in data['detail'].lower()
def test_get_instance_success(self, client, clean_registry, mock_time):
"""Test getting information about a specific instance."""
# Register an instance first
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': ['file_operations', 'terminal_access'],
}
reg_response = client.post('/api/vscode/register', json=registration_data)
connection_id = reg_response.json()['connection_id']
# Get instance information
response = client.get(f'/api/vscode/instance/{connection_id}')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
# Verify instance information
assert data['connection_id'] == connection_id
assert data['workspace_name'] == 'test-project'
assert data['workspace_path'] == '/home/user/project'
assert data['status'] == 'active'
assert data['registered_at'] == mock_time
assert data['last_heartbeat'] == mock_time
def test_get_instance_nonexistent(self, client, clean_registry):
"""Test getting information about non-existent instance."""
fake_connection_id = 'non-existent-id'
response = client.get(f'/api/vscode/instance/{fake_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
data = response.json()
assert 'not found' in data['detail'].lower()
def test_get_registry_stats_empty(self, client, clean_registry):
"""Test registry stats with empty registry."""
response = client.get('/api/vscode/registry/stats')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert data['total_instances'] == 0
assert data['status_counts'] == {}
assert data['recent_activity'] == 0
assert data['registry_size'] == 0
def test_get_registry_stats_with_instances(self, client, clean_registry, mock_time):
"""Test registry stats with multiple instances."""
current_time = mock_time
# Register multiple instances with different statuses
instances_data = [
{
'workspace_path': '/home/user/project1',
'workspace_name': 'project-1',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
},
{
'workspace_path': '/home/user/project2',
'workspace_name': 'project-2',
'vscode_version': '1.86.0',
'extension_version': '0.2.0',
},
]
connection_ids = []
for instance_data in instances_data:
reg_response = client.post('/api/vscode/register', json=instance_data)
connection_ids.append(reg_response.json()['connection_id'])
# Manually set one instance to idle status
_vscode_registry[connection_ids[1]].status = 'idle'
# Add an old instance (no recent activity)
old_time = current_time - (10 * 60) # 10 minutes ago
old_connection_id = 'old-instance-id'
_vscode_registry[old_connection_id] = VSCodeInstance(
connection_id=old_connection_id,
workspace_path='/home/user/old',
workspace_name='old-project',
vscode_version='1.84.0',
extension_version='0.0.1',
capabilities=[],
registered_at=old_time,
last_heartbeat=old_time,
status='active',
)
# Get registry stats
with patch('time.time', return_value=current_time):
response = client.get('/api/vscode/registry/stats')
assert response.status_code == http_status.HTTP_200_OK
data = response.json()
assert data['total_instances'] == 3
assert data['registry_size'] == 3
assert data['status_counts']['active'] == 2
assert data['status_counts']['idle'] == 1
assert data['recent_activity'] == 2 # Only the 2 recent instances
class TestVsCodeErrorHandling:
"""Test error handling scenarios for VSCode routes."""
def test_registration_server_error_simulation(self, client, clean_registry):
"""Test registration endpoint error handling."""
# Simulate server error by patching uuid.uuid4 to raise exception
with patch(
'openhands.server.routes.vscode.uuid.uuid4',
side_effect=Exception('UUID generation failed'),
):
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
response = client.post('/api/vscode/register', json=registration_data)
assert response.status_code == http_status.HTTP_500_INTERNAL_SERVER_ERROR
data = response.json()
assert 'Registration failed' in data['detail']
assert 'UUID generation failed' in data['detail']
# Verify no instance was stored
assert len(_vscode_registry) == 0
def test_invalid_connection_id_format(self, client, clean_registry):
"""Test endpoints with invalid connection ID formats."""
invalid_connection_id = 'invalid-id-format'
# Test heartbeat with invalid ID
response = client.post(f'/api/vscode/heartbeat/{invalid_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
# Test unregister with invalid ID
response = client.delete(f'/api/vscode/unregister/{invalid_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
# Test get instance with invalid ID
response = client.get(f'/api/vscode/instance/{invalid_connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
def test_malformed_registration_data(self, client, clean_registry):
"""Test registration with various malformed data."""
# Test with non-string workspace_path
malformed_data = {
'workspace_path': 123, # Should be string
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
response = client.post('/api/vscode/register', json=malformed_data)
assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY
# Test with non-list capabilities
malformed_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
'capabilities': 'not-a-list', # Should be list
}
response = client.post('/api/vscode/register', json=malformed_data)
assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY
def test_empty_string_fields(self, client, clean_registry):
"""Test registration with empty string fields."""
empty_data = {
'workspace_path': '', # Empty string - should fail validation
'workspace_name': '', # Empty string - should fail validation
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
response = client.post('/api/vscode/register', json=empty_data)
# Should fail validation due to min_length=1 constraint
assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY
# Verify error details mention the validation failures
data = response.json()
assert 'detail' in data
# Should have validation errors for both empty fields
errors = data['detail']
assert len(errors) >= 2 # At least workspace_path and workspace_name errors
def test_extremely_long_field_values(self, client, clean_registry):
"""Test registration with extremely long field values."""
long_string = 'x' * 10000 # Very long string
long_data = {
'workspace_path': long_string,
'workspace_name': long_string,
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
# This should still work but might be handled differently in production
response = client.post('/api/vscode/register', json=long_data)
# For now, we expect it to work, but in production you might want validation
assert response.status_code in [
http_status.HTTP_200_OK,
http_status.HTTP_422_UNPROCESSABLE_ENTITY,
]
def test_concurrent_registration_cleanup(self, client, clean_registry, mock_time):
"""Test behavior when registry is modified during operations."""
# Register an instance
registration_data = {
'workspace_path': '/home/user/project',
'workspace_name': 'test-project',
'vscode_version': '1.85.0',
'extension_version': '0.1.0',
}
reg_response = client.post('/api/vscode/register', json=registration_data)
connection_id = reg_response.json()['connection_id']
# Manually remove the instance from registry (simulating concurrent modification)
del _vscode_registry[connection_id]
# Try to access the removed instance
response = client.get(f'/api/vscode/instance/{connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
# Try to update heartbeat for removed instance
response = client.post(f'/api/vscode/heartbeat/{connection_id}')
assert response.status_code == http_status.HTTP_404_NOT_FOUND
if __name__ == '__main__':
pytest.main([__file__])
-397
View File
@@ -1,397 +0,0 @@
import os
import re
import unittest
class TestCircularImports(unittest.TestCase):
"""Test to detect circular imports in the codebase."""
def test_no_circular_imports_in_key_modules(self):
"""
Test that there are no circular imports in key modules that were previously problematic.
This test specifically checks the modules that were involved in a previous circular import issue:
- openhands.utils.prompt
- openhands.agenthub.codeact_agent.tools.bash
- openhands.agenthub.codeact_agent.tools.prompt
- openhands.memory.memory
- openhands.memory.conversation_memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.utils.prompt': os.path.join(
project_root, 'openhands/utils/prompt.py'
),
'openhands.agenthub.codeact_agent.tools.bash': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
),
'openhands.agenthub.codeact_agent.tools.prompt': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
'openhands.memory.conversation_memory': os.path.join(
project_root, 'openhands/memory/conversation_memory.py'
),
}
# Check for the specific circular import pattern that was problematic
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(f'Circular imports detected:\n{circular_import_str}')
def _find_circular_imports(
self, module_paths: dict[str, str]
) -> list[tuple[str, str]]:
"""
Find circular imports between modules.
Args:
module_paths: Dictionary mapping module names to file paths
Returns:
List of tuples (module1, module2) where module1 imports module2 and module2 imports module1
"""
# Dictionary to store imports for each module
module_imports = {}
# Extract imports for each module
for module_name, file_path in module_paths.items():
if os.path.exists(file_path):
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
module_imports[module_name] = imported_modules
# Check for circular imports
circular_imports = []
for module1, imports1 in module_imports.items():
for module2 in imports1:
if module2 in module_imports and module1 in module_imports[module2]:
# Found a circular import
circular_imports.append((module1, module2))
return circular_imports
def test_specific_circular_import_pattern(self):
"""
Test for the specific circular import pattern that caused the issue in the stack trace.
The problematic pattern was:
openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash
openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt
openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Check if the problematic pattern exists
prompt_path = os.path.join(project_root, 'openhands/utils/prompt.py')
bash_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
)
tools_prompt_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
)
# Check if all files exist
if not all(
os.path.exists(path) for path in [prompt_path, bash_path, tools_prompt_path]
):
self.skipTest('One or more required files do not exist')
# Read the files
with open(prompt_path, 'r') as f:
prompt_code = f.read()
with open(bash_path, 'r') as f:
bash_code = f.read()
with open(tools_prompt_path, 'r') as f:
tools_prompt_code = f.read()
# Check for the problematic imports
prompt_imports_bash = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.bash import',
prompt_code,
)
is not None
)
bash_imports_tools_prompt = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.prompt import',
bash_code,
)
is not None
)
tools_prompt_imports_prompt = (
re.search(r'from openhands\.utils\.prompt import', tools_prompt_code)
is not None
)
# If all three imports exist, we have a circular import
if (
prompt_imports_bash
and bash_imports_tools_prompt
and tools_prompt_imports_prompt
):
self.fail(
'Circular import pattern detected:\n'
'openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash\n'
'openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt\n'
'openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt'
)
def test_detect_circular_imports_in_server_modules(self):
"""
Test for circular imports in the server modules that were involved in the stack trace.
The problematic modules were:
- openhands.server.shared
- openhands.server.conversation_manager.conversation_manager
- openhands.server.session.agent_session
- openhands.server.session
- openhands.server.session.session
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.server.shared': os.path.join(
project_root, 'openhands/server/shared.py'
),
'openhands.server.conversation_manager.conversation_manager': os.path.join(
project_root,
'openhands/server/conversation_manager/conversation_manager.py',
),
'openhands.server.session.agent_session': os.path.join(
project_root, 'openhands/server/session/agent_session.py'
),
'openhands.server.session.__init__': os.path.join(
project_root, 'openhands/server/session/__init__.py'
),
'openhands.server.session.session': os.path.join(
project_root, 'openhands/server/session/session.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in server modules:\n{circular_import_str}'
)
def test_detect_circular_imports_in_mcp_modules(self):
"""
Test for circular imports in the MCP modules that were involved in the stack trace.
The problematic modules were:
- openhands.mcp
- openhands.mcp.utils
- openhands.memory.memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.mcp.__init__': os.path.join(
project_root, 'openhands/mcp/__init__.py'
),
'openhands.mcp.utils': os.path.join(project_root, 'openhands/mcp/utils.py'),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in MCP modules:\n{circular_import_str}'
)
def test_detect_complex_circular_import_chains(self):
"""
Test for complex circular import chains involving multiple modules.
This test checks for circular dependencies that involve more than two modules,
such as A imports B, B imports C, and C imports A.
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Define the modules involved in the stack trace
modules = [
'openhands.utils.prompt',
'openhands.agenthub.codeact_agent.tools.bash',
'openhands.agenthub.codeact_agent.tools.prompt',
'openhands.memory.memory',
'openhands.memory.conversation_memory',
'openhands.server.shared',
'openhands.server.conversation_manager.conversation_manager',
'openhands.server.session.agent_session',
'openhands.server.session.__init__',
'openhands.server.session.session',
'openhands.mcp.__init__',
'openhands.mcp.utils',
]
# Map module names to file paths
module_paths = {}
for module in modules:
if module.endswith('.__init__'):
# Handle __init__.py files
module_path = module[:-9].replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}/__init__.py')
else:
# Handle regular .py files
module_path = module.replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}.py')
if os.path.exists(file_path):
module_paths[module] = file_path
# Build the import graph
import_graph = {}
for module_name, file_path in module_paths.items():
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
import_graph[module_name] = [
m for m in imported_modules if m in module_paths
]
# Check for circular import chains
circular_chains = self._find_circular_chains(import_graph)
# If there are any circular chains, fail the test
if circular_chains:
circular_chain_str = '\n'.join(
[' -> '.join(chain) for chain in circular_chains]
)
self.fail(f'Complex circular import chains detected:\n{circular_chain_str}')
def _find_circular_chains(
self, import_graph: dict[str, list[str]]
) -> list[list[str]]:
"""
Find circular import chains in the import graph.
Args:
import_graph: Dictionary mapping module names to lists of imported modules
Returns:
List of circular import chains, where each chain is a list of module names
"""
circular_chains = []
def dfs(module: str, path: list[str], visited: set[str]):
"""
Depth-first search to find circular import chains.
Args:
module: Current module being visited
path: Current path in the DFS
visited: Set of modules visited in the current DFS path
"""
if module in visited:
# Found a circular import chain
cycle_start = path.index(module)
circular_chains.append(path[cycle_start:] + [module])
return
visited.add(module)
path.append(module)
for imported_module in import_graph.get(module, []):
dfs(imported_module, path.copy(), visited.copy())
# Start DFS from each module
for module in import_graph:
dfs(module, [], set())
return circular_chains
if __name__ == '__main__':
unittest.main()
-142
View File
@@ -1,142 +0,0 @@
# VSCode Integration Approaches
OpenHands can integrate with VSCode in three different ways, each serving different use cases:
## 1. VSCode Integration (Launcher) ✅ **Completed**
**Purpose**: Launch OpenHands from VSCode with context.
**How it works**:
- VSCode extension provides context menu commands and Command Palette entries
- User can start OpenHands with current file content, selected text, or new conversation
- Extension launches OpenHands in terminal with appropriate context
- Auto-installs when user runs OpenHands CLI in VSCode/Windsurf
**Use cases**:
- Quick OpenHands launch with file/selection context
- Seamless workflow from editing to AI assistance
- No need to manually copy-paste file contents
## 2. VSCode Runtime (Executor) ⭐ **Current Focus**
**Purpose**: Use VSCode as the execution environment for OpenHands actions.
**How it works**:
- OpenHands AgentController sends actions to VSCode Runtime (Python)
- VSCode Runtime forwards actions to VSCode Extension via Socket.IO
- VSCode Extension executes actions using VSCode API (file ops, terminal, etc.)
- VSCode Extension sends observations back via Socket.IO
- VSCode Runtime returns observations to AgentController
**Architecture**:
```
AgentController → VSCodeRuntime → Socket.IO Server → VSCode Extension → VSCode API
↑ ↓
Socket.IO ← Observations ←
```
**Connection Flow**:
1. User starts OpenHands with `--runtime vscode`
2. OpenHands backend creates `VsCodeRuntime` instance (Python)
3. `VsCodeRuntime` connects to OpenHands Socket.IO server
4. VSCode extension connects to the same Socket.IO server (triggered by runtime activation)
5. Actions flow: Backend → Socket.IO → VSCode Extension → VSCode API
6. Observations flow: VSCode API → VSCode Extension → Socket.IO → Backend
**Use cases**:
- Leverage VSCode's file system access and workspace management
- Use VSCode's integrated terminal and debugging capabilities
- Access VSCode's language services and extensions
- Work within user's existing VSCode setup and configuration
## 3. VSCode Tab (Frontend)
**Purpose**: Display OpenHands UI as a tab within VSCode.
**How it works**:
- VSCode extension creates a webview panel
- Panel displays the OpenHands web interface
- Standard Socket.IO communication with OpenHands backend (running anywhere)
- Just another frontend client, like the web UI
**Use cases**:
- View OpenHands interface without leaving VSCode
- Alternative to browser-based UI
- Integrated development environment experience
---
## Extension Architecture Recommendation
### ✅ **Combine Tasks 1, 2, and 3 in One Extension**
**Rationale**:
- **Complementary workflows**: User launches OpenHands (Task 1) → OpenHands executes in VSCode (Task 2) → User views UI in VSCode tab (Task 3)
- **Shared infrastructure**: All three use Socket.IO communication and VSCode workspace utilities
- **Better user experience**: Single extension to install and configure
- **Natural user journey**: Complete VSCode ↔ OpenHands integration suite
**Architecture**:
```typescript
extension.ts
commands/ // Task 1: Context menu commands
runtime/ // Task 2: Action execution handler
webview/ // Task 3: OpenHands UI tab
services/
socketio.ts // Shared Socket.IO client/server
workspace.ts // Shared VSCode utilities
types/ // Shared OpenHands types
```
**Activation patterns**:
- **Task 1**: On-demand (when user triggers commands)
- **Task 2**: Always listening (when OpenHands uses VSCode runtime)
- **Task 3**: On-demand (when user opens OpenHands tab)
**User stories**:
1. *"Launch OpenHands with my current file context"* → Task 1
2. *"Have OpenHands execute actions in my VSCode"* → Task 2
3. *"View OpenHands UI without leaving VSCode"* → Task 3
**Implementation strategy**:
- Rebase `vscode-runtime` branch on top of `vscode-integration` branch
- Expand existing extension with runtime capabilities (Task 2)
- Add webview panel for OpenHands UI (Task 3)
- Share Socket.IO service across all three tasks
---
## Socket.IO Infrastructure
OpenHands has existing Socket.IO infrastructure that all approaches leverage:
- **Server**: `openhands/server/shared.py` creates `socketio.AsyncServer`
- **Event Handlers**: `openhands/server/listen_socket.py` handles client connections
- **Event Flow**: Clients connect, send `oh_user_action` events, receive `oh_event` emissions
- **Consistency**: VSCode integrations use the same protocol as the web frontend
## Current Implementation Status
### ✅ **Task 1 - VSCode Integration (Completed)**
- Beautiful OpenHands submenu in context menu
- Smart dual naming strategy (short names in menu, full names in Command Palette)
- Auto-installation when running OpenHands CLI in VSCode/Windsurf
- Successfully tested and pushed to `vscode-integration` branch
### 🔧 **Task 2 - VSCode Runtime (In Progress)**
- VSCode Runtime implementation has been integrated with Task 1 extension
- Runtime action handler supports file operations (read, write, edit) and terminal commands
- Socket.IO communication established between OpenHands backend and VSCode extension
- VSCode extension can execute OpenHands actions within the VSCode environment
- Connection management with lazy initialization and error handling
- **Current work**: Refining functionality and ensuring robust operation
### 📋 **Task 3 - VSCode Tab (Planned)**
- Will be added to the combined extension
- Webview panel to display OpenHands UI
- Socket.IO client to connect to OpenHands backend
## Next Steps
1.**Rebase and combine**: Completed - `vscode-runtime` branch contains integrated functionality
2. 🔧 **Refine Task 2**: Currently working on making VSCode Runtime robust and reliable
3. **Add Task 3**: Implement webview panel for OpenHands UI
4. **Test integration**: Verify all three tasks work together seamlessly
5. **Update documentation**: Document the complete integration suite
-114
View File
@@ -1,114 +0,0 @@
# VSCode Runtime Integration Task
## What a VSCode Runtime Should Be Like
A VSCode runtime should provide a bridge between OpenHands and a VSCode extension, allowing OpenHands agents to execute actions directly within the user's VSCode environment. Key characteristics:
### Architecture
- **Socket.IO Communication**: Uses Socket.IO for real-time bidirectional communication between the OpenHands backend and VSCode extension
- **Extension-Based Execution**: Actions are executed by a VSCode extension running in the user's editor, not in a separate container
- **Direct File Access**: Works directly with files in the user's workspace without needing file copying or mounting
- **IDE Integration**: Leverages VSCode's built-in capabilities (terminal, file system, debugging, etc.)
### Core Capabilities
- **Command Execution**: Run shell commands in VSCode's integrated terminal
- **File Operations**: Read, write, and edit files using VSCode's file system APIs
- **Browser Integration**: Open URLs in VSCode's built-in browser or external browser
- **Python/IPython**: Execute Python code in VSCode's Python environment
- **MCP Tool Support**: Call Model Context Protocol tools through the extension
### Benefits
- **Native Experience**: Users see actions happening in their familiar VSCode environment
- **No Container Overhead**: Direct execution without Docker or sandboxing
- **Real-time Visibility**: Users can watch the agent work in real-time
- **Extension Ecosystem**: Can leverage VSCode's rich extension ecosystem
## Current VSCode Runtime Implementation Analysis
### What It Does Right
1. **Proper Runtime Interface**:
- ✅ Inherits from `Runtime` base class
- ✅ Implements all required abstract methods (`connect`, `copy_from`, `copy_to`, `get_mcp_config`, `list_files`, etc.)
- ✅ Compatible with the standard runtime test framework
2. **Socket.IO Architecture**:
- ✅ Uses async Socket.IO for communication
- ✅ Maintains action tracking with futures for async operations
- ✅ Proper event serialization/deserialization
3. **Action Delegation**:
- ✅ All actions (run, read, write, edit, browse, etc.) are properly delegated to VSCode extension
- ✅ Consistent error handling when extension is not connected
4. **Test Integration**:
- ✅ Successfully added to runtime test framework
- ✅ Can be instantiated and tested with `TEST_RUNTIME=vscode`
- ✅ Added to CI workflow for automated testing
### Test Results
The VSCode runtime successfully:
- ✅ Loads and initializes without errors
- ✅ Integrates with the runtime test framework
- ✅ Returns appropriate error messages when not connected to VSCode extension
- ✅ Handles action delegation correctly
Expected test behavior:
```
ERROR: VsCodeRuntime is not properly configured with a connection. Cannot operate.
```
This is correct behavior when no VSCode extension is connected.
## Implementation Locations
- **VSCode Extension**: `/openhands/integrations/vscode/` - TypeScript extension with Socket.IO connection and action handlers
- **VSCode Runtime**: `/openhands/runtime/vscode/` - Python runtime implementation that communicates with the extension
- **Server API Routes**: `/openhands/server/routes/vscode.py` - FastAPI endpoints for extension registration and discovery
## Next Steps
### For Full VSCode Integration:
1. **VSCode Extension Development**:
- ✅ Create a VSCode extension that connects to the OpenHands Socket.IO server
- ✅ Implement action handlers for all runtime operations
- ⏳ Further refinement and testing of action handlers
2. **Connection Management**:
- ✅ Add automatic connection discovery (server-side registry implemented)
- ⏳ Implement reconnection logic
- ⏳ Add connection status monitoring
3. **Enhanced File Operations**:
- ⏳ Implement proper `list_files` through extension
- ⏳ Add workspace-aware file operations
- ⏳ Handle VSCode-specific file events
4. **Testing Infrastructure**:
- ✅ Create mock VSCode extension for testing
- ⏳ Add integration tests with actual VSCode
### For Current Testing:
The VSCode runtime is now properly integrated into the test framework and will:
- Run in CI with `TEST_RUNTIME=vscode`
- Return appropriate errors when no extension is connected
- Validate the runtime interface implementation
This provides a solid foundation for future VSCode extension development.
## Current Status
The VSCode runtime is now:
- ✅ Properly integrated into the OpenHands runtime system
- ✅ Compatible with the existing test framework
- ✅ Ready for CI testing
- ✅ Prepared for future VSCode extension development
The implementation provides a solid foundation that correctly handles the case where no VSCode extension is connected, making it safe to include in automated testing.