mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
27 Commits
vscode-run
...
fix-timeou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877a857e55 | ||
|
|
2a52902db8 | ||
|
|
02a5b7c307 | ||
|
|
4d4645068c | ||
|
|
8e30ecca11 | ||
|
|
9722d23f38 | ||
|
|
587b4c311a | ||
|
|
7a86402c9c | ||
|
|
06d283dfa0 | ||
|
|
a6a4246e30 | ||
|
|
4830b9a67d | ||
|
|
d4489d62d7 | ||
|
|
e41c020073 | ||
|
|
3f44c8436f | ||
|
|
b740944075 | ||
|
|
5618a3eebb | ||
|
|
a1ffe5c936 | ||
|
|
f8376a9702 | ||
|
|
985a634d60 | ||
|
|
9b78a5e200 | ||
|
|
1ce3723b60 | ||
|
|
95a32ae459 | ||
|
|
9be0acea9c | ||
|
|
1a5965b951 | ||
|
|
f5cbb26770 | ||
|
|
8caad14eb8 | ||
|
|
43e6ce631a |
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
283
frontend/package-lock.json
generated
283
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -122,5 +122,5 @@ export function getStatusCode(
|
||||
return runtimeStatus;
|
||||
}
|
||||
|
||||
return "STATUS$ERROR"; // illegal state
|
||||
return I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
1584
openhands/integrations/vscode/package-lock.json
generated
1584
openhands/integrations/vscode/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import * as vscode from "vscode";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { SocketService } from "./services/socket-service";
|
||||
import { VSCodeRuntimeActionHandler } from "./services/runtime-action-handler";
|
||||
|
||||
// Create output channel for debug logging
|
||||
const outputChannel = vscode.window.createOutputChannel("OpenHands Debug");
|
||||
|
||||
// Runtime services - initialized lazily when needed
|
||||
let socketService: SocketService | null = null;
|
||||
let runtimeActionHandler: VSCodeRuntimeActionHandler | null = null;
|
||||
|
||||
// Connection status tracking
|
||||
enum ConnectionStatus {
|
||||
DISCONNECTED = "disconnected",
|
||||
CONNECTING = "connecting",
|
||||
CONNECTED = "connected",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
let connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED;
|
||||
let connectionError: string | null = null;
|
||||
|
||||
/**
|
||||
* This implementation uses VSCode's Shell Integration API.
|
||||
*
|
||||
@@ -297,119 +280,7 @@ function startOpenHandsInTerminal(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// Old initializeRuntime function removed - replaced with lazy connection via ensureConnected()
|
||||
|
||||
/**
|
||||
* Lazy connection to OpenHands backend - only connects when needed
|
||||
*/
|
||||
async function ensureConnected(): Promise<boolean> {
|
||||
// If already connected, return true
|
||||
if (connectionStatus === ConnectionStatus.CONNECTED && socketService) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If currently connecting, don't start another connection attempt
|
||||
if (connectionStatus === ConnectionStatus.CONNECTING) {
|
||||
vscode.window.showInformationMessage(
|
||||
"⏳ Already connecting to OpenHands...",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
connectionStatus = ConnectionStatus.CONNECTING;
|
||||
connectionError = null;
|
||||
|
||||
try {
|
||||
// Get server URL from configuration
|
||||
const config = vscode.workspace.getConfiguration("openhands");
|
||||
const serverUrl = config.get<string>("serverUrl", "http://localhost:3000");
|
||||
|
||||
outputChannel.appendLine(
|
||||
`DEBUG: Connecting to OpenHands backend at: ${serverUrl}`,
|
||||
);
|
||||
|
||||
// Initialize services if not already done
|
||||
if (!socketService) {
|
||||
socketService = new SocketService(serverUrl);
|
||||
}
|
||||
|
||||
if (!runtimeActionHandler) {
|
||||
runtimeActionHandler = new VSCodeRuntimeActionHandler();
|
||||
runtimeActionHandler.setSocketService(socketService);
|
||||
|
||||
// Set up event listener for incoming actions
|
||||
socketService.onEvent((event) => {
|
||||
if (runtimeActionHandler) {
|
||||
runtimeActionHandler.handleAction(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt connection
|
||||
await socketService.connect();
|
||||
|
||||
connectionStatus = ConnectionStatus.CONNECTED;
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: Successfully connected to OpenHands backend",
|
||||
);
|
||||
vscode.window.showInformationMessage(
|
||||
"✅ Connected to OpenHands - ready to execute actions in VSCode",
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
connectionStatus = ConnectionStatus.ERROR;
|
||||
connectionError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
outputChannel.appendLine(
|
||||
`ERROR: Failed to connect to OpenHands backend: ${connectionError}`,
|
||||
);
|
||||
|
||||
// Show user-friendly error message
|
||||
const errorMsg = `❌ Cannot connect to OpenHands server. Is OpenHands running?\n\nError: ${connectionError}`;
|
||||
const result = await vscode.window.showErrorMessage(
|
||||
errorMsg,
|
||||
"Retry Connection",
|
||||
"Check Configuration",
|
||||
);
|
||||
|
||||
if (result === "Retry Connection") {
|
||||
// Reset status and try again
|
||||
connectionStatus = ConnectionStatus.DISCONNECTED;
|
||||
return ensureConnected();
|
||||
}
|
||||
if (result === "Check Configuration") {
|
||||
// Open settings
|
||||
vscode.commands.executeCommand(
|
||||
"workbench.action.openSettings",
|
||||
"openhands.serverUrl",
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up runtime services
|
||||
*/
|
||||
function cleanupRuntime(): void {
|
||||
if (socketService) {
|
||||
socketService.disconnect();
|
||||
socketService = null;
|
||||
}
|
||||
runtimeActionHandler = null;
|
||||
connectionStatus = ConnectionStatus.DISCONNECTED;
|
||||
connectionError = null;
|
||||
outputChannel.appendLine("DEBUG: OpenHands runtime services cleaned up");
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
// Note: Runtime services are now initialized lazily when user runs commands
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: OpenHands extension activated - runtime will connect on-demand",
|
||||
);
|
||||
// Clean up terminal tracking when terminals are closed
|
||||
const terminalCloseDisposable = vscode.window.onDidCloseTerminal(
|
||||
(terminal) => {
|
||||
@@ -421,12 +292,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Command: Start New Conversation
|
||||
const startConversationDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversation",
|
||||
async () => {
|
||||
// Ensure connection before starting conversation
|
||||
const connected = await ensureConnected();
|
||||
if (connected) {
|
||||
startOpenHandsInTerminal({});
|
||||
}
|
||||
() => {
|
||||
startOpenHandsInTerminal({});
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(startConversationDisposable);
|
||||
@@ -434,12 +301,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Command: Start Conversation with Active File Content
|
||||
const startWithFileContextDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversationWithFileContext",
|
||||
async () => {
|
||||
// Ensure connection before starting conversation
|
||||
const connected = await ensureConnected();
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
() => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
// No active editor, start conversation without task
|
||||
@@ -474,12 +336,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Command: Start Conversation with Selected Text
|
||||
const startWithSelectionContextDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
async () => {
|
||||
// Ensure connection before starting conversation
|
||||
const connected = await ensureConnected();
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
() => {
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: startConversationWithSelectionContext command triggered!",
|
||||
);
|
||||
@@ -515,29 +372,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(startWithSelectionContextDisposable);
|
||||
|
||||
// Command: Test Connection to OpenHands
|
||||
const testConnectionDisposable = vscode.commands.registerCommand(
|
||||
"openhands.testConnection",
|
||||
async () => {
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: Testing connection to OpenHands backend...",
|
||||
);
|
||||
const connected = await ensureConnected();
|
||||
if (connected) {
|
||||
vscode.window.showInformationMessage(
|
||||
"✅ OpenHands connection successful!",
|
||||
);
|
||||
}
|
||||
// Error handling is done in ensureConnected()
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(testConnectionDisposable);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
// Clean up runtime services
|
||||
cleanupRuntime();
|
||||
// Clean up resources if needed, though for this simple extension,
|
||||
// VS Code handles terminal disposal.
|
||||
}
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
OpenHandsEventType,
|
||||
OpenHandsObservationEvent,
|
||||
OpenHandsParsedEvent,
|
||||
isOpenHandsAction,
|
||||
} from "openhands-types";
|
||||
import { SocketService } from "./socket-service";
|
||||
|
||||
export class VSCodeRuntimeActionHandler {
|
||||
private workspacePath: string | undefined;
|
||||
|
||||
private socketService: SocketService | null = null;
|
||||
|
||||
constructor() {
|
||||
// Determine the workspace path for security restrictions
|
||||
const { workspaceFolders } = vscode.workspace;
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
this.workspacePath = workspaceFolders[0].uri.fsPath;
|
||||
console.log(`Workspace path set to: ${this.workspacePath}`);
|
||||
} else {
|
||||
console.warn(
|
||||
"No workspace folder found. File operations will be restricted.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setSocketService(socketService: SocketService): void {
|
||||
this.socketService = socketService;
|
||||
console.log("SocketService set for VSCodeRuntimeActionHandler");
|
||||
}
|
||||
|
||||
private sanitizePath(filePath: string): string | null {
|
||||
if (!this.workspacePath) {
|
||||
console.error(
|
||||
"No workspace path defined. Blocking file operation for security.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle absolute and relative paths
|
||||
let resolvedPath = filePath;
|
||||
if (!filePath.startsWith("/")) {
|
||||
resolvedPath = `${this.workspacePath}/${filePath}`;
|
||||
}
|
||||
|
||||
// Basic check to prevent path traversal
|
||||
if (!resolvedPath.startsWith(this.workspacePath)) {
|
||||
console.error(
|
||||
`Path traversal attempt detected. Path ${resolvedPath} is outside workspace ${this.workspacePath}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private static async openOrFocusFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
const uri = vscode.Uri.file(filePath);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(document);
|
||||
} catch (error) {
|
||||
console.error(`Failed to open file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
handleAction(event: OpenHandsParsedEvent): void {
|
||||
if (!isOpenHandsAction(event) || !event.args) {
|
||||
console.error("Invalid event received for action handling:", event);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Handling action: ${event.action} with args:`, event.args);
|
||||
|
||||
switch (event.action) {
|
||||
case "run":
|
||||
this.handleRunAction(event);
|
||||
break;
|
||||
case "read":
|
||||
this.handleReadAction(event);
|
||||
break;
|
||||
case "write":
|
||||
this.handleWriteAction(event);
|
||||
break;
|
||||
case "edit":
|
||||
this.handleEditAction(event);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unsupported action received: ${event.action}`);
|
||||
this.sendErrorObservation(event, `Unsupported action: ${event.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
private sendObservation(
|
||||
event: OpenHandsParsedEvent,
|
||||
observationType: string,
|
||||
content: string,
|
||||
extras: Record<string, unknown> = {},
|
||||
error: boolean = false,
|
||||
): void {
|
||||
const observationEvent: OpenHandsObservationEvent<OpenHandsEventType> = {
|
||||
id: Date.now(),
|
||||
observation: observationType as OpenHandsEventType,
|
||||
content,
|
||||
extras,
|
||||
message: error
|
||||
? `Error during ${observationType} operation`
|
||||
: `VSCode executed ${observationType} operation`,
|
||||
source: "environment",
|
||||
cause: -1,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if ("id" in event && typeof event.id === "number") {
|
||||
observationEvent.cause = event.id;
|
||||
}
|
||||
|
||||
if (this.socketService) {
|
||||
this.socketService.sendEvent(
|
||||
observationEvent as unknown as OpenHandsParsedEvent,
|
||||
);
|
||||
} else {
|
||||
console.error("Cannot send observation: SocketService is not set");
|
||||
console.log("Observation that would have been sent:", observationEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private sendErrorObservation(
|
||||
event: OpenHandsParsedEvent,
|
||||
errorMessage: string,
|
||||
): void {
|
||||
this.sendObservation(
|
||||
event,
|
||||
"action" in event ? event.action || "unknown" : "unknown",
|
||||
errorMessage,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private handleRunAction(event: OpenHandsParsedEvent): void {
|
||||
if (!isOpenHandsAction(event) || event.action !== "run") {
|
||||
this.sendErrorObservation(event, "Invalid event type for run action");
|
||||
return;
|
||||
}
|
||||
const args = event.args as Record<string, unknown>;
|
||||
const command = args.command as string | undefined;
|
||||
if (!command) {
|
||||
this.sendErrorObservation(event, "No command provided for run action");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or get a terminal for OpenHands commands
|
||||
const terminalName = "OpenHands Runtime";
|
||||
let terminal = vscode.window.terminals.find((t) => t.name === terminalName);
|
||||
if (!terminal) {
|
||||
terminal = vscode.window.createTerminal(terminalName);
|
||||
}
|
||||
terminal.show(true); // Show the terminal but preserve focus on editor
|
||||
|
||||
// Send the command to the terminal
|
||||
terminal.sendText(command);
|
||||
|
||||
// For now, we can't reliably capture terminal output programmatically
|
||||
// So we'll send a placeholder observation
|
||||
this.sendObservation(
|
||||
event,
|
||||
"run",
|
||||
`Command '${command}' sent to terminal. Output will be visible in the '${terminalName}' terminal.`,
|
||||
{ command, exit_code: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
private async handleReadAction(event: OpenHandsParsedEvent): Promise<void> {
|
||||
if (!isOpenHandsAction(event) || event.action !== "read") {
|
||||
this.sendErrorObservation(event, "Invalid event type for read action");
|
||||
return;
|
||||
}
|
||||
const args = event.args as { path?: string };
|
||||
const filePath = args.path;
|
||||
if (!filePath) {
|
||||
this.sendErrorObservation(event, "No path provided for read action");
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath);
|
||||
if (!sanitizedPath) {
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(sanitizedPath);
|
||||
const contentBuffer = await vscode.workspace.fs.readFile(uri);
|
||||
const content = contentBuffer.toString();
|
||||
this.sendObservation(event, "read", content, { path: filePath });
|
||||
// Optionally open the file in the editor for viewing
|
||||
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${sanitizedPath}:`, error);
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Error reading file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWriteAction(event: OpenHandsParsedEvent): Promise<void> {
|
||||
if (!isOpenHandsAction(event) || event.action !== "write") {
|
||||
this.sendErrorObservation(event, "Invalid event type for write action");
|
||||
return;
|
||||
}
|
||||
const args = event.args as { path: string; content: string };
|
||||
const filePath = args.path;
|
||||
const { content } = args;
|
||||
if (!filePath || content === undefined) {
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
"Missing path or content for write action",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath);
|
||||
if (!sanitizedPath) {
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(sanitizedPath);
|
||||
const contentBuffer = new TextEncoder().encode(content);
|
||||
await vscode.workspace.fs.writeFile(uri, contentBuffer);
|
||||
this.sendObservation(
|
||||
event,
|
||||
"write",
|
||||
`File ${filePath} written successfully`,
|
||||
{ path: filePath },
|
||||
);
|
||||
// Open the file in the editor for viewing
|
||||
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
|
||||
} catch (error) {
|
||||
console.error(`Error writing to file ${sanitizedPath}:`, error);
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Error writing to file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEditAction(event: OpenHandsParsedEvent): Promise<void> {
|
||||
if (!isOpenHandsAction(event) || event.action !== "edit") {
|
||||
this.sendErrorObservation(event, "Invalid event type for edit action");
|
||||
return;
|
||||
}
|
||||
const args = event.args as { path: string; content: string };
|
||||
const filePath = args.path;
|
||||
const newContent = args.content;
|
||||
if (!filePath || newContent === undefined) {
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
"Missing path or content for edit action",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath);
|
||||
if (!sanitizedPath) {
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Invalid path: ${filePath}. Path resolves outside the workspace.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(sanitizedPath);
|
||||
// Read the current content to potentially show a diff
|
||||
let oldContent = "";
|
||||
try {
|
||||
const currentContentBuffer = await vscode.workspace.fs.readFile(uri);
|
||||
oldContent = currentContentBuffer.toString();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not read current content of ${filePath} for diff, file might not exist yet.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Write the new content
|
||||
const contentBuffer = new TextEncoder().encode(newContent);
|
||||
await vscode.workspace.fs.writeFile(uri, contentBuffer);
|
||||
|
||||
// Open or focus the file to show changes
|
||||
await VSCodeRuntimeActionHandler.openOrFocusFile(sanitizedPath);
|
||||
|
||||
this.sendObservation(
|
||||
event,
|
||||
"edit",
|
||||
`File ${filePath} edited successfully`,
|
||||
{ path: filePath, old_content: oldContent, new_content: newContent },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error editing file ${sanitizedPath}:`, error);
|
||||
this.sendErrorObservation(
|
||||
event,
|
||||
`Error editing file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { OpenHandsParsedEvent } from "openhands-types";
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "path";
|
||||
|
||||
export class SocketService {
|
||||
private socket: Socket | null = null;
|
||||
|
||||
private serverUrl: string;
|
||||
|
||||
private conversationId: string | null = null;
|
||||
|
||||
private connectionId: string | null = null;
|
||||
|
||||
private eventListeners: Array<(event: OpenHandsParsedEvent) => void> = [];
|
||||
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(serverUrl: string) {
|
||||
this.serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Step 1: Register this VSCode instance with the server
|
||||
await this.registerVSCodeInstance();
|
||||
|
||||
// Step 2: Initialize a conversation via HTTP API
|
||||
const response = await fetch(`${this.serverUrl}/api/conversations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ initial_user_msg: "VSCode Runtime Connection" }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to initialize conversation: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// TODO: Type check, do this better
|
||||
this.conversationId = (
|
||||
data as { conversation_id: string }
|
||||
).conversation_id;
|
||||
|
||||
// Now connect via Socket.IO
|
||||
this.socket = io(this.serverUrl, {
|
||||
query: {
|
||||
conversation_id: this.conversationId,
|
||||
latest_event_id: "-1",
|
||||
},
|
||||
});
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
console.log("Connected to OpenHands backend via Socket.IO");
|
||||
});
|
||||
|
||||
this.socket.on("oh_event", (event: OpenHandsParsedEvent) => {
|
||||
console.log("Received event:", event);
|
||||
this.eventListeners.forEach((listener) => listener(event));
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
console.log("Disconnected from OpenHands backend");
|
||||
});
|
||||
|
||||
this.socket.on("error", (error: unknown) => {
|
||||
console.error("Socket.IO error:", error);
|
||||
});
|
||||
|
||||
this.socket.on("connect_error", (error: unknown) => {
|
||||
console.error("Socket.IO connection error:", error);
|
||||
});
|
||||
|
||||
// Step 3: Start heartbeat to keep registration alive
|
||||
this.startHeartbeat();
|
||||
} catch (error) {
|
||||
console.error("Error connecting to OpenHands backend:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Stop heartbeat
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Unregister from VSCode registry
|
||||
if (this.connectionId) {
|
||||
this.unregisterVSCodeInstance().catch((error) => {
|
||||
console.error("Failed to unregister VSCode instance:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect socket
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
console.log("Socket.IO connection closed");
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.conversationId = null;
|
||||
this.connectionId = null;
|
||||
}
|
||||
|
||||
onEvent(listener: (event: OpenHandsParsedEvent) => void): void {
|
||||
this.eventListeners.push(listener);
|
||||
}
|
||||
|
||||
sendEvent(event: OpenHandsParsedEvent): void {
|
||||
if (this.socket && this.socket.connected) {
|
||||
this.socket.emit("oh_event", event);
|
||||
console.log("Sent event:", event);
|
||||
} else {
|
||||
console.error("Cannot send event: Socket is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionId(): string | null {
|
||||
return this.connectionId;
|
||||
}
|
||||
|
||||
private async registerVSCodeInstance(): Promise<void> {
|
||||
try {
|
||||
// Get workspace information
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workspacePath = workspaceFolder?.uri.fsPath || "";
|
||||
const workspaceName =
|
||||
workspaceFolder?.name ||
|
||||
path.basename(workspacePath) ||
|
||||
"Unknown Workspace";
|
||||
|
||||
// Get VSCode version
|
||||
const vscodeVersion = vscode.version;
|
||||
|
||||
// Get extension version (from package.json)
|
||||
const extensionVersion =
|
||||
vscode.extensions.getExtension("openhands.openhands-vscode")
|
||||
?.packageJSON?.version || "0.0.1";
|
||||
|
||||
// Define capabilities
|
||||
const capabilities = [
|
||||
"file_operations",
|
||||
"text_editing",
|
||||
"workspace_navigation",
|
||||
"terminal_access",
|
||||
];
|
||||
|
||||
const registrationData = {
|
||||
workspace_path: workspacePath,
|
||||
workspace_name: workspaceName,
|
||||
vscode_version: vscodeVersion,
|
||||
extension_version: extensionVersion,
|
||||
capabilities,
|
||||
};
|
||||
|
||||
console.log("Registering VSCode instance:", registrationData);
|
||||
|
||||
const response = await fetch(`${this.serverUrl}/api/vscode/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(registrationData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to register VSCode instance: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.connectionId = (data as { connection_id: string }).connection_id;
|
||||
|
||||
console.log(
|
||||
`VSCode instance registered with connection ID: ${this.connectionId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error registering VSCode instance:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async unregisterVSCodeInstance(): Promise<void> {
|
||||
if (!this.connectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.serverUrl}/api/vscode/unregister/${this.connectionId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to unregister VSCode instance: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`VSCode instance unregistered: ${this.connectionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error unregistering VSCode instance:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
if (!this.connectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send heartbeat every 30 seconds
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.serverUrl}/api/vscode/heartbeat/${this.connectionId}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Heartbeat failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Heartbeat error:", error);
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from "path";
|
||||
import Mocha = require("mocha"); // Changed import style
|
||||
import { glob } from "glob"; // Use named import for modern glob
|
||||
import glob = require("glob"); // Changed import style
|
||||
|
||||
export function run(): Promise<void> {
|
||||
// Create the mocha test
|
||||
@@ -14,10 +14,14 @@ export function run(): Promise<void> {
|
||||
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
|
||||
|
||||
return new Promise((c, e) => {
|
||||
(async () => {
|
||||
try {
|
||||
// Use glob to find all test files (ending with .test.js in the compiled output)
|
||||
const files = await glob("**/**.test.js", { cwd: testsRoot });
|
||||
// Use glob to find all test files (ending with .test.js in the compiled output)
|
||||
glob(
|
||||
"**/**.test.js",
|
||||
{ cwd: testsRoot },
|
||||
(err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) {
|
||||
return e(err);
|
||||
}
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
|
||||
@@ -35,10 +39,7 @@ export function run(): Promise<void> {
|
||||
console.error(err);
|
||||
e(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error finding test files:", err);
|
||||
e(err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import * as vscode from "vscode";
|
||||
import { VSCodeRuntimeActionHandler } from "../../services/runtime-action-handler";
|
||||
import { SocketService } from "../../services/socket-service";
|
||||
|
||||
suite("VSCodeRuntimeActionHandler Test Suite", () => {
|
||||
let handler: VSCodeRuntimeActionHandler;
|
||||
let mockSocketService: SocketService;
|
||||
let originalWorkspaceFolders: PropertyDescriptor | undefined;
|
||||
|
||||
setup(() => {
|
||||
// Create handler instance
|
||||
handler = new VSCodeRuntimeActionHandler();
|
||||
|
||||
// Create mock socket service
|
||||
mockSocketService = {
|
||||
onEvent: () => {},
|
||||
sendEvent: () => {},
|
||||
connect: () => Promise.resolve(),
|
||||
disconnect: () => {},
|
||||
getConnectionId: () => null,
|
||||
} as any;
|
||||
|
||||
// Store original workspace folders for restoration
|
||||
originalWorkspaceFolders = Object.getOwnPropertyDescriptor(
|
||||
vscode.workspace,
|
||||
"workspaceFolders",
|
||||
);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
// Restore original workspace folders
|
||||
if (originalWorkspaceFolders) {
|
||||
Object.defineProperty(
|
||||
vscode.workspace,
|
||||
"workspaceFolders",
|
||||
originalWorkspaceFolders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
suite("Constructor and Initialization", () => {
|
||||
test("should initialize without workspace", () => {
|
||||
// Mock no workspace folders
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const handlerNoWorkspace = new VSCodeRuntimeActionHandler();
|
||||
assert.ok(
|
||||
handlerNoWorkspace,
|
||||
"Handler should be created even without workspace",
|
||||
);
|
||||
});
|
||||
|
||||
test("should initialize with workspace", () => {
|
||||
// Mock workspace folders
|
||||
const mockWorkspaceFolder = {
|
||||
uri: vscode.Uri.file("/test/workspace"),
|
||||
name: "test-workspace",
|
||||
index: 0,
|
||||
};
|
||||
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
get: () => [mockWorkspaceFolder],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const handlerWithWorkspace = new VSCodeRuntimeActionHandler();
|
||||
assert.ok(
|
||||
handlerWithWorkspace,
|
||||
"Handler should be created with workspace",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle multiple workspace folders", () => {
|
||||
// Mock multiple workspace folders
|
||||
const mockWorkspaceFolders = [
|
||||
{
|
||||
uri: vscode.Uri.file("/test/workspace1"),
|
||||
name: "workspace1",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
uri: vscode.Uri.file("/test/workspace2"),
|
||||
name: "workspace2",
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
get: () => mockWorkspaceFolders,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const handlerMultiWorkspace = new VSCodeRuntimeActionHandler();
|
||||
assert.ok(
|
||||
handlerMultiWorkspace,
|
||||
"Handler should be created with multiple workspaces",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite("SocketService Integration", () => {
|
||||
test("should accept socket service", () => {
|
||||
handler.setSocketService(mockSocketService);
|
||||
assert.ok(true, "Should accept socket service without error");
|
||||
});
|
||||
|
||||
test("should handle socket service events", () => {
|
||||
let eventListenerAdded = false;
|
||||
const mockSocketWithEventTracking = {
|
||||
onEvent: (listener: any) => {
|
||||
eventListenerAdded = true;
|
||||
assert.ok(
|
||||
typeof listener === "function",
|
||||
"Event listener should be a function",
|
||||
);
|
||||
},
|
||||
sendEvent: () => {},
|
||||
connect: () => Promise.resolve(),
|
||||
disconnect: () => {},
|
||||
getConnectionId: () => null,
|
||||
} as any;
|
||||
|
||||
handler.setSocketService(mockSocketWithEventTracking);
|
||||
assert.ok(
|
||||
eventListenerAdded,
|
||||
"Should add event listener to socket service",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite("Action Validation", () => {
|
||||
test("should validate action structure", () => {
|
||||
// Test with valid action-like object
|
||||
const validAction = {
|
||||
event_type: "action",
|
||||
action: "run",
|
||||
args: { command: "echo test" },
|
||||
};
|
||||
|
||||
// We can't directly test isOpenHandsAction without importing it,
|
||||
// but we can test that the handler doesn't throw with valid structure
|
||||
assert.ok(validAction.event_type, "Valid action should have event_type");
|
||||
assert.ok(validAction.action, "Valid action should have action");
|
||||
});
|
||||
|
||||
test("should handle invalid action structure", () => {
|
||||
// Test with invalid action-like object
|
||||
const invalidAction = {
|
||||
// Missing required fields
|
||||
some_field: "value",
|
||||
};
|
||||
|
||||
// Handler should be able to process this without throwing
|
||||
assert.ok(
|
||||
typeof invalidAction === "object",
|
||||
"Should handle object input",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import { SocketService } from "../../services/socket-service";
|
||||
|
||||
// Mock Socket.IO client (for future use if needed)
|
||||
// const mockSocket = {
|
||||
// on: () => {},
|
||||
// emit: () => {},
|
||||
// disconnect: () => {},
|
||||
// connected: true,
|
||||
// id: "mock-socket-id",
|
||||
// };
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
suite("SocketService Test Suite", () => {
|
||||
let socketService: SocketService;
|
||||
let mockFetch: any;
|
||||
|
||||
setup(() => {
|
||||
// Create service instance
|
||||
socketService = new SocketService("http://localhost:3000");
|
||||
|
||||
// Reset fetch mock
|
||||
mockFetch = null;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
// Restore original fetch
|
||||
if (originalFetch) {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
// Clean up service
|
||||
if (socketService) {
|
||||
socketService.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
suite("Constructor and Initialization", () => {
|
||||
test("should initialize with server URL", () => {
|
||||
const service = new SocketService("http://test-server:8080");
|
||||
assert.ok(service, "SocketService should be created");
|
||||
});
|
||||
|
||||
test("should store server URL correctly", () => {
|
||||
const serverUrl = "http://custom-server:9000";
|
||||
const service = new SocketService(serverUrl);
|
||||
// We can't directly access private properties, but we can test behavior
|
||||
assert.ok(service, "Service should be initialized with custom URL");
|
||||
});
|
||||
|
||||
test("should have null connection ID initially", () => {
|
||||
const connectionId = socketService.getConnectionId();
|
||||
assert.strictEqual(
|
||||
connectionId,
|
||||
null,
|
||||
"Connection ID should be null initially",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite("Event Handling Interface", () => {
|
||||
test("should allow adding event listeners", () => {
|
||||
const listener = (event: any) => {
|
||||
console.log("Event received:", event);
|
||||
};
|
||||
|
||||
// This tests the public interface
|
||||
socketService.onEvent(listener);
|
||||
assert.ok(true, "Should allow adding event listeners without error");
|
||||
});
|
||||
|
||||
test("should allow sending events when not connected", () => {
|
||||
const mockEvent = {
|
||||
id: "test-event-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "vscode",
|
||||
message: "test message",
|
||||
event_type: "test",
|
||||
} as any;
|
||||
|
||||
// This should not throw even if not connected
|
||||
socketService.sendEvent(mockEvent);
|
||||
assert.ok(
|
||||
true,
|
||||
"Should allow sending events without error when disconnected",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite("Registration Workflow", () => {
|
||||
test("should prepare correct registration data", async () => {
|
||||
let registrationCalled = false;
|
||||
let registrationData: any = null;
|
||||
|
||||
// Mock successful registration
|
||||
mockFetch = (url: string, options?: any) => {
|
||||
if (url.includes("/api/vscode/register")) {
|
||||
registrationCalled = true;
|
||||
registrationData = JSON.parse(options.body);
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
connection_id: "test-connection-id",
|
||||
status: "registered",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/conversations")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
conversation_id: "test-conversation-id",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
};
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
await socketService.connect();
|
||||
} catch (error) {
|
||||
// Expected to fail due to Socket.IO mocking limitations
|
||||
}
|
||||
|
||||
assert.ok(registrationCalled, "Registration should be called");
|
||||
assert.ok(registrationData, "Registration data should be captured");
|
||||
assert.ok(
|
||||
registrationData.workspace_path !== undefined,
|
||||
"Should include workspace path",
|
||||
);
|
||||
assert.ok(
|
||||
registrationData.vscode_version,
|
||||
"Should include VSCode version",
|
||||
);
|
||||
assert.ok(
|
||||
registrationData.extension_version,
|
||||
"Should include extension version",
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(registrationData.capabilities),
|
||||
"Should include capabilities array",
|
||||
);
|
||||
assert.ok(
|
||||
registrationData.capabilities.includes("file_operations"),
|
||||
"Should include file_operations capability",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle registration failure", async () => {
|
||||
mockFetch = (url: string) => {
|
||||
if (url.includes("/api/vscode/register")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
};
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
await socketService.connect();
|
||||
assert.fail("Should have thrown an error for registration failure");
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof Error, "Should throw an Error");
|
||||
assert.ok(
|
||||
(error as Error).message.includes(
|
||||
"Failed to register VSCode instance",
|
||||
),
|
||||
"Should have descriptive error message",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle network errors during registration", async () => {
|
||||
mockFetch = () => Promise.reject(new Error("Network error"));
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
await socketService.connect();
|
||||
assert.fail("Should have thrown an error for network failure");
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof Error, "Should throw an Error");
|
||||
assert.ok(
|
||||
(error as Error).message.includes("Network error"),
|
||||
"Should propagate network error",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suite("Conversation Creation", () => {
|
||||
test("should create conversation after successful registration", async () => {
|
||||
let conversationCalled = false;
|
||||
let conversationData: any = null;
|
||||
|
||||
mockFetch = (url: string, options?: any) => {
|
||||
if (url.includes("/api/vscode/register")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
connection_id: "test-connection-id",
|
||||
status: "registered",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/conversations")) {
|
||||
conversationCalled = true;
|
||||
conversationData = JSON.parse(options.body);
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
conversation_id: "test-conversation-id",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
};
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
await socketService.connect();
|
||||
} catch (error) {
|
||||
// Expected to fail due to Socket.IO mocking limitations
|
||||
}
|
||||
|
||||
assert.ok(conversationCalled, "Conversation creation should be called");
|
||||
assert.ok(conversationData, "Conversation data should be captured");
|
||||
assert.strictEqual(
|
||||
conversationData.initial_user_msg,
|
||||
"VSCode Runtime Connection",
|
||||
"Should have correct initial message",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle conversation creation failure", async () => {
|
||||
mockFetch = (url: string) => {
|
||||
if (url.includes("/api/vscode/register")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
connection_id: "test-connection-id",
|
||||
status: "registered",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/conversations")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
};
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
await socketService.connect();
|
||||
assert.fail(
|
||||
"Should have thrown an error for conversation creation failure",
|
||||
);
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof Error, "Should throw an Error");
|
||||
assert.ok(
|
||||
(error as Error).message.includes(
|
||||
"Failed to initialize conversation",
|
||||
),
|
||||
"Should have descriptive error message",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suite("Disconnection and Cleanup", () => {
|
||||
test("should handle disconnection gracefully when not connected", () => {
|
||||
try {
|
||||
socketService.disconnect();
|
||||
assert.ok(true, "Should handle disconnection without error");
|
||||
} catch (error) {
|
||||
assert.fail("Disconnection should not throw error when not connected");
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple disconnects safely", () => {
|
||||
// Test that disconnect doesn't throw and cleans up properly
|
||||
socketService.disconnect();
|
||||
|
||||
// Try to disconnect again - should not throw
|
||||
socketService.disconnect();
|
||||
assert.ok(true, "Multiple disconnects should be safe");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .vscode_runtime import VsCodeRuntime
|
||||
|
||||
__all__ = ['VsCodeRuntime']
|
||||
@@ -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.')
|
||||
@@ -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)
|
||||
|
||||
@@ -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
19
poetry.lock
generated
@@ -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
391
task.md
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
604
tests/e2e/test_conversation.py
Normal file
604
tests/e2e/test_conversation.py
Normal 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
290
tests/e2e/test_settings.py
Normal 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')
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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
142
vscode.md
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user