Compare commits

..

27 Commits

Author SHA1 Message Date
Xingyao Wang
877a857e55 Merge branch 'main' into fix-timeout-message-tests 2025-08-15 15:40:45 -04:00
openhands
2a52902db8 Revert implementation changes - keep main branch implementation 2025-08-15 19:39:05 +00:00
openhands
02a5b7c307 Remove prompt changes - keep only timeout message fixes 2025-08-15 19:35:20 +00:00
openhands
4d4645068c Fix timeout message format to match main branch
- Updated TIMEOUT_MESSAGE_TEMPLATE in bash_constants.py to include specific key combinations ('C-c', 'C-z', 'C-d')
- Modified bash.py to use TIMEOUT_MESSAGE_TEMPLATE instead of hardcoded message
- Modified windows_bash.py to use TIMEOUT_MESSAGE_TEMPLATE instead of hardcoded message
- Ensures consistent timeout message format across all bash implementations
- Fixes failing runtime tests related to timeout error prompt changes
2025-08-15 19:24:03 +00:00
openhands
8e30ecca11 Revert unintended default system prompt change
The change to set system_prompt_filename default to 'system_prompt_tech_philosophy.j2'
was unintended. Reverting back to the original default 'system_prompt.j2' and
updating the test accordingly.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 18:42:34 +00:00
openhands
9722d23f38 Fix test to match new default system prompt filename
The default system_prompt_filename was changed from 'system_prompt.j2' to
'system_prompt_tech_philosophy.j2' in the AgentConfig, so the test needed
to be updated to reflect this change.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 18:26:44 +00:00
Xingyao Wang
587b4c311a runtime(bash): clarify guidance when previous command still running; recommend execute_bash timeout (issue #10350) (#10389)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 02:15:05 +08:00
Xingyao Wang
7a86402c9c Add process management guidance to system prompt (#10083)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 02:09:20 +08:00
Insop
06d283dfa0 Temp fix for docker file and debug log (#10345) 2025-08-15 15:34:46 +00:00
sp.wack
a6a4246e30 fix(frontend): Failing tests (#10369) 2025-08-15 15:18:35 +00:00
Xingyao Wang
4830b9a67d fix(llm): include gpt-5 to fn call model; set top p default value to None (#10363) 2025-08-15 15:08:01 +00:00
Neeraj Panwar
d4489d62d7 fix(llm): set AWS credentials in config.toml (#10351) 2025-08-15 22:16:50 +08:00
Ryan H. Tran
e41c020073 [CLI] Fix MCP toml formatting issue (#10312)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 13:56:46 +00:00
Ryan H. Tran
3f44c8436f Fix swebench modal patch eval intermittent crash (#10353) 2025-08-15 21:51:03 +08:00
Graham Neubig
b740944075 Split E2E settings and conversation tests; run settings first in workflow (#10359)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 09:19:27 -04:00
dependabot[bot]
5618a3eebb chore(deps): bump the version-all group in /frontend with 9 updates (#10318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 16:51:41 +04:00
Hiep Le
a1ffe5c936 fix(frontend): frontend UI keep flashing (#10352) 2025-08-15 12:19:30 +04:00
Hiep Le
f8376a9702 fix(frontend): status message missing (#10349) 2025-08-15 12:11:42 +07:00
Tim O'Farrell
985a634d60 Fix for issue where static system commands are truncated (#10292)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 04:16:50 +00:00
Xingyao Wang
9b78a5e200 update default prompt name 2025-08-14 13:49:50 -04:00
Xingyao Wang
1ce3723b60 rename and add file back 2025-08-14 13:48:44 -04:00
Xingyao Wang
95a32ae459 set default 2025-08-14 13:18:22 -04:00
Xingyao Wang
9be0acea9c improve prompt 2025-08-14 13:15:01 -04:00
test
1a5965b951 Merge system prompt templates into experimental version
- Combine system_prompt_interactive.j2 and system_prompt_roleplay.j2 into system_prompt_experimental.j2
- Use include directive to avoid code duplication and maintain DRY principle
- Integrate interactive rules for handling vague instructions and clarification protocols
- Incorporate Linus Torvalds technical philosophy with constructive communication style
- Replace harsh language with professional, educational feedback approach
- Maintain technical rigor while ensuring respectful user interactions

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-14 17:03:53 +00:00
Xingyao Wang
f5cbb26770 minor tweak and reformat 2025-08-14 12:49:49 -04:00
Xingyao Wang
8caad14eb8 update prompt 2025-08-14 12:15:06 -04:00
Xingyao Wang
43e6ce631a roleplay prompt 2025-08-14 00:11:38 -04:00
49 changed files with 2098 additions and 6212 deletions

View File

@@ -183,7 +183,7 @@ jobs:
# 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
poetry run python -m pytest test_settings.py::test_github_token_configuration test_conversation.py::test_conversation_start -v --no-header --capture=no --timeout=600
- name: Upload test results
if: always()

5
.gitignore vendored
View File

@@ -255,10 +255,5 @@ 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

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,
}

View File

@@ -1,6 +1,6 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
@@ -36,6 +36,8 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$NAV_SECRETS": "Secrets",
"SETTINGS$NAV_MCP": "MCP",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -47,8 +49,33 @@ vi.mock("react-i18next", async () => {
};
});
// Mock useConfig hook
const { mockUseConfig } = vi.hoisted(() => ({
mockUseConfig: vi.fn(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: mockUseConfig,
}));
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
beforeEach(() => {
// Set default config to OSS mode
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
});
const RoutesStub = createRoutesStub([
{
@@ -79,19 +106,7 @@ describe("Settings Billing", () => {
});
it("should not render the credits tab if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// OSS mode is set by default in beforeEach
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -100,17 +115,20 @@ describe("Settings Billing", () => {
});
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
renderSettingsScreen();
@@ -121,17 +139,20 @@ describe("Settings Billing", () => {
it("should render the billing settings if clicking the credits item", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
renderSettingsScreen();

View File

@@ -19,6 +19,9 @@ vi.mock("react-i18next", async () => {
SETTINGS$NAV_CREDITS: "Credits",
SETTINGS$NAV_API_KEYS: "API Keys",
SETTINGS$NAV_LLM: "LLM",
SETTINGS$NAV_SECRETS: "Secrets",
SETTINGS$NAV_MCP: "MCP",
SETTINGS$NAV_USER: "User",
SETTINGS$TITLE: "Settings",
};
return translations[key] || key;
@@ -119,22 +122,21 @@ describe("Settings Screen", () => {
});
it("should render the saas navbar", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
const saasConfig = { APP_MODE: "saas" };
// Clear any existing query data and set the config
mockQueryClient.clear();
mockQueryClient.setQueryData(["config"], saasConfig);
const sectionsToInclude = [
"user",
"integrations",
"application",
"credits", // The nav item shows "credits" text but routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
// Clear any existing query data
mockQueryClient.clear();
const sectionsToExclude = ["llm", "mcp"];
renderSettingsScreen();
@@ -151,8 +153,6 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should not be able to access saas-only routes in oss mode", async () => {

View File

@@ -18,9 +18,9 @@
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.2",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -30,14 +30,14 @@
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.259.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -60,8 +60,8 @@
"ws": "^8.18.2"
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
@@ -69,7 +69,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.2.0",
@@ -226,13 +226,12 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -459,12 +458,11 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.28.2"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -616,17 +614,16 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/types": "^7.28.2",
"debug": "^4.3.1"
},
"engines": {
@@ -3405,6 +3402,15 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -5899,26 +5905,24 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.5.1",
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.4.3"
@@ -5927,28 +5931,27 @@
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-x64": "4.1.11",
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
"@tailwindcss/oxide-android-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-x64": "4.1.12",
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -5958,13 +5961,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5974,13 +5976,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5990,13 +5991,12 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -6006,13 +6006,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6022,13 +6021,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6038,13 +6036,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6054,13 +6051,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6070,13 +6066,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6086,9 +6081,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -6100,14 +6095,13 @@
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.11",
"@tybys/wasm-util": "^0.9.0",
"@emnapi/core": "^1.4.5",
"@emnapi/runtime": "^1.4.5",
"@emnapi/wasi-threads": "^1.0.4",
"@napi-rs/wasm-runtime": "^0.2.12",
"@tybys/wasm-util": "^0.10.0",
"tslib": "^2.8.0"
},
"engines": {
@@ -6115,17 +6109,17 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"version": "1.4.5",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"version": "1.4.5",
"inBundle": true,
"license": "MIT",
"optional": true,
@@ -6134,7 +6128,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"version": "1.0.4",
"inBundle": true,
"license": "MIT",
"optional": true,
@@ -6143,18 +6137,18 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"version": "0.2.12",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"version": "0.10.0",
"inBundle": true,
"license": "MIT",
"optional": true,
@@ -6169,13 +6163,12 @@
"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",
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -6185,13 +6178,12 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -6201,16 +6193,15 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"@tailwindcss/node": "4.1.12",
"@tailwindcss/oxide": "4.1.12",
"postcss": "^8.4.41",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@tailwindcss/typography": {
@@ -6230,14 +6221,13 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz",
"integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz",
"integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==",
"dependencies": {
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"tailwindcss": "4.1.11"
"@tailwindcss/node": "4.1.12",
"@tailwindcss/oxide": "4.1.12",
"tailwindcss": "4.1.12"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
@@ -6261,22 +6251,20 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.83.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
"integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
"license": "MIT",
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz",
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
"integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
"license": "MIT",
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz",
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==",
"dependencies": {
"@tanstack/query-core": "5.83.1"
"@tanstack/query-core": "5.85.3"
},
"funding": {
"type": "github",
@@ -6334,17 +6322,15 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.4.tgz",
"integrity": "sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==",
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.7.0.tgz",
"integrity": "sha512-RI2e97YZ7MRa+vxP4UUnMuMFL2buSsf0ollxUbTgrbPLKhMn8KVTx7raS6DYjC7v1NDVrioOvaShxsguLNISCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"lodash": "^4.17.21",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
@@ -11134,9 +11120,9 @@
}
},
"node_modules/i18next": {
"version": "25.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.4.tgz",
"integrity": "sha512-AHklEYFLiRRxW1Cb6zE9lfnEtYvsydRC8nRS3RSKGX3zCqZ8nLZwMaUsrb80YuccPNv2RNokDL8LkTNnp+6mDw==",
"version": "25.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.6.tgz",
"integrity": "sha512-dThZ0CTCM3sUG/qS0ZtQYZQcUI6DtBN8yBHK+SKEqihPcEYmjVWh/YJ4luic73Iq6Uxhp6q7LJJntRK5+1t7jQ==",
"funding": [
{
"type": "individual",
@@ -11151,7 +11137,6 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -14817,10 +14802,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.259.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.260.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.1.tgz",
"integrity": "sha512-DD8ZSRpdScacMqtqUIvMFme8lmOWkOvExG8VvjONE7Cm3xpRH5xXpfrwMJE4bayTGWKMx4ij6SfphK6dm/o2ug==",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
@@ -17029,10 +17013,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"license": "MIT"
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="
},
"node_modules/tapable": {
"version": "2.2.2",

View File

@@ -17,9 +17,9 @@
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.2",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -29,14 +29,14 @@
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.259.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -84,8 +84,8 @@
]
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
@@ -93,7 +93,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.2.0",

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { NavTab } from "./nav-tab";
import { ScrollLeftButton } from "./scroll-left-button";
import { ScrollRightButton } from "./scroll-right-button";
@@ -25,18 +25,12 @@ export function Container({
children,
className,
}: ContainerProps) {
const [containerWidth, setContainerWidth] = useState(0);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollButtons, setShowScrollButtons] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Track container width using ResizeObserver
useTrackElementWidth({
elementRef: containerRef,
callback: setContainerWidth,
});
// Check scroll position and update button states
const updateScrollButtons = () => {
if (scrollContainerRef.current) {
@@ -47,10 +41,19 @@ export function Container({
}
};
// Update scroll buttons when tabs change or container width changes
useEffect(() => {
updateScrollButtons();
}, [labels, containerWidth]);
// Track container width using ResizeObserver
useTrackElementWidth({
elementRef: containerRef,
callback: (width: number) => {
// Only update scroll button visibility when crossing the threshold
const shouldShowScrollButtons =
width < 598 && Boolean(labels) && labels!.length > 0;
if (shouldShowScrollButtons) {
setShowScrollButtons(shouldShowScrollButtons);
}
updateScrollButtons();
},
});
// Scroll functions
const scrollLeft = () => {
@@ -65,8 +68,6 @@ export function Container({
}
};
const showScrollButtons = containerWidth < 598 && labels && labels.length > 0;
return (
<div
ref={containerRef}

View File

@@ -1,18 +1,38 @@
import { useEffect } from "react";
import { useEffect, useCallback, useRef } from "react";
interface UseTrackElementWidthProps {
elementRef: React.RefObject<HTMLElement | null>;
callback: (width: number) => void;
delay?: number; // Optional delay parameter with default
}
export const useTrackElementWidth = ({
elementRef,
callback,
delay = 100, // Default 100ms delay
}: UseTrackElementWidthProps) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Create debounced callback that only fires after delay
const debouncedCallback = useCallback(
(width: number) => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
callback(width);
}, delay);
},
[callback, delay],
);
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
callback(entry.contentRect.width);
debouncedCallback(entry.contentRect.width);
}
});
@@ -21,7 +41,11 @@ export const useTrackElementWidth = ({
}
return () => {
// Clean up timeout and observer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
resizeObserver.disconnect();
};
}, []);
}, [debouncedCallback]);
};

View File

@@ -25,7 +25,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
provider_tokens_set: {},
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:

View File

@@ -122,5 +122,5 @@ export function getStatusCode(
return runtimeStatus;
}
return "STATUS$ERROR"; // illegal state
return I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE;
}

View File

@@ -98,3 +98,11 @@ Your primary role is to assist users by executing commands, modifying code, and
- Confirm whether they want it as a separate file or just in the conversation
- Ask if they want documentation files to be included in version control
</DOCUMENTATION>
<PROCESS_MANAGEMENT>
* When terminating processes:
- Do NOT use general keywords with commands like `pkill -f server` or `pkill -f python` as this might accidentally kill other important servers or processes
- Always use specific keywords that uniquely identify the target process
- Prefer using `ps aux` to find the exact process ID (PID) first, then kill that specific PID
- When possible, use more targeted approaches like finding the PID from a pidfile or using application-specific shutdown commands
</PROCESS_MANAGEMENT>

View File

@@ -4,7 +4,7 @@ import sys
from pathlib import Path
from typing import Any
import toml
import tomlkit
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import clear, print_container
@@ -520,7 +520,7 @@ def load_config_file(file_path: Path) -> dict:
if file_path.exists():
try:
with open(file_path, 'r') as f:
return toml.load(f)
return dict(tomlkit.load(f))
except Exception:
pass
@@ -530,9 +530,36 @@ def load_config_file(file_path: Path) -> dict:
def save_config_file(config_data: dict, file_path: Path) -> None:
"""Save the config file."""
"""Save the config file with proper MCP formatting."""
doc = tomlkit.document()
for key, value in config_data.items():
if key == 'mcp':
# Handle MCP section specially
mcp_section = tomlkit.table()
for mcp_key, mcp_value in value.items():
# Create array with inline tables for server configurations
server_array = tomlkit.array()
for server_config in mcp_value:
if isinstance(server_config, dict):
# Create inline table for each server
inline_table = tomlkit.inline_table()
for server_key, server_val in server_config.items():
inline_table[server_key] = server_val
server_array.append(inline_table)
else:
# Handle non-dict values (like string URLs)
server_array.append(server_config)
mcp_section[mcp_key] = server_array
doc[key] = mcp_section
else:
# Handle non-MCP sections normally
doc[key] = value
with open(file_path, 'w') as f:
toml.dump(config_data, f)
f.write(tomlkit.dumps(doc))
def _ensure_mcp_config_structure(config_data: dict) -> None:

View File

@@ -183,3 +183,13 @@ class LLMConfig(BaseModel):
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/7755
if self.model.startswith('azure') and self.api_version is None:
self.api_version = '2024-12-01-preview'
# Set AWS credentials as environment variables for LiteLLM Bedrock
if self.aws_access_key_id:
os.environ['AWS_ACCESS_KEY_ID'] = self.aws_access_key_id.get_secret_value()
if self.aws_secret_access_key:
os.environ['AWS_SECRET_ACCESS_KEY'] = (
self.aws_secret_access_key.get_secret_value()
)
if self.aws_region_name:
os.environ['AWS_REGION_NAME'] = self.aws_region_name

View File

@@ -19,7 +19,9 @@ class CmdRunAction(Action):
blocking: bool = False # if True, the command will be run in a blocking manner, but a timeout must be set through _set_hard_timeout
is_static: bool = False # if True, runs the command in a separate process
cwd: str | None = None # current working directory, only used if is_static is True
hidden: bool = False
hidden: bool = (
False # if True, this command does not go through the LLM or event stream
)
action: str = ActionType.RUN
runnable: ClassVar[bool] = True
confirmation_state: ActionConfirmationStatus = ActionConfirmationStatus.CONFIRMED

View File

@@ -115,9 +115,12 @@ class CmdOutputObservation(Observation):
**kwargs: Any,
) -> None:
# Truncate content before passing it to parent
truncated_content = self._maybe_truncate(content)
# Hidden commands don't go through LLM/event stream, so no need to truncate
truncate = not hidden
if truncate:
content = self._maybe_truncate(content)
super().__init__(truncated_content)
super().__init__(content)
self.command = command
self.observation = observation

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

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"
}
}

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.
}

View File

@@ -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)}`,
);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
})();
},
);
});
}

View File

@@ -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",
);
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -94,6 +94,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'kimi-k2-instruct',
'Qwen3-Coder-480B-A35B-Instruct',
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
'gpt-5',
'gpt-5-2025-08-07',
]
@@ -108,7 +109,9 @@ REASONING_EFFORT_SUPPORTED_MODELS = [
'o4-mini-2025-04-16',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gpt-5',
'gpt-5-2025-08-07',
'claude-opus-4-1-20250805', # we need to remove top_p for opus 4.1
]
MODELS_WITHOUT_STOP_WORDS = [

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',
]

View File

@@ -1038,7 +1038,9 @@ fi
self, command: str, cwd: str | None
) -> CommandResult:
"""This function is used by the GitHandler to execute shell commands."""
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
obs = self.run(
CmdRunAction(command=command, is_static=True, hidden=True, cwd=cwd)
)
exit_code = 0
content = ''

View File

@@ -37,7 +37,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
@staticmethod
def check_buildx(is_podman: bool = False) -> bool:
"""Check if Docker Buildx is available"""
"""Check if Docker Buildx is available."""
try:
result = subprocess.run(
['docker' if not is_podman else 'podman', 'buildx', 'version'],
@@ -176,29 +176,32 @@ class DockerRuntimeBuilder(RuntimeBuilder):
bufsize=1,
)
output_lines = []
if process.stdout:
for line in iter(process.stdout.readline, ''):
line = line.strip()
if line:
output_lines.append(line) # Store all output lines
self._output_logs(line)
return_code = process.wait()
if return_code != 0:
# Use the collected output for error reporting
output_str = '\n'.join(output_lines)
raise subprocess.CalledProcessError(
return_code,
process.args,
output=process.stdout.read() if process.stdout else None,
stderr=process.stderr.read() if process.stderr else None,
output=output_str, # Use the collected output
stderr=None,
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed:\n{e}') # TODO: {e} is empty
logger.error(f'Command output:\n{e.output}')
if self.rolling_logger.is_enabled():
logger.error(
'Docker build output:\n' + self.rolling_logger.all_lines
) # Show the error
logger.error(f'Image build failed with exit code {e.returncode}')
if e.output:
logger.error(f'Command output:\n{e.output}')
elif self.rolling_logger.is_enabled() and self.rolling_logger.all_lines:
logger.error(f'Docker build output:\n{self.rolling_logger.all_lines}')
raise
except subprocess.TimeoutExpired:

View File

@@ -322,6 +322,8 @@ class ActionExecutionClient(Runtime):
)
assert response.is_closed
output = response.json()
if getattr(action, 'hidden', False):
output.get('extras')['hidden'] = True
obs = observation_from_dict(output)
obs._cause = action.id # type: ignore[attr-defined]
except httpx.TimeoutException:

View File

@@ -312,7 +312,11 @@ class BashSession:
return command_output.rstrip()
def _handle_completed_command(
self, command: str, pane_content: str, ps1_matches: list[re.Match]
self,
command: str,
pane_content: str,
ps1_matches: list[re.Match],
hidden: bool,
) -> CmdOutputObservation:
is_special_key = self._is_special_key(command)
assert len(ps1_matches) >= 1, (
@@ -359,6 +363,7 @@ class BashSession:
content=command_output,
command=command,
metadata=metadata,
hidden=hidden,
)
def _handle_nochange_timeout_command(
@@ -549,11 +554,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
command_output = self._get_command_output(
@@ -566,6 +569,7 @@ class BashSession:
command=command,
content=command_output,
metadata=metadata,
hidden=getattr(action, 'hidden', False),
)
# Send actual command/inputs to the pane
@@ -616,6 +620,7 @@ class BashSession:
command,
pane_content=cur_pane_output,
ps1_matches=ps1_matches,
hidden=getattr(action, 'hidden', False),
)
# Timeout checks should only trigger if a new prompt hasn't appeared yet.

View File

@@ -2,6 +2,6 @@
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command, '
'or use the timeout parameter in execute_bash for future commands.'
)

View File

@@ -1,5 +1,7 @@
FROM {{ base_image }}
SHELL ["/bin/bash", "-c"]
# Shared environment variables
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
MAMBA_ROOT_PREFIX=/openhands/micromamba \
@@ -94,13 +96,8 @@ RUN \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
# Add the repository to Apt sources
# For Debian, if it's noble (testing/unstable), use bookworm (stable) repository
if [ "$(lsb_release -cs)" = "noble" ]; then \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
else \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi; \
# Add the repository to Apt sources (default to bookworm for stability)
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi && \
# Install Docker Engine, containerd, and Docker Compose
apt-get update && \

View File

@@ -855,9 +855,7 @@ class WindowsPowershellSession:
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
return CmdOutputObservation(

View File

@@ -1,3 +0,0 @@
from .vscode_runtime import VsCodeRuntime
__all__ = ['VsCodeRuntime']

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.')

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)

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)}',
)

19
poetry.lock generated
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.3 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"
@@ -6663,8 +6663,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"
@@ -9438,7 +9438,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 +9681,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 +9698,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"},
@@ -9811,7 +9810,7 @@ test = ["pytest", "pytest-cov"]
type = "git"
url = "https://github.com/ryanhoangt/SWE-bench.git"
reference = "fix-modal-patch-eval"
resolved_reference = "03846bfaa2f1bad5e72354094ac308590d5e2b37"
resolved_reference = "aa0f1ed4c3b57828c8b9b2821b0ce74e201cf813"
[[package]]
name = "swegym"

391
task.md
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
```

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

View File

@@ -0,0 +1,604 @@
"""
E2E: Conversation start test
This test assumes the GitHub token has already been configured (by the
settings test) and verifies that a conversation can be started and the
agent responds to a README line-count question.
"""
import os
import time
from playwright.sync_api import Page, expect
def get_readme_line_count():
"""Get the line count of the main README.md file for verification."""
current_dir = os.getcwd()
if current_dir.endswith('tests/e2e'):
repo_root = os.path.abspath(os.path.join(current_dir, '../..'))
else:
repo_root = current_dir
readme_path = os.path.join(repo_root, 'README.md')
print(f'Looking for README.md at: {readme_path}')
try:
with open(readme_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
return len(lines)
except (FileNotFoundError, IOError, OSError) as e:
print(f'Error reading README.md: {e}')
return 0
def test_conversation_start(page: Page):
"""
Test starting a conversation with the OpenHands agent:
1. Navigate to OpenHands (assumes GitHub token is already configured)
2. Select the OpenHands repository
3. Click Launch
4. Wait for the agent to initialize
5. Ask a question about the README.md file
6. Verify the agent responds correctly
"""
# Create test-results directory if it doesn't exist
os.makedirs('test-results', exist_ok=True)
expected_line_count = get_readme_line_count()
print(f'Expected README.md line count: {expected_line_count}')
# Navigate to the OpenHands application
print('Step 1: Navigating to OpenHands application...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle', timeout=30000)
# Take initial screenshot
page.screenshot(path='test-results/conv_01_initial_load.png')
print('Screenshot saved: conv_01_initial_load.png')
# Step 2: Select the OpenHands repository
print('Step 2: Selecting openhands-agent/OpenHands repository...')
# Wait for the home screen to load
home_screen = page.locator('[data-testid="home-screen"]')
expect(home_screen).to_be_visible(timeout=15000)
print('Home screen is visible')
# Look for the repository dropdown/selector
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
expect(repo_dropdown).to_be_visible(timeout=15000)
print('Repository dropdown is visible')
# Click on the repository input to open dropdown
repo_dropdown.click()
page.wait_for_timeout(1000)
# Type the repository name
try:
page.keyboard.press('Control+a') # Select all
page.keyboard.type('openhands-agent/OpenHands')
print('Used keyboard.type() for React Select component')
except Exception as e:
print(f'Keyboard input failed: {e}')
page.wait_for_timeout(2000) # Wait for search results
# Try to find and click the repository option
option_selectors = [
'[data-testid="repo-dropdown"] [role="option"]:has-text("openhands-agent/OpenHands")',
'[data-testid="repo-dropdown"] [role="option"]:has-text("OpenHands")',
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("openhands-agent/OpenHands")',
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("OpenHands")',
'[role="option"]:has-text("openhands-agent/OpenHands")',
'[role="option"]:has-text("OpenHands")',
'div:has-text("openhands-agent/OpenHands"):not([id="aria-results"])',
'div:has-text("OpenHands"):not([id="aria-results"])',
]
option_found = False
for selector in option_selectors:
try:
option = page.locator(selector).first
if option.is_visible(timeout=3000):
print(f'Found repository option with selector: {selector}')
try:
option.click(force=True)
print('Successfully clicked option with force=True')
option_found = True
page.wait_for_timeout(2000)
break
except Exception:
continue
except Exception:
continue
if not option_found:
print(
'Could not find repository option in dropdown, trying keyboard navigation'
)
page.keyboard.press('ArrowDown')
page.wait_for_timeout(500)
page.keyboard.press('Enter')
print('Used keyboard navigation to select option')
page.screenshot(path='test-results/conv_02_repo_selected.png')
print('Screenshot saved: conv_02_repo_selected.png')
# Step 3: Click Launch button
print('Step 3: Clicking Launch button...')
launch_button = page.locator('[data-testid="repo-launch-button"]')
expect(launch_button).to_be_visible(timeout=10000)
# Wait for the button to be enabled (not disabled)
max_wait_attempts = 30
button_enabled = False
for attempt in range(max_wait_attempts):
try:
is_disabled = launch_button.is_disabled()
if not is_disabled:
print(
f'Repository Launch button is now enabled (attempt {attempt + 1})'
)
button_enabled = True
break
else:
print(
f'Launch button still disabled, waiting... (attempt {attempt + 1}/{max_wait_attempts})'
)
page.wait_for_timeout(2000)
except Exception as e:
print(f'Error checking button state (attempt {attempt + 1}): {e}')
page.wait_for_timeout(2000)
try:
if button_enabled:
launch_button.click()
print('Launch button clicked normally')
else:
print('Launch button still disabled, trying JavaScript force click...')
result = page.evaluate("""() => {
const button = document.querySelector('[data-testid="repo-launch-button"]');
if (button) {
console.log('Found button, removing disabled attribute');
button.removeAttribute('disabled');
console.log('Clicking button');
button.click();
return true;
}
return false;
}""")
if result:
print('Successfully force-clicked Launch button with JavaScript')
else:
print('JavaScript could not find the Launch button')
except Exception as e:
print(f'Error clicking Launch button: {e}')
page.screenshot(path='test-results/conv_03_launch_error.png')
print('Screenshot saved: conv_03_launch_error.png')
raise
# Step 4: Wait for conversation interface to load
print('Step 4: Waiting for conversation interface to load...')
navigation_timeout = 300000 # 5 minutes
check_interval = 10000 # 10 seconds
page.screenshot(path='test-results/conv_04_after_launch.png')
print('Screenshot saved: conv_04_after_launch.png')
loading_selectors = [
'[data-testid="loading-indicator"]',
'[data-testid="loading-spinner"]',
'.loading-spinner',
'.spinner',
'div:has-text("Loading...")',
'div:has-text("Initializing...")',
'div:has-text("Please wait...")',
]
for selector in loading_selectors:
try:
loading = page.locator(selector)
if loading.is_visible(timeout=5000):
print(f'Found loading indicator with selector: {selector}')
print('Waiting for loading to complete...')
expect(loading).not_to_be_visible(timeout=120000)
print('Loading completed')
break
except Exception:
continue
try:
current_url = page.url
print(f'Current URL: {current_url}')
if '/conversation/' in current_url or '/chat/' in current_url:
print('URL indicates conversation page has loaded')
except Exception as e:
print(f'Error checking URL: {e}')
start_time = time.time()
conversation_loaded = False
while time.time() - start_time < navigation_timeout / 1000:
try:
selectors = [
'.scrollbar.flex.flex-col.grow',
'[data-testid="chat-input"]',
'[data-testid="app-route"]',
'[data-testid="conversation-screen"]',
'[data-testid="message-input"]',
'.conversation-container',
'.chat-container',
'textarea',
'form textarea',
'div[role="main"]',
'main',
]
for selector in selectors:
try:
element = page.locator(selector)
if element.is_visible(timeout=2000):
print(
f'Found conversation interface element with selector: {selector}'
)
conversation_loaded = True
break
except Exception:
continue
if conversation_loaded:
break
if (time.time() - start_time) % (check_interval / 1000) < 1:
elapsed = int(time.time() - start_time)
page.screenshot(path=f'test-results/conv_05_waiting_{elapsed}s.png')
print(f'Screenshot saved: conv_05_waiting_{elapsed}s.png')
page.wait_for_timeout(5000)
except Exception as e:
print(f'Error checking for conversation interface: {e}')
page.wait_for_timeout(5000)
if not conversation_loaded:
print('Timed out waiting for conversation interface to load')
page.screenshot(path='test-results/conv_06_timeout.png')
print('Screenshot saved: conv_06_timeout.png')
raise TimeoutError('Timed out waiting for conversation interface to load')
# Step 5: Wait for agent to initialize
print('Step 5: Waiting for agent to initialize...')
try:
chat_input = page.locator('[data-testid="chat-input"]')
expect(chat_input).to_be_visible(timeout=60000)
submit_button = page.locator('[data-testid="chat-input"] button[type="submit"]')
expect(submit_button).to_be_visible(timeout=10000)
print('Agent interface is loaded')
page.wait_for_timeout(10000)
except Exception as e:
print(f'Could not confirm agent interface is loaded: {e}')
page.screenshot(path='test-results/conv_07_agent_ready.png')
print('Screenshot saved: conv_07_agent_ready.png')
# Step 6: Wait for agent to be fully ready for input
print('Step 6: Waiting for agent to be fully ready for input...')
max_wait_time = 480
start_time = time.time()
agent_ready = False
print(f'Waiting up to {max_wait_time} seconds for agent to be ready...')
while time.time() - start_time < max_wait_time:
elapsed = int(time.time() - start_time)
if elapsed % 30 == 0 and elapsed > 0:
page.screenshot(path=f'test-results/conv_waiting_{elapsed}s.png')
print(f'Screenshot saved: conv_waiting_{elapsed}s.png (waiting {elapsed}s)')
try:
status_messages = []
status_bar_selector = '.bg-base-secondary .text-stone-400'
try:
status_elements = page.locator(status_bar_selector)
if status_elements.count() > 0:
for i in range(status_elements.count()):
text = status_elements.nth(i).text_content()
if text and text.strip():
status_messages.append(text.strip())
except Exception:
pass
ready_indicators = [
'div:has-text("Agent is ready")',
'div:has-text("Waiting for user input")',
'div:has-text("Awaiting input")',
'div:has-text("Task completed")',
'div:has-text("Agent has finished")',
]
input_ready = False
submit_ready = False
try:
input_field = page.locator('[data-testid="chat-input"] textarea')
submit_button = page.locator(
'[data-testid="chat-input"] button[type="submit"]'
)
if (
input_field.is_visible(timeout=2000)
and input_field.is_enabled(timeout=2000)
and submit_button.is_visible(timeout=2000)
and submit_button.is_enabled(timeout=2000)
):
print(
'Chat input field and submit button are both visible and enabled'
)
input_ready = True
submit_ready = True
except Exception:
pass
connecting_or_starting = any(
msg
for msg in status_messages
if 'connecting' in msg.lower()
or 'starting' in msg.lower()
or 'runtime to start' in msg.lower()
)
has_ready_indicator = False
for indicator in ready_indicators:
try:
element = page.locator(indicator)
if element.is_visible(timeout=2000):
print(f'Agent appears ready (found: {indicator})')
has_ready_indicator = True
break
except Exception:
continue
if (
(has_ready_indicator or not connecting_or_starting)
and input_ready
and submit_ready
):
print(
'✅ Agent is ready for user input - input field and submit button are enabled'
)
agent_ready = True
break
elif (
not connecting_or_starting
and not status_messages
and input_ready
and submit_ready
):
print(
'No status messages found and input is ready, agent appears ready...'
)
agent_ready = True
break
except Exception as e:
print(f'Error checking agent ready state: {e}')
page.wait_for_timeout(2000)
if not agent_ready:
page.screenshot(path='test-results/conv_timeout_waiting_for_agent.png')
raise AssertionError(
f'Agent did not become ready for input within {max_wait_time} seconds'
)
# Step 7: Ask a question about the README.md file
print('Step 7: Asking question about README.md file...')
input_selectors = [
'[data-testid="chat-input"] textarea',
'[data-testid="message-input"]',
'textarea',
'form textarea',
'input[type="text"]',
'[placeholder*="message"]',
'[placeholder*="question"]',
'[placeholder*="ask"]',
'[contenteditable="true"]',
]
message_input = None
for selector in input_selectors:
try:
input_element = page.locator(selector)
if input_element.is_visible(timeout=5000):
print(f'Found message input with selector: {selector}')
message_input = input_element
break
except Exception:
continue
if not message_input:
print('Could not find message input, trying to reload the page')
page.screenshot(path='test-results/conv_08_no_input_found.png')
print('Screenshot saved: conv_08_no_input_found.png')
try:
print('Reloading the page...')
page.reload()
page.wait_for_load_state('networkidle', timeout=30000)
print('Page reloaded')
for selector in input_selectors:
try:
input_element = page.locator(selector)
if input_element.is_visible(timeout=5000):
print(
f'Found message input after reload with selector: {selector}'
)
message_input = input_element
break
except Exception:
continue
except Exception as e:
print(f'Error reloading page: {e}')
if not message_input:
print('Still could not find message input, taking final screenshot')
page.screenshot(path='test-results/conv_09_reload_failed.png')
print('Screenshot saved: conv_09_reload_failed.png')
raise AssertionError('Could not find message input field after reload')
message_input.fill(
'How many lines are there in the README.md file in the root directory of this repository? Please use wc -l README.md to count the lines.'
)
print('Entered question about README.md line count')
submit_selectors = [
'[data-testid="chat-input"] button[type="submit"]',
'button[type="submit"]',
'button:has-text("Send")',
'button:has-text("Submit")',
'button svg[data-testid="send-icon"]',
'button.send-button',
'form button',
'button:right-of(textarea)',
'button:right-of(input[type="text"])',
]
submit_button = None
for selector in submit_selectors:
try:
button_element = page.locator(selector)
if button_element.is_visible(timeout=5000):
print(f'Found submit button with selector: {selector}')
submit_button = button_element
break
except Exception:
continue
button_enabled = False
if submit_button:
max_wait_time = 60
start_time = time.time()
while time.time() - start_time < max_wait_time:
try:
if not submit_button.is_disabled():
button_enabled = True
print('Submit button is enabled')
break
print(
f'Waiting for submit button to be enabled... ({int(time.time() - start_time)}s)'
)
except Exception as e:
print(f'Error checking if button is disabled: {e}')
page.wait_for_timeout(2000)
if not submit_button or not button_enabled:
print('Submit button not found or never became enabled, trying alternatives')
try:
message_input.press('Enter')
print('Pressed Enter key to submit')
button_enabled = True
except Exception as e:
print(f'Error pressing Enter key: {e}')
if submit_button:
try:
page.evaluate("""() => {
const button = document.querySelector('[data-testid="chat-input"] button[type="submit"]');
if (button) {
button.removeAttribute('disabled');
button.click();
return true;
}
return false;
}""")
print('Used JavaScript to force click submit button')
button_enabled = True
except Exception as e2:
print(f'JavaScript force click failed: {e2}')
if not button_enabled:
page.screenshot(path='test-results/conv_09_submit_failed.png')
print('Screenshot saved: conv_09_submit_failed.png')
raise RuntimeError('Could not submit message')
else:
submit_button.click()
print('Clicked submit button')
page.screenshot(path='test-results/conv_08_question_sent.png')
print('Screenshot saved: conv_08_question_sent.png')
print('Step 8: Waiting for agent response to README question...')
response_wait_time = 180
response_start_time = time.time()
while time.time() - response_start_time < response_wait_time:
elapsed = int(time.time() - response_start_time)
if elapsed % 30 == 0 and elapsed > 0:
page.screenshot(path=f'test-results/conv_response_wait_{elapsed}s.png')
print(
f'Screenshot saved: conv_response_wait_{elapsed}s.png (waiting {elapsed}s for response)'
)
try:
agent_messages = page.locator('[data-testid="agent-message"]').all()
if elapsed % 30 == 0:
print(f'Found {len(agent_messages)} agent messages')
for i, msg in enumerate(agent_messages):
try:
content = msg.text_content()
if content and len(content.strip()) > 10:
content_lower = content.lower()
import re
line_count_pattern = r'\b(\d{3})\b'
line_counts = re.findall(line_count_pattern, content)
if (
(
str(expected_line_count) in content
and 'readme' in content_lower
)
or (
'line' in content_lower
and 'readme' in content_lower
and any(
num in content
for num in ['183', str(expected_line_count)]
)
)
or (
'line' in content_lower
and 'readme' in content_lower
and line_counts
and any(100 <= int(num) <= 300 for num in line_counts)
)
):
print(
'✅ Found agent response about README.md with line count!'
)
page.screenshot(
path='test-results/conv_09_agent_response.png'
)
print('Screenshot saved: conv_09_agent_response.png')
page.screenshot(path='test-results/conv_10_final_state.png')
print('Screenshot saved: conv_10_final_state.png')
print(
'✅ Test completed successfully - agent provided correct README line count'
)
return
except Exception as e:
print(f'Error processing agent message {i}: {e}')
continue
except Exception as e:
print(f'Error checking for agent messages: {e}')
page.wait_for_timeout(5000)
print('❌ Did not find agent response with README line count within time limit')
page.screenshot(path='test-results/conv_09_agent_response.png')
print('Screenshot saved: conv_09_agent_response.png')
page.screenshot(path='test-results/conv_10_final_state.png')
print('Screenshot saved: conv_10_final_state.png')
raise AssertionError(
'Agent response did not include README line count within time limit'
)

File diff suppressed because it is too large Load Diff

290
tests/e2e/test_settings.py Normal file
View File

@@ -0,0 +1,290 @@
"""
E2E: Settings configuration test (GitHub token)
This test navigates to OpenHands, configures the LLM API key if prompted,
then ensures the GitHub token is set in Settings → Integrations and that the
home screen shows the repository selector.
"""
import os
from playwright.sync_api import Page, expect
def test_github_token_configuration(page: Page):
"""
Test the GitHub token configuration flow:
1. Navigate to OpenHands
2. Configure LLM API key if needed
3. Check if GitHub token is already configured
4. If not, navigate to settings and configure it
5. Verify the token is saved and repository selection is available
"""
# Create test-results directory if it doesn't exist
os.makedirs('test-results', exist_ok=True)
# Navigate to the OpenHands application
print('Step 1: Navigating to OpenHands application...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle', timeout=30000)
# Take initial screenshot
page.screenshot(path='test-results/token_01_initial_load.png')
print('Screenshot saved: token_01_initial_load.png')
# Step 1.5: Handle any initial modals that might appear (LLM API key configuration)
try:
# Check for AI Provider Configuration modal
config_modal = page.locator('text=AI Provider Configuration')
if config_modal.is_visible(timeout=5000):
print('AI Provider Configuration modal detected')
# Fill in the LLM API key if available
llm_api_key_input = page.locator('[data-testid="llm-api-key-input"]')
if llm_api_key_input.is_visible(timeout=3000):
llm_api_key = os.getenv('LLM_API_KEY', 'test-key')
llm_api_key_input.fill(llm_api_key)
print(f'Filled LLM API key (length: {len(llm_api_key)})')
# Click the Save button
save_button = page.locator('button:has-text("Save")')
if save_button.is_visible(timeout=3000):
save_button.click()
page.wait_for_timeout(2000)
print('Saved LLM API key configuration')
# Check for Privacy Preferences modal
privacy_modal = page.locator('text=Your Privacy Preferences')
if privacy_modal.is_visible(timeout=5000):
print('Privacy Preferences modal detected')
confirm_button = page.locator('button:has-text("Confirm Preferences")')
if confirm_button.is_visible(timeout=3000):
confirm_button.click()
page.wait_for_timeout(2000)
print('Confirmed privacy preferences')
except Exception as e:
print(f'Error handling initial modals: {e}')
page.screenshot(path='test-results/token_01_5_modal_error.png')
print('Screenshot saved: token_01_5_modal_error.png')
# Step 2: Check if GitHub token is already configured or needs to be set
print('Step 2: Checking if GitHub token is configured...')
try:
# First, check if we're already on the home screen with repository selection
# This means the GitHub token is already configured in ~/.openhands/settings.json
connect_to_provider = page.locator('text=Connect to a Repository')
if connect_to_provider.is_visible(timeout=3000):
print('Found "Connect to a Repository" section')
# Check if we need to configure a provider (GitHub token)
navigate_to_settings_button = page.locator(
'[data-testid="navigate-to-settings-button"]'
)
if navigate_to_settings_button.is_visible(timeout=3000):
print('GitHub token not configured. Need to navigate to settings...')
# Click the Settings button to navigate to the settings page
navigate_to_settings_button.click()
page.wait_for_load_state('networkidle', timeout=10000)
page.wait_for_timeout(3000) # Wait for navigation to complete
# We should now be on the /settings/integrations page
print('Navigated to settings page, looking for GitHub token input...')
# Check if we're on the settings page with the integrations tab
settings_screen = page.locator('[data-testid="settings-screen"]')
if settings_screen.is_visible(timeout=5000):
print('Settings screen is visible')
# Make sure we're on the Integrations tab
integrations_tab = page.locator('text=Integrations')
if integrations_tab.is_visible(timeout=3000):
# Check if we need to click the tab
if not page.url.endswith('/settings/integrations'):
print('Clicking Integrations tab...')
integrations_tab.click()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# Now look for the GitHub token input
github_token_input = page.locator(
'[data-testid="github-token-input"]'
)
if github_token_input.is_visible(timeout=5000):
print('Found GitHub token input field')
# Fill in the GitHub token from environment variable
github_token = os.getenv('GITHUB_TOKEN', '')
if github_token:
# Clear the field first, then fill it
github_token_input.clear()
github_token_input.fill(github_token)
print(
f'Filled GitHub token from environment variable (length: {len(github_token)})'
)
# Verify the token was filled
filled_value = github_token_input.input_value()
if filled_value:
print(
f'Token field now contains value of length: {len(filled_value)}'
)
else:
print(
'WARNING: Token field appears to be empty after filling'
)
# Look for the Save Changes button and ensure it's enabled
save_button = page.locator('[data-testid="submit-button"]')
if save_button.is_visible(timeout=3000):
# Check if button is enabled
is_disabled = save_button.is_disabled()
print(
f'Save Changes button found, disabled: {is_disabled}'
)
if not is_disabled:
print('Clicking Save Changes button...')
save_button.click()
# Wait for the save operation to complete
try:
# Wait for the button to show "Saving..." (if it does)
page.wait_for_timeout(1000)
# Wait for the save to complete - button should be disabled again
page.wait_for_function(
'document.querySelector(\'[data-testid="submit-button"]\').disabled === true',
timeout=10000,
)
print(
'Save operation completed - form is now clean'
)
except Exception:
print(
'Save operation completed (timeout waiting for form clean state)'
)
# Navigate back to home page after successful save
print('Navigating back to home page...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(
5000
) # Wait longer for providers to be updated
else:
print(
'Save Changes button is disabled - form may be invalid'
)
else:
print('Save Changes button not found')
else:
print('No GitHub token found in environment variables')
else:
print('GitHub token input field not found on settings page')
# Take a screenshot to see what's on the page
page.screenshot(path='test-results/token_02_settings_debug.png')
print('Debug screenshot saved: token_02_settings_debug.png')
else:
print('Settings screen not found')
else:
# Branch 2: GitHub token is already configured, repository selection is available
print(
'GitHub token is already configured, repository selection is available'
)
# Check if we need to update the token by going to settings manually
settings_button = page.locator('button:has-text("Settings")')
if settings_button.is_visible(timeout=3000):
print(
'Settings button found, clicking to navigate to settings page...'
)
settings_button.click()
page.wait_for_load_state('networkidle', timeout=10000)
page.wait_for_timeout(3000) # Wait for navigation to complete
# Navigate to the Integrations tab
integrations_tab = page.locator('text=Integrations')
if integrations_tab.is_visible(timeout=3000):
print('Clicking Integrations tab...')
integrations_tab.click()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# Now look for the GitHub token input
github_token_input = page.locator(
'[data-testid="github-token-input"]'
)
if github_token_input.is_visible(timeout=5000):
print('Found GitHub token input field')
# Fill in the GitHub token from environment variable
github_token = os.getenv('GITHUB_TOKEN', '')
if github_token:
# Clear the field first, then fill it
github_token_input.clear()
github_token_input.fill(github_token)
print(
f'Filled GitHub token from environment variable (length: {len(github_token)})'
)
# Look for the Save Changes button and ensure it's enabled
save_button = page.locator(
'[data-testid="submit-button"]'
)
if (
save_button.is_visible(timeout=3000)
and not save_button.is_disabled()
):
print('Clicking Save Changes button...')
save_button.click()
page.wait_for_timeout(3000)
# Navigate back to home page
print('Navigating back to home page...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(3000)
else:
print(
'GitHub token input field not found, going back to home page'
)
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
else:
print('Integrations tab not found, going back to home page')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
else:
print('Settings button not found, continuing with existing token')
else:
print('Could not find "Connect to a Repository" section')
page.screenshot(path='test-results/token_03_after_settings.png')
print('Screenshot saved: token_03_after_settings.png')
except Exception as e:
print(f'Error checking GitHub token configuration: {e}')
page.screenshot(path='test-results/token_04_error.png')
print('Screenshot saved: token_04_error.png')
# Step 3: Verify we're back on the home screen with repository selection available
print('Step 3: Verifying repository selection is available...')
# Wait for the home screen to load
home_screen = page.locator('[data-testid="home-screen"]')
expect(home_screen).to_be_visible(timeout=15000)
print('Home screen is visible')
# Look for the repository dropdown/selector
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
expect(repo_dropdown).to_be_visible(timeout=15000)
print('Repository dropdown is visible')
# Success - we've verified the GitHub token configuration
print('GitHub token configuration verified successfully')
page.screenshot(path='test-results/token_05_success.png')
print('Screenshot saved: token_05_success.png')

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}')

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'

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__])

142
vscode.md
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

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.