- This repository is the OpenHands frontend.
- Frontend API adaptation lives mainly in
src/api/:option-servicefabricates a web-client config and reads models/providers through@openhands/typescript-clientLLM endpoints.settings-serviceuses@openhands/typescript-clientsettings APIs for persistence; reads schemas from/api/settings/agent-schemaand/api/settings/conversation-schema, fetches settings with optionalX-Expose-Secrets: encryptedheader for conversation start payloads, and saves settings via PATCH with diffs.agent-server-conversation-service,event-service,agent-server-git-service, andskills-serviceroute local agent-server access through@openhands/typescript-clientrather than direct HTTP calls.
- Supported env vars for deployment:
VITE_BACKEND_BASE_URLfor the agent server base URL.VITE_SESSION_API_KEYfor optional session auth.VITE_WORKING_DIRfor the default workspace path sent when starting conversations.VITE_ENABLE_BROWSER_TOOLS=falseto omit thebrowser_tool_settool from new conversation payloads.VITE_BASE_PATHfor serving the SPA under a subpath such as/canvas; pair it withscripts/static-server.mjs --base-pathat runtime.
- Public skills are loaded from the
@openhands/extensionsnpm package at build time viaSKILLS_CATALOG(exported from@openhands/extensions/skills). The frontend'sSkillsServicemaps catalog entries toSkillInfoobjects and merges them with user/project skills fetched from the agent-server (withload_public: false). The agent-server no longer clones the extensions repo or usesEXTENSIONS_REFfor public skills. - Default working-dir fallback is now the relative path
workspace/project(exported asDEFAULT_WORKING_DIRfromsrc/api/agent-server-config.ts); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding/workspace/project. - Current Cloud behavior is implemented explicitly through the backend registry, Cloud service layer, and device authorization flow.
- Primary verification commands:
npm run lint,npm test,npm run build, andnpm run build:lib. - GitHub automation now includes
.github/workflows/ci.ymlfornpm ci,npm test, andnpm run build, plus.github/dependabot.ymlwith weekly npm/github-actions updates gated by a 7-day cooldown.
This repo (OpenHands/OpenHands) is only the agent-canvas frontend. It is one
piece of a multi-repo system. Before adding code here, check the change belongs in
this repo β several kinds of work belong in a sibling repo instead.
| Repo | Owns | Add code here when⦠|
|---|---|---|
OpenHands/OpenHands (this repo) |
The React/TypeScript frontend (agent-canvas): UI, routes, frontend services in src/api/ that consume backend APIs. |
You are changing UI, frontend state, or how the frontend calls an existing backend endpoint. |
OpenHands/software-agent-sdk |
The Python SDK + agent-server: agents, tools, conversations, events, and the REST/WebSocket API surface (openhands-sdk, openhands-tools, openhands-agent-server, openhands-workspace). |
You are adding or changing a backend endpoint, agent/tool behaviour, or server-side logic. New API endpoints live here, not in the frontend. |
OpenHands/typescript-client (@openhands/typescript-client) |
The generated/maintained TypeScript client that mirrors the agent-server API. The frontend's only sanctioned way to reach the agent-server (see "API Access Rules"). | You are adding client-side access to an agent-server endpoint (typed client method, request/response types). API-access code belongs here, not re-implemented in this repo. |
OpenHands/extensions (@openhands/extensions) |
Public skills, automations, and integrations (loaded here at build time via SKILLS_CATALOG). |
You are adding or editing a skill, automation, or MCP integration. |
Common mis-placements to avoid:
- API endpoint access β belongs in
typescript-client, then consumed here. Do not add rawaxios/fetchendpoint code to the frontend (CI guard:src/api/no-direct-agent-server-calls.test.ts; see "API Access Rules"). - New server endpoints / agent or tool logic β belongs in
software-agent-sdk. - Skills / automations / integrations β belong in
extensions.
The HUMAN: section in PR descriptions is reserved for human contributors only.
AI agents MUST NOT add to, edit, move, or remove it. If the PR description
CI fails because the section is missing or empty, stop and ask the
human user to update it in their own words. If the section was already updated
by a human, report the exact validator error rather than editing it yourself.
One Canvas-owned PostHog client owns telemetry and app analytics.
src/services/telemetry.tsis the only module that accesses the namedagent-canvasPostHog client. The name isolates Canvas identity, persistence, configuration, and consent from an embedding host's default singleton. React code declares Cloud user identity and event context through the service and captures through the service; it never receives, identifies, or resets the SDK client directly.TelemetryProviderconfigures bootstrap/runtime options, eagerly initializes the service, and is the sole owner of theuseTelemetry()lifecycle that emits install/session events. Do not mount that lifecycle hook separately in Canvas routes or internal components. The provider does not expose PostHog context or maintain a second client lifecycle.- The default PostHog key and direct ingestion host live in
config/defaults.jsonundertelemetry. Local launchers (dev-with-automation,dev-static, published binary path) and Docker defaultAUTOMATION_POSTHOG_API_KEYfrom explicit automation env, thenVITE_POSTHOG_API_KEY, then that shared default key, so the automation backend can emit local consent-gated telemetry without extra user config. KeepVITE_DO_NOT_TRACK=1disabling the zero-config default. - Unconfigured source builds use the staging key and route through
https://z.openhands.dev. Release workflows pass the public production key throughVITE_POSTHOG_API_KEY. Precompiled npm consumers overrideapiKey,apiHost, anduiHostat runtime throughAgentServerUIProviders.analyticsorconfigureTelemetry(). setTelemetryConsentis the only user-consent controller;configureTelemetry(false)is the embedding host's hard disable. An explicit first-run browser decision remains pending across local backends untiluseSyncTelemetryConsentpersists it to Cloud; a stale/default backend value must not overwrite that newer choice during login or navigation. Once Cloud confirms the choice, backenduser_consents_to_analyticschanges are authoritative and mirrored to the client. No other hook or component should callopt_in_capturing/opt_out_capturingdirectly.subscribeTelemetryConsentis the sole React-facing consent store. Hooks that render consent state must useuseSyncExternalStore; do not mirror consent in component state or gate events outsidetelemetry.ts.canvas_installfires once, pre-consent, with the client's anonymous distinct ID. After consent and Cloud authentication, Canvas identifies PostHog with the stable Cloud user ID so PostHog joins the earlier anonymous activity to that person. Merely switching to a local backend clears Cloud event context without resetting the identified person; a resolved logout/account change, consent revocation, or privacy clear owns the reset. Local-only and never-authenticated traffic remains on the anonymous browser/install ID. Cloud account context (cloud_user_id,cloud_user_email,cloud_org_id) is attached as event properties only while a Cloud backend is active.telemetry.tsadds immutableclient_source,client_version,package_name, andpackage_versionproperties inbefore_send, so reset cannot remove attribution and event producers cannot override it. Repeated business milestones use deterministic PostHog$insert_idvalues instead of process-local caches.trackEventanduseTelemetryremain the public library telemetry API for npm consumers (theTelemetryConsentBannercomponent was removed; hosts needing a consent UI build their own onuseTelemetry). Non-React state machines use typed functions incloud-funnel-analytics.ts; they do not calltrackEventdirectly.- React app events use typed functions in
src/hooks/use-tracking.ts; components never callposthog.capture()raw. The hook attachescurrent_urlautomatically and captures through the telemetry service; Cloud account email is attached centrally ascloud_user_emailwhile Cloud context is active. It may read backend settings for event properties, but must never gate capture on a settings snapshot:useSyncTelemetryConsenthas already mirrored the authoritative decision to the telemetry service, and settings can be stale during a backend transition. - A business milestone has one canonical event capture. Do not conditionally switch between telemetry and app clients or emit duplicate events.
- OAuth device authorization and Cloud conversation-start requests include the coarse
X-OpenHands-Client: agent_canvasandX-OpenHands-Client-Versionheaders fromsrc/api/client-source.ts. Never put device codes, API keys, conversation content, raw hosts, or other user data in these headers. - Production ingress must retain those two headers as structured Datadog facets before source-specific operational queries will work.
- The consented OSS funnel uses typed
cloud_device_authorization_started,cloud_device_authorization_succeeded, andcloud_conversation_readyevents fromcloud-funnel-analytics.ts; React emits the canonicalbackend_addedevent throughuseTracking.
- Add a typed function to
useTrackinginsrc/hooks/use-tracking.ts - Add the function to the hook's
returnobject - Destructure and call it from the component:
const { trackFoo } = useTracking()
One stable event for every onboarding link/CTA click. New onboarding links must
reuse this contract (extend the unions in use-tracking.ts), never add one-off
events per destination.
Properties (all values controlled enums or booleans β never raw destination
URLs, query params, or link text; current_url is the standard app-page common
property, not a destination):
link_id(OnboardingLinkId):configure_llm|start_conversation|schedule_task|customize_agent|connect_mcp|join_slack|open_docsdestination_type(OnboardingLinkDestinationType):community|integration|documentation|settings|conversation|automationsurface(OnboardingLinkSurface):landing_checklist|onboarding_modal(reserved; no modal links are instrumented yet)checklist_item(optional): the owning checklist item'slink_id; set on everylanding_checklistemission, includingopen_docsclicksstep_id(optional): reserved for future onboarding-modal linksis_external(boolean): whether the destination leaves the app
Instrumented CTAs (sidebar "Getting started" checklist; the row link and its
preview action CTA intentionally share one link_id β same destination):
| Checklist item | Row + preview action | Preview docs link |
|---|---|---|
| Add LLM API key | configure_llm / settings / internal |
open_docs / documentation / external |
| Start your first chat | start_conversation / conversation / internal |
open_docs |
| Schedule a task | schedule_task / automation / internal |
open_docs |
| Customize your agent | customize_agent / settings / internal |
open_docs |
| Connect an MCP integration | connect_mcp / integration / internal |
open_docs |
| Join the OpenHands Slack | join_slack / community / external |
open_docs |
Excluded CTAs (per the one-canonical-capture rule above):
- Onboarding-modal wizard controls (back/next/skip/close, agent cards) β
covered by
onboarding_step_viewed/onboarding_completed/onboarding_skipped - Modal backend-connect CTAs and the backend form's docs links β
backend_addedwithsource: "onboarding" - LLM settings help links inside the embedded settings screen (shared with
non-onboarding surfaces) β setup outcome captured by
settings_saved - Recommended-automation cards β
prebuilt_automation_enabled - Checklist expand/collapse toggle and the settings visibility switch β UI state, not destination links
Known limitation: middle-click (auxclick) opens are not captured; tracking
uses React onClick only and never prevents default navigation.
VITE_POSTHOG_API_KEY is the sole build-time PostHog key. Unconfigured source builds use staging; official release workflows set production explicitly. Precompiled consumers use runtime configuration instead.
- When the agent-canvas dev launchers (
npm run dev/dev:static/ the publishedagent-canvasbinary) start a stack with ingress/static-server, the backend-facing server appends runtime service metadata to/server_infoas the optionalruntime_servicesfield. The frontend reads that backend-provided value when creating conversations and forwards it asAgentContext.system_message_suffixonPOST /api/conversations, so conversations land with a<RUNTIME_SERVICES>block appended to the system prompt. - The block lists URLs from the agent's point of view:
- The Agent Server is always reachable as
http://localhost:<port>from inside the sandbox β but that is you, not the automation backend. - Host-side services (ingress, Vite, automation) are reachable as
http://localhost:<port>.
- The Agent Server is always reachable as
- Agents should treat the
<RUNTIME_SERVICES>block as authoritative: don't hardcodelocalhost:8000for "the automation server", and don't probe random ports trying to discover services. If the block says automation is not running, skip/api/automationcalls; otherwise use the listedurl_from_agent+api_prefix(default/api/automation) and theX-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEYheader. - The launcher β backend β frontend β suffix plumbing is:
scripts/runtime-services-info.mjs::buildRuntimeServicesInfo()β dependency-free module that constructs the info object; also runs as a CLI for the Docker entrypoint. Re-exported byscripts/dev-safe.mjsfor backward compat.scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()β wraps it with automation details.dev-with-automation,dev-static, and the published binary pass the JSON toscripts/ingress.mjsorscripts/static-server.mjsvia--runtime-services-info.scripts/ingress.mjsandscripts/static-server.mjsproxy the real agent-server/server_inforesponse and appendruntime_serviceswhen configured. This keeps version/tool compatibility fields authoritative from the SDK while letting the Agent Canvas stack advertise automation/frontend/ingress topology.src/api/agent-server-adapter.ts::fetchBackendRuntimeServicesInfo()readsruntime_servicesfrom cached or freshly fetched/server_info;buildRuntimeServicesSystemSuffix()renders the<RUNTIME_SERVICES>markdown block;buildAgentContext()attaches it toagent_context.system_message_suffixwhen present.- E2E coverage: the mock-LLM automation test (
tests/e2e/mock-llm/automations/mock-llm-automation.spec.ts) verifies the<RUNTIME_SERVICES>block reaches the LLM viagetMockLLMRequests()and checks for Agent Server, Automation backend, and/api/automationentries.
The runtime_services value is a JSON object of:
{
"mode": "dev:automation",
"services": {
"agent_server": {
"description": "The OpenHands Agent Server this agent is running inside. ...",
"url_from_agent": "http://localhost:18000"
},
"ingress": {
"description": "Unified entry point. Routes /api/automation/* ...",
"url_from_agent": "http://localhost:8000"
},
"frontend": {
"kind": "vite",
"description": "Vite dev server hosting the agent-canvas frontend.",
"url_from_agent": "http://localhost:3001"
},
"automation": {
"description": "OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'.",
"url_from_agent": "http://localhost:18001",
"api_prefix": "/api/automation",
"docs_url": "http://localhost:18001/api/automation/docs",
"openapi_url": "http://localhost:18001/api/automation/openapi.json",
"auth_env_var": "OPENHANDS_AUTOMATION_API_KEY"
}
}
}All keys under services are optional and omitted when the corresponding service isn't running. frontend.kind is "vite" for dev launchers running the Vite dev server and "static" for stacks serving a pre-built build/ directory (dev:static, the published agent-canvas binary).
<RUNTIME_SERVICES>
You are running inside an agent-canvas dev stack started in 'dev:automation' mode.
The following services are reachable from your sandbox. URLs are written
from your point of view (i.e., as you should curl/fetch them).
* Agent Server (you): http://localhost:18000
The OpenHands Agent Server this agent is running inside. Tool calls (terminal, file_editor, browser, etc.) execute here.
* Ingress: http://localhost:8000
Unified entry point. Routes /api/automation/* to the automation backend, /api/* and /sockets to the agent-server, and /* to the frontend.
* Frontend: http://localhost:3001
Vite dev server hosting the agent-canvas frontend.
* Automation backend: http://localhost:18001
OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'.
Docs: http://localhost:18001/api/automation/docs
OpenAPI: http://localhost:18001/api/automation/openapi.json
Auth: header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'
Trust this block over guessing: do not assume any other URLs are running.
In particular, http://localhost:18000 inside your sandbox is the Agent Server
you are running inside of β NOT the automation backend.
</RUNTIME_SERVICES>
- The live QA path is intentionally separate from ordinary mocked Playwright coverage. If ordinary browser tests are added, keep them outside
tests/e2e/live/soplaywright.config.tscan run them while ignoring**/live/**; live LLM-backed tests must never run as part ofnpm run test:e2e. - Live tests live under
tests/e2e/live/and are run only throughnpm run test:e2e:live, which usesplaywright.live.config.ts. Keep the spec names descriptive; the primary conversation smoke test istests/e2e/live/real-agent-server-conversation.spec.ts. npm run test:e2e:liveloads.envthrough Node's--env-file-if-existsflag and invokestests/e2e/live/scripts/run-live-e2e.mjs. The runner validates the required local environment, explains missing credentials/prerequisites, and then runsplaywright test --config=playwright.live.config.ts. Usenpm run test:e2e:live -- --checkto validate local setup without running the test, and pass Playwright flags after--(for examplenpm run test:e2e:live -- --headed).- Local live E2E requires one LLM credential:
LIVE_E2E_LLM_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY, orLLM_API_KEY. Optional overrides areLIVE_E2E_LLM_BASE_URL,LIVE_E2E_LLM_MODEL,LIVE_E2E_SESSION_API_KEY,LIVE_E2E_BACKEND_URL, andLIVE_E2E_FRONTEND_PORT. The local runner prints which variables are missing without printing secret values. - Live-test-only helpers belong under
tests/e2e/live/utils/. The current helper module istests/e2e/live/utils/agent-server-conversation.ts; do not put live-only helpers in the sharedtests/e2e/support/directory. playwright.live.config.tsstarts the real local Agent Server/UI stack vianpm run dev:minimal, not MSW mocks. It usesLIVE_E2E_SESSION_API_KEYwhen set, otherwise generates a per-run random session key and passes it throughSESSION_API_KEY,OH_SESSION_API_KEYS_0, andVITE_SESSION_API_KEY; specs that need direct backend requests must injectX-Session-API-Keyonly for the configured backend origin throughrouteBackendSessionApiKey(page), never through global PlaywrightextraHTTPHeaders. Live tests default to frontend port3101and Agent Serverhttp://127.0.0.1:18100so they do not accidentally reuse a normal local dev stack.tests/e2e/live/utils/agent-server-conversation.tsconfigures the running Agent Server before each live conversation by PATCHing${LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100"}/api/settingswith LLM settings and low-risk conversation settings. LLM credentials are read fromLIVE_E2E_LLM_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY, orLLM_API_KEY; CI defaults useLIVE_E2E_LLM_BASE_URL(defaulthttps://llm-proxy.app.all-hands.dev) andLIVE_E2E_LLM_MODEL(defaultopenhands/claude-haiku-4-5-20251001).- The live conversation test should stay cheap and as deterministic as possible while still exercising one real tool call: it asks the model to run the exact
EXPECTED_BASH_COMMAND, waits for the bash output token to appear outside the user's message in the UI, confirms a successfulExecuteBashObservation/TerminalObservationthrough the real Agent Server events API, and then waits for the finalEXPECTED_REPLY_TOKEN. This exercises the real UI, Agent Server settings API, conversation creation, websocket/event path, terminal tool execution, and LLM response path. Because LLM behavior is not perfectly deterministic even at temperature 0, CI keeps one retry for live E2E; future live tests should document any expected variance and avoid prompts that require unnecessary formatting obedience. - Live E2E must not pollute analytics.
playwright.live.config.tsstarts the app withVITE_DO_NOT_TRACK=1; the live helper seeds local storage with telemetry/analytics opt-out values before app code runs; and each live spec should installguardAgainstPostHogRequests(page)before navigation so any attempted request to*.posthog.comorz.openhands.devis blocked locally and fails the test. - Live Playwright videos are intentionally recorded for PR QA debugging when
LIVE_E2E_RECORD_VIDEO=onis set by CI; local default video mode isretain-on-failure. Do not add live tests that render API keys, tokens, secret values, or credential-bearing error messages in the browser. Screenshots should target a safe app/chat region such asdata-testid="chat-interface"instead ofpage.screenshot({ fullPage: true }), and should applygetLiveArtifactMask(page)for text/field redaction; if a future live test must exercise sensitive UI, change that test/media path to redact the sensitive output or retain video only on failure. .github/workflows/ci.ymlruns live E2E only at PR level: either manually viaworkflow_dispatchwith a requiredpr_number, or from a same-repository PR carrying thelive-e2elabel. The live job must skip fork PRs before checking out PR code so LLM credentials and artifact-push tokens are never exposed to untrusted code. Do not broaden it to run on every PR push unless cost and credential policy are explicitly revisited.- Keep live E2E secrets out of job-level
env. The workflow should check whether credentials exist before checkout, but inject the LLM key only into the trusted step that actually runs the live test. - The live job uploads the Playwright HTML report plus screenshot/video output as a GitHub Actions artifact, and also extracts the primary screenshot/video attachments. It converts the WebM recording to a GIF preview with
ffmpegso GitHub PR comments can inline the preview. Keep Playwright trace capture disabled for live tests because the setup flow sends LLM credentials to the Agent Server settings API, and traces can record request bodies. Failure messages around live Agent Server settings must not print response bodies from credential-bearing requests. - Inline PR-comment media is stored as PR-only files under
.pr/live-e2e/<github_run_id>/on the PR branch, not on a long-lived orphan media branch. The comment usesraw.githubusercontent.com/<repo>/<artifact_commit>/.pr/live-e2e/...URLs for the GIF and PNG so GitHub can render them inline. The WebM is linked as the full recording because GitHub comments do not reliably inline WebM. .github/workflows/pr-artifacts.ymlowns cleanup for.pr/live-e2e/: it comments when.pr/artifacts exist and removes them after PR approval for same-repo PRs (fork PRs require manual cleanup).- The live reporting scripts live beside the live tests under
tests/e2e/live/scripts/:run-live-e2e.mjs,extract-live-e2e-media.mjs,render-live-e2e-report.mjs, andupsert-pr-comment.mjs. Keep report/comment/local-runner logic there rather than in top-levelscripts/, because these scripts are part of the live E2E framework. - When changing any part of this framework β live workflow triggers, artifact publishing,
.prcleanup, live Playwright config, live test file layout, helper locations, local runner behavior, or report/comment scripts β update thisAGENTS.mdsection in the same PR so future agents have the current operating model.
- Mock-LLM tests live under
tests/e2e/mock-llm/and exercise the complete stack β from the browser through the real agent-server to a scripted mock LLM server β without any real LLM credentials. Run locally withnpm run test:e2e:mock-llm. - Production-fidelity launch: The Playwright config (
playwright.mock-llm.config.ts) starts the fullagent-canvasstack viabin/agent-canvas.mjsβ the same binary thatnpx @openhands/agent-canvasexecutes when users install the npm package. This means mock-LLM tests exercise the actual production path: pre-built static frontend + static-server.mjs + agent-server via uvx + automation backend via uvx + ingress proxy, all behind a single port. - A pre-built
build/directory is required. The Playwright webServer command runsnpm run build:appwhenbuild/index.htmlis absent, but CI should run the build step explicitly for caching (npm run build:appin.github/workflows/mock-llm-e2e.yml). - Single ingress URL: Tests use one URL for both the browser (
baseURL) and backend API assertions (BACKEND_URL). The ingress proxy routes/api/*to the agent-server,/api/automation/*to the automation backend, and/*to the static frontend. Default ingress port for tests is18300(override viaMOCK_LLM_INGRESS_PORTenv var). - State isolation:
OH_CANVAS_SAFE_STATE_DIR=.tmp/mock-llm-stateisolates test state from the user's real~/.openhands/agent-canvas/directory. BothSTATE_DIR(.tmp/mock-llm-state) and the automation DB dir (.tmp/automation/) are cleaned before each test run β the automation DB now lives outside STATE_DIR atdirname(STATE_DIR)/automation/automations.db, mirroring Docker's~/.openhands/automation/automations.db. - Session API key: A random key is generated per test run and passed to the stack via
SESSION_API_KEY/OH_SESSION_API_KEYS_0/VITE_SESSION_API_KEY. The static server injects it intoindex.htmlat serve time so the frontend authenticates automatically. - Mock LLM server (
tests/e2e/mock-llm/scripts/mock-llm-server.py): Python HTTP server using openhands-sdk'sTestLLMto return scripted tool-call + text trajectories. Supports admin API endpoints for dynamic trajectory management:POST /admin/resetβ reset to the default trajectory (terminal printf + text reply); also clears the stored completion-request historyPOST /admin/trajectory/registerβ register a named trajectory (JSON body:{name, turns}where each turn is{tool_call: {name, arguments}}or{text: "..."})POST /admin/trajectory/activateβ activate a previously registered trajectoryGET /admin/requestsβ return the list of all/v1/chat/completionsrequest bodies captured since the last reset (used by the image-upload test to verify the image was forwarded to the LLM)
- Real automation backend: The automation test uses the production automation backend (started by
bin/agent-canvas.mjs), NOT a mock server. Terminalcurlcommands from the agent hit the automation API through the ingress proxy at the test'sBACKEND_URL(defaulthttp://localhost:18300). Auth uses theX-Session-API-Keyheader matching the stack's session key. - Test helpers (
tests/e2e/mock-llm/utils/mock-llm-helpers.ts): ExportsregisterTrajectory(),activateTrajectory(),resetMockLLM(),ensureMockLLMProfile(),getMockLLMRequests()(fetches captured completion bodies fromGET /admin/requests),IMAGE_REPLY_TOKEN+MINIMAL_PNG_BASE64(constants for the image-upload spec), ACP helpers (configureAcpAgent(),verifyAcpAgentSettings(),resetToOpenHandsAgent(),ACP_REPLY_TOKEN,MOCK_ACP_SERVER_PATH), and more. - Padding response for internal LLM call: The agent-server makes an internal LLM call (condenser/skill-analysis) before the agent's main loop starts when skills are activated. This consumes one trajectory response. Automation tests prepend a throwaway
{ text: "" }response as padding. The conversation test does NOT need this because its user message doesn't trigger skill activation. - Mock ACP server (
tests/e2e/mock-llm/scripts/mock-acp-server.py): A minimal stdio-based ACP agent that speaks JSON-RPC using theacpPython library (installed as a dependency ofopenhands-sdk). Handlesinitialize,session/new, andsession/prompt; sends a scriptedsession/updatenotification withACP_REPLY_TOKENin a text content block, then returnsstop_reason: "end_turn". The agent-server spawns it as a subprocess viaacp_command. Accepts--reply-token TOKENto customize the reply token. - Test directory layout: Specs are organized into feature subdirectories that mirror the source code structure, enabling selective test execution based on which source files changed:
settings/β LLM profile management, ACP agent config, model switching (mock-llm-acp-agent.spec.ts,mock-llm-profile-management.spec.ts,mock-llm-model-switch.spec.ts)conversations/β Core conversation flow, image upload (mock-llm-conversation.spec.ts,mock-llm-image-upload.spec.ts)files/β Files tab, Browser tab, and git control bar coverage (mock-llm-files-and-git.spec.ts)automations/β Automation lifecycle, preset cards (mock-llm-automation.spec.ts,mock-llm-preset-automation.spec.ts)onboarding/β First-run onboarding flow (mock-llm-onboarding-happy-path.spec.ts,mock-llm-onboarding-regressions.spec.ts)backends/β Auth modes, cross-connect, partial stack (mock-llm-auth-modes.spec.ts,mock-llm-cross-connect.spec.ts,mock-llm-partial-stack.spec.ts)home/β Workspace selection, folder browser (mock-llm-folder-workspace.spec.ts)mcp/β MCP marketplace/server management and credential verification (mock-llm-mcp-github.spec.ts,mock-llm-mcp-slack-credentials.spec.ts)skills/β Skill loading and activation (mock-llm-skills.spec.ts)regressions/β CSS isolation, event pagination, workspace persistence (mock-llm-ui-regressions.spec.ts). Always included in selective runs.
- Selective test execution:
tests/e2e/mock-llm/test-mapping.jsonmaps source paths to test subdirectories. Thetests/e2e/mock-llm/scripts/resolve-affected-tests.mjsscript reads the PR's changed files and outputs which test directories to run. Four resolution modes: (1) changed files match specificmappingsβ run only those subdirs +regressions; (2) changed mock-LLM spec files β run the containing feature subdirectory +regressions, so test-only PRs that add new specs still execute the new tests; (3) changed files matchrunAllSourcespatterns (cross-cutting files likesrc/api/agent-server-adapter.ts,package.json, shared test helpers, ortest-mapping.json) or are unmappedsrc/files β run full suite (__ALL__); (4) changed files are outside the E2E-relevant tree (docs, specs) β nothing; the workflow still starts so required checks do not remain pending, but the heavy test job is skipped by its internal change detector. The CI workflow's "Resolve affected test directories" step runs the script and passes the result to Playwright;workflow_dispatchalways runs the full suite. - Tests run serially (
workers: 1,mode: "serial"per describe block). Each spec is self-contained (configures its own LLM profile, resets mock LLM inafterEach). TheafterEachhook resets the mock LLM to its default trajectory so subsequent specs start fresh even when a preceding test fails. - CI workflow:
.github/workflows/mock-llm-e2e.ymlruns on PR commits (opened, synchronize, reopened) and on manual dispatch. It intentionally does not usepull_request.pathsfilters, because path-skipped workflows can leave required checks pending. Instead a lightweightdetect-pr-changesjob marks the heavymock-llm-e2ejob skipped-success for PRs that only touch docs, specs, or other non-stack files. Relevant paths aresrc/**,public/**,scripts/**,bin/**,config/**,tests/e2e/mock-llm/**,tests/e2e/support/**,package.json,package-lock.json, build/TS configs, styling configs, and the workflow file itself.workflow_dispatchis unaffected by path filters and always runs. The workflow builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results.render-mock-llm-report.mjskeeps only the heading/summary/commit links visible and wraps the full test table in a<details><summary>Details</summary>block.upsert-pr-comment.mjsdeletes any older comment for the same mock-LLM job (matched by hidden marker or the legacy bot-authored heading) before posting the latest report, so same-job comments do not accumulate. The PR comment marks tests from newly added spec files with a π badge; the "Detect newly added spec files" step queries the GitHub API for files withstatus == "added"matchingtests/e2e/mock-llm/**/*.spec.ts, and passes them as--new-filestorender-mock-llm-report.mjs. The summary line shows the count of new tests (e.g.π 2 new). The same detection and comment replacement flow are wired into the Docker E2E workflow (mock-llm-docker-e2e.yml). - The custom
DoneMarkerReporterwrites.mock-llm-markers/.tests-doneafter all tests complete (before webServer teardown) so the CI wrapper can detect completion and kill the lingering teardown process.
- The same test specs and helpers are reused to validate the Docker image via
playwright.mock-llm-docker.config.ts. Run locally withnpm run test:e2e:mock-llm:docker(requires Docker daemon and a built image). - Architecture: The Docker config replaces the npm path's
bin/agent-canvas.mjswebServer with adocker run --network hostcommand. The mock LLM server still runs on the host. On Linux (including CI),--network hostlets the container share the host's network stack so all127.0.0.1URLs work identically. On macOS/Windows Docker Desktop (bridge networking), setMOCK_LLM_AGENT_URL=http://host.docker.internal:<port>so the agent-server inside Docker can reach the host-side mock LLM server. - Dual-stack binding: Both
scripts/static-server.mjsandscripts/ingress.mjsdefault to::(dual-stack, accepting IPv4 and IPv6 connections). The Docker entrypoint passes--host ::explicitly. This meanslocalhostis safe in both the Docker and npm Playwright configs β whether it resolves to127.0.0.1(IPv4) or::1(IPv6), the server accepts the connection. The mock LLM server URL (MOCK_LLM_URL) still uses127.0.0.1because the Python mock server is a separate process whose bind behavior we don't control. - Entrypoint crash resilience:
docker/entrypoint.shuses awhile kill -0 "$STATIC_PID"; do sleep 10 & wait $!; doneloop instead ofwait -n "${PIDS[@]}"(any child). If the agent-server or automation backend exits mid-test, the static-server proxy stays up and returns 502s for backend routes β the container doesn't disappear withECONNREFUSED. The container exits only when the static-server (ingress) dies or on SIGTERM/SIGINT. Thesleep & wait $!pattern ensureswait(a bash builtin) is the foreground op, so trapped signals fire immediately.cleanup()includesexit 0so the script terminates after a signal-triggered trap return. - URL split:
mock-llm-helpers.tsexports two mock LLM URL constants:MOCK_LLM_BASE_URLβ alwayshttp://127.0.0.1:<port>, used by tests for the mock LLM admin API (register/activate/reset trajectories).MOCK_LLM_AGENT_URLβ defaults toMOCK_LLM_BASE_URL, overridable viaMOCK_LLM_AGENT_URLenv var. Used when configuring the LLM profile (base_urlfield) β this is the URL the agent-server uses for inference calls. The npm path and Docker-with---network hostpath use the same value; Docker on macOS needs the override.
- Docker image: Set
MOCK_LLM_DOCKER_IMAGEto the image tag (default:ghcr.io/openhands/agent-canvas:latest). The container is started with--rm --network hostand a unique--namefor cleanup. - State isolation: The Docker container uses its internal state directory (no host mount needed for tests). Each test run starts a fresh container.
- Skill test volume mounts: Tests that create files the agent-server needs to read (skill repos, user skills) require Docker volume mounts because the container has an isolated filesystem. The Docker config mounts
.tmp/mock-llm-skill-repos/β/tmp/mock-llm-skill-repos/for project skills and.tmp/mock-llm-user-skills/β/home/openhands/.openhands/skills/for user skills. Env varsMOCK_LLM_SKILL_REPOS_CONTAINER_DIRandMOCK_LLM_USER_SKILLS_HOST_DIRtellskill-test-helpers.tswhich paths to use for agent-server API registration vs. host-side file operations. - CI workflow:
.github/workflows/mock-llm-docker-e2e.ymlhas three triggers β all pull the already-built image from GHCR (no rebuild): (1)workflow_runfires automatically after theDockerworkflow completes on main (no path filter β always validates the published image); (2)pull_requestfires on PR commits (opened, synchronize, reopened) withoutpaths:filters, then its lightweightdetect-pr-changesjob skips the heavy Docker E2E job for docs-only/non-stack PRs while still producing a completed required-check context; (3)workflow_dispatchaccepts a customdocker_imageinput (always runs). The image tag is derived from the commit SHA (ghcr.io/openhands/agent-canvas:sha-<short>-amd64). Fork PRs are skipped (no GHCR push). When the PR description links anOpenHands/software-agent-sdkPR, the Docker E2E job installs the host-side mock LLM SDK package from that SDK PR branch and exposes the same branch asOH_AGENT_SERVER_GIT_REFfor partial-stack specs. Report artifacts go totest-results-mock-llm-docker/andplaywright-report-mock-llm-docker/.
When an E2E test fails in CI, use this workflow to diagnose the root cause efficiently:
The mock-LLM E2E workflow posts a structured comment on the PR with a test results table, pass/fail status, and collapsible failure details including the Playwright error message. Start here β the error message usually reveals whether the failure is a locator mismatch, a timeout, or a missing element.
Every failing test run uploads artifacts (mock-llm-e2e-results for npm, mock-llm-docker-e2e-results for Docker). Download them with:
gh run download <run_id> --repo OpenHands/agent-canvas --name mock-llm-e2e-results --dir /tmp/artifactsArtifacts contain:
test-results-mock-llm/β per-test directories withtest-failed-N.png(screenshot at failure) anderror-context.md(Playwright page snapshot as YAML accessibility tree + test source with the failing line marked)playwright-report-mock-llm/β full HTML report (npx playwright show-report /tmp/artifacts/playwright-report-mock-llm)
The error-context.md file contains a YAML accessibility tree of the entire page at the moment of failure. This is the single most useful artifact β it shows exactly what DOM elements exist, which tabs are selected, what text is in inputs, and whether a component rendered at all. Search for the element your test expects (e.g. llm-provider-input) to see if it's present or absent, and check surrounding context (tab selection state, form view mode, etc.) to understand why.
"element(s) not found" β The locator matched zero elements. The component either:
- Didn't render (conditional rendering path not taken β check the page snapshot for what DID render)
- Has a different
name/data-testidthan expected - Is behind a lazy-load boundary that hasn't resolved
Stale state from earlier serial tests β Mock-LLM tests run serially (workers: 1) against a real agent-server. Earlier tests (conversation, automation) persist settings on the server. If your test depends on "clean" state but a prior test configured llm_base_url, llm_model, etc., the form may render in a different view mode. Use Playwright page.route() to intercept and normalize the settings response. Example: routeOnboardingLlmCatalog in tests/e2e/support/onboarding-helpers.ts intercepts GET /api/settings to clear llm_base_url so the LLM form always opens in "Basic" view.
View mode mismatch (Basic vs Advanced) β LlmSettingsScreen switches between "Basic" (renders ModelSelector with provider/model dropdowns) and "Advanced" (renders plain text inputs). The view is determined by getInitialView() which checks currentSettings.llm_base_url β a non-default base URL triggers "Advanced" view. If your test expects input[name="llm-provider-input"] but sees text inputs instead, the settings have a stale base_url.
Playwright route interception vs real server β In mock-LLM tests, routes registered with page.route() intercept at the browser level before requests reach the real agent-server. However, page.route() must be set up BEFORE page.goto(). The showOnboarding helper handles this correctly (routes are registered before navigation). Non-GET methods should use route.fallback() to pass through to the real server.
npm run test:e2e:mock-llm # full suite
npm run test:e2e:mock-llm -- --headed # watch in browser
npm run test:e2e:mock-llm -- -g "test name" # run single test by name<TESTING_RULES> Create TDD tests for behavioral changes. Focus on user behavior and follow TDD best practices, including:
- AAA structure (Arrange, Act, Assert)
- Clear test focus
- Proper test data management
Before writing any test:
- Avoid duplicating test cases or logic
- Do not assert the same condition more than once
- Do not mock the hook. Instead, mock the underlying service that the hook depends on
- Prefer adding to or extending existing test files whenever possible. Create new test files only if no suitable ones exist
- Avoid brittle visual-presentation assertions. Functional CSS contracts such as style scoping and selector transformation may be tested directly
- Keep the number of test cases to the minimum necessary while still fully covering the intended changes and behaviors
Ensure each test is meaningful, concise, and covers a unique aspect of user interaction. </TESTING_RULES>
-
Published binary auth fix: When users install the npm package globally (
npm install -g @openhands/agent-canvas) and runagent-canvas, the pre-built static frontend has NOVITE_SESSION_API_KEYbaked in (npm publish runsnpm run buildwith no such env var). The runtime session key is generated when the CLI launches and reaches the frontend viascripts/static-server.mjs --session-api-key <key>, which injects a<head>script that does two things: (a) setswindow.__AGENT_CANVAS_SESSION_API_KEY__ = <key>β read bygetBakedSessionApiKey()insrc/api/agent-server-config.tsas a fallback when the env var is empty, symmetric with__AGENT_CANVAS_AUTH_REQUIRED__/isAuthRequired(); (b) writes the same key intolocalStorage['openhands-agent-server-config'].sessionApiKey, always overwriting when the value differs, so any code path that still reads the legacy storage key (e.g. e2e fixtures) sees the live key. The window-global path is the load-bearing one β without it,makeDefaultLocalBackend()returns null on a fresh install, the backend registry seeds empty, androot.tsxtraps the user behind the Manage Backends modal instead of onboarding.scripts/dev-with-automation.mjsandscripts/dev-static.mjsboth pass--session-api-key ${config.sessionApiKey}when starting the static server. -
Direct
dependenciesanddevDependenciesinpackage.jsonare exact-pinned (no caret ranges); reproducible installs should use the committedpackage-lock.jsonplusnpm ci, and targeted transitive fixes still belong inoverrides. -
Current
overridesinpackage.jsonand the advisories they address (keep this list in sync when adding/removing overrides):@vercel/static-config > ajv: 8.20.0β GHSA-2g4f-4pwh-qvx6 (ReDoS in ajv's$dataoption, affects 7.0.0-alpha.0β8.17.1, reaches us through@vercel/react-routerβ@vercel/static-configβajv@8.6.3). The override is intentionally scoped to@vercel/static-config: a top-levelajvoverride forces ajv 8.x everywhere and breaks ESLint's@eslint/eslintrc, which requires ajv ^6.x (incompatible API). Scoping lets ESLint keep its nestedajv@6.xwhile only the vulnerable consumer is bumped.dompurify: 3.4.7β GHSA-39q2-94rc-95cp and related XSS bypasses (affectsdompurify <= 3.3.3).
-
When bumping pinned versions, use npm to update
package.jsonandpackage-lock.jsontogether; do not hand-edit only one of them. -
npm testnow runsnpm run make-i18nfirst so clean environments generatesrc/i18n/declaration.tsbefore Vitest loads aliased imports. -
__tests__/vite-config.test.tsshould importvite.configdirectly under// @vitest-environment node; spawning plainnode -e 'import ./vite.config.ts'is not portable across Node patch releases in CI. -
vitest.setup.tsmust guard DOM-specific globals (HTMLCanvasElement,HTMLElement,window) because some suites run in the Node environment instead of jsdom. -
WebSocket hook regression note:
__tests__/hooks/use-websocket.test.ts'sonClosecallback assertion was flaky against the shared MSW websocket server in CI; keep that single test on a deterministic stubbedWebSocketclose path instead of relying on MSW close timing. -
Library i18n regression note:
__tests__/i18n/library-namespace.test.tsimports../../src/index, which can take >5s under the full Vitest suite aftervi.resetModules(). Keep an explicit per-test timeout (currently 15s) so the suite doesn't fail on slow workers. -
src/components/shared/buttons/styled-tooltip.tsxshould keep HeroUI tooltip animations disabled in Vitest (disableAnimationwhenimport.meta.env.MODE === "test"); otherwise full-suite runs can end with unhandledwindow is not definedrejections fromframer-motionafter jsdom teardown (seen viarecent-conversationtests in CI). -
@openhands/typescript-clientshould be pinned to a released npm version rather than an unreleased commit SHA; when agent-canvas needs new client API, release the client first and then update the dependency. Released versions should include the typed clients, agent-server version compatibility helpers,WorkspacesClient,ConversationClient.switchLLM, and subpath exports forevents/remote-events-listandworkspace/remote-workspaceneeded by the agent-canvas agent-server integration.RemoteWorkspace.gitChanges/gitDiffaccept an optional{ ref }option; agent-canvas passes'HEAD'so the changes panel reflects working-tree + index versus the latest commit (i.e. staged + unstaged) instead of a diff against the upstream/default branch. -
If a GitHub-hosted git dependency is introduced, npm may normalize its lockfile URL to SSH.
scripts/vercel-install.sh(wired throughvercel.json) rewrites GitHub SSH URLs to HTTPS beforenpm ci; keep that generic protection in sync with any future git dependencies.
Two strict conventions govern every REST call in the frontend. Violations break CI
via src/api/no-direct-agent-server-calls.test.ts.
All calls that target the local agent-server (/api/*, /server_info, /sockets)
must go through typed client classes from @openhands/typescript-client, never
raw axios, fetch, or the legacy shared openHands axios instance.
Available clients and their subpath imports:
ConversationClient--@openhands/typescript-client/clientsFileClient--@openhands/typescript-client/clientsVSCodeClient--@openhands/typescript-client/clientsServerClient--@openhands/typescript-client/clientsRemoteWorkspace--@openhands/typescript-client/workspace/remote-workspaceRemoteEventsList--@openhands/typescript-client/events/remote-events-list
Client options are always assembled via helpers in src/api/agent-server-client-options.ts:
getAgentServerClientOptions(overrides?)-- for SDK client constructorsgetAgentServerHttpClientOptions(overrides?)-- for typed wrappers such asRemoteEventsList; application code must not import or construct the low-levelHttpClient
These helpers read host, session API key, and working directory from the active backend registry and env config, so callers never hardcode URLs or auth tokens.
// CORRECT
const data = await new ConversationClient(
getAgentServerClientOptions(),
).getConversation(id);
const file = await new FileClient(
getAgentServerClientOptions(),
).downloadTextFile(path);
// WRONG -- raw axios/fetch calls fail the no-direct-agent-server-calls.test.ts guard
const data = await axios.get(`${host}/api/conversations/${id}`);
const data = await fetch(`/api/conversations/${id}`);Allowed exceptions (files that may use axios directly for infrastructure reasons):
src/api/automation-service/automation-service.api.tssrc/api/cloud/proxy.ts-- the proxy envelope POST itselfsrc/api/main-app-auth.ts-- the local main-app authentication endpoint
Any call from the browser to the cloud backend (app.all-hands.dev) or a cloud
runtime sandbox (*.prod-runtime.all-hands.dev) must go through callCloudProxy()
in src/api/cloud/proxy.ts. These origins do not permit CORS from localhost;
callCloudProxy POSTs the request envelope to /api/cloud-proxy on the local
agent-server, which forwards it server-side.
import { callCloudProxy } from "../cloud/proxy";
// CORRECT -- cloud endpoint
const result = await callCloudProxy<ResponseType>({
backend,
method: "GET",
path: `/api/v1/app-conversations/search?${params}`,
});
// CORRECT -- cloud runtime sandbox, auth via session key
const result = await callCloudProxy<ResponseType>({
backend,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/git/changes?path=${path}`,
authMode: "session-api-key",
sessionApiKey,
});
// WRONG -- direct fetch/axios to a cloud host is blocked by CORS in the browser
const result = await axios.get(`${backend.host}/api/v1/app-conversations`);callCloudProxy key options:
backend-- the cloudBackendobject (provides host and bearer token)hostOverride-- override for runtime-sandbox calls; replacesbackend.hostauthMode--"bearer"(default, cloud) |"session-api-key"(runtime sandbox) |"none"sessionApiKey-- required whenauthMode === "session-api-key"
Standard cloud/local branch pattern used throughout the service layer:
if (getActiveBackend().backend.kind === "cloud") {
return callCloudProxy({ backend: active, ... });
}
return new ConversationClient(getAgentServerClientOptions()).someMethod(...);Avoid inline string literals when they represent reusable user-facing copy or shared program identifiers. The i18next/no-literal-string rule is set to "error" for configured JSX text and attributes, and targeted no-restricted-syntax rules enforce shared translation and query-key patterns. Do not claim broader lint enforcement than eslint.config.js provides.
Every visible string (button labels, headings, validation messages, aria-label, title, alt, toast copy, placeholders) must be routed through react-i18next's t() keyed by an I18nKey enum member. Keys are declared once in src/i18n/translation.json with values for all 15 supported languages (see src/i18n/index.ts::AvailableLanguages), and npm run make-i18n regenerates src/i18n/declaration.ts + public/locales/<lang>/openhands.json. Run npm run check-translation-completeness when translations change; it is also part of the staged-file checks.
// CORRECT
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
const { t } = useTranslation("openhands");
return (
<button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>
{t(I18nKey.CHAT$DISMISS)}
</button>
);
// WRONG -- ships English to every locale; flagged by i18next/no-literal-string
return <button aria-label="Dismiss">Dismiss</button>;Key naming follows the existing CATEGORY$IDENTIFIER convention (see src/i18n/translation.json β common prefixes: CHAT_INTERFACE$, SETTINGS$, COMMON$, BUTTON$, HOME$, MICROAGENT$, etc.). Reuse an existing prefix; only introduce a new one when no sensible bucket exists.
The configured jsx-attributes.include list catches literal values for common user-facing attributes such as aria-label, placeholder, title, and alt. Continue to route user-facing strings through t() even when an uncommon prop is outside that list.
For strings the user never sees but the program reads (storage keys, event names, query keys, route paths, env-var names, header names, hardcoded paths, feature-flag identifiers), declare a single named constant in the closest module that owns the concept and import it everywhere else. Co-locate related constants in a tiny dedicated file (*-keys.ts, *-constants.ts) when more than two callers need them.
// CORRECT
const ONBOARDING_COMPLETED_KEY = "openhands-onboarded";
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
// CORRECT -- query keys go through SETTINGS_QUERY_KEYS / SECRETS_QUERY_KEYS / β¦
// in src/hooks/query/query-keys.ts (enforced by no-restricted-syntax)
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
// WRONG -- duplicated literal across files, no compile-time link, silent typo risk
localStorage.setItem("openhands-onboarded", "true");
queryClient.invalidateQueries({ queryKey: ["settings"] });Already-named constants in this repo include DEFAULT_WORKING_DIR (src/api/agent-server-config.ts), OPENHANDS_I18N_NAMESPACE (src/i18n/index.ts), BUNDLED_BACKEND_ID (backend registry), and the *_QUERY_KEYS helpers in src/hooks/query/query-keys.ts. Reuse these instead of re-inlining the literal.
When a string is part of a discriminated union or enum-like set (event kinds, backend kinds, tab IDs, agent statuses, observation result statuses), the type itself should constrain the literal. Pass values typed against that union, not raw string, so callers get autocomplete and the compiler catches typos.
// CORRECT
type BackendKind = "local" | "cloud";
if (backend.kind === "cloud") { β¦ }
// WRONG -- `backend.kind` typed as `string`; "clould" compiles fine
if (backend.kind === "clould") { β¦ }- Test fixtures (
__tests__/,tests/e2e/) may use inline literals for setup data β tests are the boundary where strings stop being magic. - Non-localizable display glyphs (keyboard shortcuts like
ββ©, currency symbols, etc.) may stay inline behind aneslint-disable-next-line i18next/no-literal-stringcomment. Keep the disable on the single offending line; never widen it to a file-level disable for a single glyph. - Generated files (
src/i18n/declaration.ts,public/locales/<lang>/openhands.json) are produced bynpm run make-i18n; do not hand-edit, do not lint-target.
When adding code that needs a new string, decide up front which rule it falls under: if a user reads it β Rule 1; if the program reads it β Rule 2; if it tags a union β Rule 3. Do not commit code that fails any of these rules just because the linter happens not to catch it.
-
Use
@openhands/typescript-clientclasses directly for agent-server-backed REST/workspace/event/VS Code calls. Centralize host/session API key/working-directory option assembly throughsrc/api/agent-server-client-options.ts; the backend fallback policy itself lives insrc/api/backend-registry/active-store.ts. -
Local verification/build gotchas:
npm run typecheckassumes generated translation types exist; runnpm run make-i18nfirst ifsrc/i18n/declaration.tsis missing.
-
Original OpenHands hosted routes were removed, while current Cloud behavior is implemented explicitly through the backend registry and
src/api/cloud/. Usesrc/routes.tsas the source of truth and do not restore old project-management, invitation, account, or git-settings routes from upstream without restoring their full API and i18n dependencies. -
npm run dev:mockneeds MSW handlers for the direct agent-server routes used by the adapted frontend, not the original OpenHands mock paths. Key routes that must stay covered are:- bootstrap/model loading:
/server_info,/api/llm/models/verified,/api/llm/providers - settings schemas:
/api/settings/agent-schema,/api/settings/conversation-schema - settings CRUD:
GET /api/settings,PATCH /api/settings - secrets CRUD:
GET /api/settings/secrets(list),GET /api/settings/secrets/:name(value),PUT /api/settings/secrets(upsert),DELETE /api/settings/secrets/:name - conversation browsing/loading:
/api/conversations/search,/api/conversations?ids=...,/api/conversations/:id,/api/conversations/:id/events/* - runtime git panels:
/api/git/changes,/api/git/diff
- bootstrap/model loading:
-
Static mock verification needs a build created with
VITE_MOCK_API=true(usenpm run build:mock); the client must start MSW whenever that flag is enabled, even in production/static builds, otherwise routes like/settingsand the conversations pane fall through to the static server and crash on undefined.filter/.mapassumptions. -
Frontend compatibility is enforced by
assertAgentServerVersionIsSupported()insrc/api/agent-server-compatibility.ts, usingcompatibility.minimumAgentServerfromconfig/defaults.json.OptionService.getConfig()callsloadAgentServerInfo()to enforce that floor, detect unavailable/auth-failing servers, and cacheusable_toolsfor tool gating. -
Backend registry: there is no longer a separate "bundled" backend. On first read of the
openhands-backendslocalStorage key (raw === null),readStoredBackends()seeds the registry with one default local backend (makeDefaultLocalBackend(), idBUNDLED_BACKEND_ID = "default-local", host/api-key fromagent-server-config). After that the seed is just an ordinary registered backend β users can rename or remove it like any other.getEffectiveLocalBackend()returns the first registered local, falling back to a synthesized default if the registry has no locals (used by API clients that need a baselinelocaltarget). The "Manage backends" modal and the BackendSelector dropdown both read from the single registered list, so the seeded default appears in both without any special-casing. -
Shared
Dropdownopen behavior: when the menu opens, it clears the input/search text so callers can show the current selection viaplaceholderwhile still rendering the full option list. Generic dropdown tests should not expect the selected label to remain in the input after reopening unless the parent explicitly controls that display. -
useLoadOlderEventsneeds ref-basedisLoading/hasMoreguards in addition to React state becauseChatInterfacecan trigger pagination fromonScroll,onWheel, and the no-overflow effect in the same tick; closure-based state alone allows duplicate page requests. -
ChatInterfacecontinuity tests should assert that conversation messages render without the fullchat-messages-skeleton, not thatdata-testid="loading-spinner"is absent: the lazy older-events indicator reuses the sharedLoadingSpinnercomponent and legitimately renders that inner test id while history backfill is running. -
useConversationHistorynow mirrors the older-events pagination fallback when the first page is exactlyINITIAL_HISTORY_PAGE_SIZE: treatnext_page_idor a full page ashasMore, so older agent-server variants that omitnext_page_idstill allow one more backfill request. The hook anduseLoadOlderEventsalso defensively reject mocked/malformedpage.itemsresponses before reversing them. -
/server_infotool capability metadata fromsoftware-agent-sdkPR #3028 ended up shipping asusable_tools(notavailable_tools). Frontend browser-tool gating should key offusable_tools, and still default to allowing tools when the server does not advertise tool metadata. -
Useful regression tests for mock mode live in
__tests__/api/option-service.test.ts,__tests__/api/mock-conversation-handlers.test.ts, and__tests__/api/mock-settings-handlers.test.ts. -
Agent-server compatibility conventions:
- Authenticated schema and settings calls must send the configured
X-Session-API-Key. - Local provider/model discovery uses
/api/llm/providers,/api/llm/models, and/api/llm/models/verified; Cloud model/provider search goes through the Cloud service layer. GET /api/conversationsuses repeatedidsquery parameters (?ids=a&ids=b).- Runtime git panels prefer the conversation's reported
workspace.working_dir. - Conversation-start payloads use SDK-registered snake-case tool names such as
terminal,file_editor,task_tracker, andbrowser_tool_set. - The
/server_infobootstrap uses a 5-second timeout and surfaces unavailable, authentication, and unsupported-version states through the backend recovery UI.
- Authenticated schema and settings calls must send the configured
-
Git provider tokens are stored exclusively on the agent-server via
SecretsService(PUT /api/settings/secrets). They are NOT mirrored to localStorage; the frontend reads which providers are connected fromsettings.provider_tokens_set(populated byGET /api/settings). Older notes about anopenhands-agent-server-git-provider-tokenslocalStorage key are obsolete β no such key is read or written anywhere in the codebase. -
App-level user preferences (language, sound notifications, analytics consent, git identity, disabled skills) are persisted server-side under
PersistedSettings.misc_settings.app_preferencessince agent-server 1.27.misc_settingsis a generic container for frontend-owned settings the agent doesn't interpret;app_preferencesis currently its only nested category, but additional categories (e.g. a futureui_preferencesfor sidebar layout / view modes) drop in as additional siblings without churning the top-level wire shape.GET /api/settingsreturns the block undermisc_settings.app_preferences, andPATCH /api/settingsaccepts amisc_settings_diffthat is deep-merged into the persisted block (same semantics asagent_settings_diff/conversation_settings_diff). Partial diffs like{"misc_settings_diff": {"app_preferences": {"language": "fr"}}}update only the named nested field; siblingapp_preferencesfields are left untouched. Lists (disabled_skills) are replaced wholesale by the deep-merge.SettingsService.transformApiResponsehoists the nested fields onto the flatSettingsshape so the rest of the GUI keeps reading them as top-level keys (settings.language,settings.disabled_skills, β¦). Do NOT reintroduce a localStorage fallback for these fields βdisabled_skillsand the rest belong inmisc_settings_diff.app_preferences. The previous flatapp_preferences/app_preferences_diffAPI (introduced in SDK PR #3539, never shipped to users) was replaced by themisc_settingscontainer before either side reached a stable release; on-disk v2 settings files written by the flat shape are migrated automatically on first read by the agent-server. The legacy localStorage migration (src/api/settings-service/legacy-app-preferences-migration.ts) was removed in issue #1337 after a one-release drain period β it is no longer needed. -
Auth modes for
agent-canvas(dev and production):- Local mode (default, no
--publicflag): A session API key is auto-generated and persisted to~/.openhands/agent-canvas/session-api-key.txt. The key is baked into the Vite dev server viaVITE_SESSION_API_KEYor injected into static builds viastatic-server.mjs --session-api-key. Users never need to paste a key. - Public mode (
--publicflag): RequiresLOCAL_BACKEND_API_KEYenv var. The key is used as the agent-server session key (OH_SESSION_API_KEYS_0) but is NOT baked into the frontend (noVITE_SESSION_API_KEY, no--session-api-keyto static-server). The frontend detects a 401 from/server_infoviaisAgentServerAuthError()and showsApiKeyEntryScreen(src/components/features/backends/api-key-entry-screen.tsx). The screen reusesBackendFormwith the host pre-filled (read-only) and prompts for the API key. On submit, the key is persisted tolocalStorage['openhands-agent-server-config']and the page reloads. - Dev usage:
LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public - Production usage:
LOCAL_BACKEND_API_KEY=my-secret npx @openhands/agent-canvas --public - The
--publicflag is supported by bothscripts/dev-with-automation.mjs(parsed inparseArgs(), propagated viaconfig.isPublic) andbin/agent-canvas.mjs(passed asisPublictomain()). - The 401 detection lives in
src/api/agent-server-compatibility.ts(isAgentServerAuthError()), and the gate is insrc/root.tsx'sAppcomponent, between theAgentServerUnavailableErrorcheck and the<Outlet />render. - Key rotation resilience (non-public):
syncLauncherDefaultLocalBackend()insrc/api/backend-registry/storage.tsre-runs at module init: for any stored backend whose id is"default-local"and whose host matches (or is loopback-equivalent to) the launcher's default, itsapiKeyis overwritten with the currentmakeDefaultLocalBackend().apiKey(sourced fromVITE_SESSION_API_KEYor, in the published-binary path,window.__AGENT_CANVAS_SESSION_API_KEY__). E2E coverage:tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts(fresh-install, key-rotation, and public-mode scenarios).
- Local mode (default, no
-
Backend/footer actions that launch modals from inside a dropdown or popover should intercept
onMouseDownto keep the menu mounted, then perform the actual open ononClick. Current examples:Add backend/Manage backendsinsrc/components/features/backends/backend-selector.tsx, plus the mirrored workspace-footer buttons insrc/components/features/conversation-panel/new-conversation-button.tsx. -
BackendSelector's cloud-org switch paths should never rethrow from the dropdownonChangehandler: unexpected non-Axios failures need a generic error toast instead of an unhandled promise rejection, and the malformed(cloud backend, null org)self-heal path should fall back to the bundled backend if/switchfails. -
NewConversationButtonshould support keyboard dismissal (Escape) for its inline popover, while still keeping the popover open when its modal children (FolderBrowserModal,ManageWorkspacesModal) are active. -
README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real
openhands-agent-server(clone, install prerequisites, optional.env, runnpm run dev). -
Windows-specific command syntax (PowerShell) lives in
README.windows.md. When changing install / Docker sandbox instructions inREADME.md, updateREADME.windows.mdin the same PR to keep them in sync. -
scripts/dev-safe.mjsusesuvxfor temporary agent-server installation β no permanentuv tool installneeded. Environment variables (highest precedence first):OH_AGENT_SERVER_LOCAL_PATHβ absolute path to a localsoftware-agent-sdkcheckout. Runs the local checkout viauvxwith--with-editableforopenhands-sdk/openhands-tools/openhands-workspaceand--reinstallforopenhands-agent-server, so SDK edits are picked up on restart. Highest precedence.OH_AGENT_SERVER_GIT_REFβ git commit SHA or branch name (takes precedence over version)OH_AGENT_SERVER_VERSIONβ specific PyPI version (e.g., "1.42.1")OH_SECRET_KEYβ secret key for settings encryption; auto-generated and persisted to~/.openhands/agent-canvas/secret-key.txton first run (same file Docker uses), ensuring dev mode and Docker share the same key when both mount the same~/.openhandsdirectory. Override with the env var to pin a specific key.SESSION_API_KEY/OH_SESSION_API_KEYS_0/VITE_SESSION_API_KEYβ session API key for agent-server authentication; auto-generated usingcrypto.randomBytes(32)if not set, passed to both agent-server (OH_SESSION_API_KEYS_0) and frontend (VITE_SESSION_API_KEY)- Default: released PyPI version
1.42.1for agent-server SDK libraries
-
Security: launchers generate and persist a 64-character session API key at
~/.openhands/agent-canvas/session-api-key.txtunless overridden. The agent-server and automation backend share that session key.OH_SECRET_KEYprotects settings encryption and is persisted separately at~/.openhands/agent-canvas/secret-key.txt. -
scripts/dev-safe.mjsshould fail fast ifuvxcannot be spawned (for example missing PATH entries). -
npm run devruns the full local stack viauvx(agent-server + automation backend + Vite dev server + ingress proxy) with no Docker dependency.npm run dev:staticdoes the same but serves a production build of the frontend instead of the Vite dev server. -
scripts/dev-with-automation.mjsruns the full stack: agent-server, automation backend (both via uvx), frontend server, and ingress proxy. It defaults to Vite when run directly, supports--staticfor an existing build, and supports--dynamicso wrappers that default static can opt back into Vite. Uses a standalone ingress proxy (scripts/ingress.mjs) to route traffic:- Keep
SIGINT,SIGTERM, andSIGHUPwired through the coordinated shutdown handler. Services run in detached process groups on POSIX, so cleanup must usesignalProcessTree()rather than signaling only the direct child; regression coverage lives in__tests__/scripts/dev-with-automation.test.ts. /api/automation/*β automation backend (:18001)/api/*,/sockets, etc. β agent server (:18000)/*(default) β frontend server (:3001), either Vite or static depending on launcher mode- Environment variables:
PORT(ingress port, default fromconfig/defaults.json),OH_AUTOMATION_GIT_REF(git ref, overrides default version), andOH_AUTOMATION_VERSION(defaults toversions.automationinconfig/defaults.json) scripts/check-sdk-version-sync.mjschecks the releasedopenhands-automationpackage againstversions.agentServerinconfig/defaults.json; these must always match β if the automation package's SDK dependencies differ fromagentServer, the check fails.- Access points:
http://localhost:8000/(main UI),http://localhost:8000/api/automation/docs(API docs) - Security: the automation backend receives the same session key as the agent-server through
AUTOMATION_LOCAL_API_KEY; the frontend does not bake a separate automation API key.
- Keep
-
scripts/ingress.mjsis a standalone HTTP reverse proxy that can be used independently to route traffic to multiple backends based on URL path prefix. -
scripts/dev-safe.mjs(nownpm run dev:minimal) runs just agent-server + Vite without automation. -
Vite dev mode can black-screen on first load with
504 Outdated Optimize Depif core client-entry deps are not prebundled; keepreact,react/jsx-runtime,react-dom/client, andreact-router/dominoptimizeDeps.include. -
Vercel deployment note: React Router builds for this repo must keep
build/clientintact on actual Vercel builds and includepresets: [vercelPreset()]from@vercel/react-router/vite; flatteningbuild/clientduring a Vercel build produces deployments with empty outputs (routes: null, no static files) and a production 404. -
The repo should include a root
LICENSEfile to satisfy the incubator-program requirements. -
OpenHands repo bootstrap files live under
.openhands/:.openhands/setup.shinstallsuv(viacurl -LsSf https://astral.sh/uv/install.sh | sh) if not present, installs frontend dependencies withnpm ciwhen needed, creates.envfrom.env.sampleif missing, appendsVITE_WORKING_DIRfor this repo when unset, and generatessrc/i18n/declaration.tsvianpm run make-i18n.
-
The repo now includes
.agents/skills/custom-codereview-guide.md, adapted fromOpenHands/software-agent-sdk, to force PR reviews to always leave either an APPROVE or COMMENT review instead of silently finishing with no review object. -
HeroUI rollback / migration notes:
- The attempted HeroUI v3 upgrade changed global theme wiring and homepage design tokens enough that the repo currently prefers
@heroui/react@2.8.10until a broader visual validation pass is done. - Keep the v2 Tailwind integration active via
@plugin '../hero.ts'insrc/tailwind.cssand source HeroUI classes fromnode_modules/@heroui/theme/dist/**/*. - The settings UI currently relies on the v2
Autocomplete+AutocompleteItem/AutocompleteSectionAPIs insettings-dropdown-input.tsxandmodel-selector.tsx; a future v3 retry will need to replace those controls again.
- The attempted HeroUI v3 upgrade changed global theme wiring and homepage design tokens enough that the repo currently prefers
-
Library i18n is now namespace-scoped under
openhands:src/i18n/index.tsexportsOPENHANDS_I18N_NAMESPACE,translationResources, andwaitForI18n(),scripts/make-i18n-translations.cjsemitspublic/locales/<lang>/openhands.json, standalonesrc/entry.client.tsxexplicitly awaits i18n init, and host apps can register bundles via the@openhands/agent-canvas/i18nsubpath export. -
Route decoupling note:
src/components/should stay free of directreact-routerimports. Route state now flows throughsrc/context/navigation-context.tsx, the standalone app bridges router state withsrc/routes/react-router-navigation-provider.tsx, and link-like UI should usesrc/components/shared/navigation-link.tsx. -
Test helper note:
test-utils.tsxnow wraps renders with a defaultNavigationProvider(currentPath: "/",conversationId: "test-conversation-id"). Navigation-sensitive tests can override that viarenderWithProviders(..., { navigation: { ... } }). -
CSS isolation for embeddable/hosted use now relies on a scoped wrapper attribute: all bundled CSS is prefixed under
[data-agent-server-ui]viapostcss-prefix-selectorinvite.config.ts, with selector exceptions handled bytransformAgentServerUISelector()insrc/styles/agent-server-ui-style-scope.ts. That transform must remap global selectors like:root,html, andbodydirectly onto the scoped shell instead of emitting impossible descendants such as[data-agent-server-ui] :root. -
Public embedding entry points should use
AgentServerUIProviders(scoped root on by default) orAgentServerUIRootfor manual control. The standalone app already renders its own scoped root insrc/root.tsx, sosrc/entry.client.tsxmust passwithStyleRoot={false}to avoid nesting duplicate shells. KeepAgentServerUIRootand the scoping constants re-exported fromsrc/lib/index.tsso library consumers can customize the host wrapper without reaching into private paths. -
AgentServerUIRoot's themed inner wrapper must set a defaultcolor: var(--foreground)in addition to thedark/data-thememarkers; otherwise inherited text andcurrentColorSVG icons fall back to dark browser defaults after CSS scoping, causing dark-on-dark regressions on pages like the home screen. -
Theme/customization tokens for the embedded shell are exposed as
--oh-*CSS variables. Override them throughstyleOverrides,style, or host CSS targeting[data-agent-server-ui]; Tailwind theme tokens insrc/tailwind.cssshould continue to reference those variables with@theme inlineso host apps can restyle the UI without reworking component class names. -
Regression coverage for the CSS isolation work lives in
__tests__/agent-server-ui-providers.test.tsx,__tests__/agent-server-ui-style-scope.test.ts, and the browser-level CSS-isolation test intests/e2e/mock-llm/regressions/mock-llm-ui-regressions.spec.ts. -
Conversation history is loaded lazily, REST-first then WebSocket:
-
useConversationHistory(insrc/hooks/query/use-conversation-history.ts) fetches only the most recentINITIAL_HISTORY_PAGE_SIZE(default 50) events usingsort_order='TIMESTAMP_DESC', then reverses to chronological order. Older pages are paginated in viauseLoadOlderEventswhen the user scrolls near the top of the chat. -
EventService.searchEvents(conversationId, conversationUrl, sessionApiKey, options)returns the rawEventSearchPage({ items, next_page_id }); options supportlimit,pageId,sortOrder,timestampGte,timestampLt. Both the local and cloud-proxy code paths forward the new params. -
The main
ConversationWebSocketProviderwaits for the REST query to settle before opening its socket, then connects withresend_mode='since'andafter_timestamp=<latest preloaded event ts>(falling back to'all'when the REST result is empty or errored). The legacyresend_all=trueflag is removed for the main connection; the planning-agent sub-conversation still usesresend_alluntil it is migrated to the same REST-then-WS pattern. -
The event store gained a bulk
addEvents(events)action (used for the initial REST seed and for "scroll-up" pagination) that re-sorts by timestamp once at the end so older pages can be merged in cheaply. Per-event dedup still works via the existingeventIdsset. -
ChatInterfacewiresuseLoadOlderEventsinto its scroll handler (threshold 80px from the top), shows adata-testid="loading-older-events"spinner during pagination, and preserves the visible scroll offset by storing the previousscrollHeightand adding the height delta after the older page renders. -
useLoadOlderEventsintentionally distinguishes between "no anchor yet" (empty store before the initial REST seed, soloadOlder()should no-op) and "malformed oldest event" (store has an oldest event with no timestamp, so the hook throws, flipshasMorefalse, andChatInterfacesurfaces the failure via the shared error banner instead of failing silently).
-
-
Action grouping in the chat stream:
src/components/conversation-events/chat/group-events.tsfolds runs of consecutive groupable events (regularActionEvent/ObservationEventcards, but notFinishAction,ThinkAction,PlanningFileEditorObservation,TaskTrackerObservation, hooks, errors, or message events) into singleRenderedItemgroups. The threshold lives inEVENT_GROUP_MIN_SIZE(currently 2, so even pairs of back-to-back actions get folded).EventGroup(src/components/conversation-events/chat/event-message-components/event-group.tsx) is the collapsible header that wraps each run. Default state is collapsed; the header showsEVENT_GROUP$ACTIONS_COMPLETED(with a success check) when the group is done, orEVENT_GROUP$ACTIONS_PROGRESSplus the currently-running action's title (fromgetEventContent) while a memberActionEventhas not yet been replaced by its observation in the UI events array. Expanding renders the originalEventMessages verbatim so each card still expands the way it did before.- Agent thoughts attached to an
ActionEvent(event.thought) are hoisted out of groups:groupEventsemits a thirdRenderedItemkind"thought"whenever a groupable event carries (or, for an observation, originates from) a non-empty thought, flushing the current run and starting a new one.messages.tsxrenders that item viaThoughtEventMessageand passessuppressThoughttoEventMessageso the inline thought isn't duplicated inside the group's expanded content.ThinkActionis excluded from this hoisting because the thought IS its action body and is rendered through its own codepath. groupEventsnow de-duplicates hoisted thoughts by action ID so mixed UI arrays that temporarily contain both an action and its replacement observation do not emit the same thought twice;minSizeis treated as a validated internal invariant (>= 1).EventGroupshould returnnullfor an emptyeventsarray and wire the toggle button to the expanded body witharia-controls/role="region"/aria-labelledby.src/components/conversation-events/chat/messages.tsxis the only consumer; the grouping is transparent to upstream code. Coverage lives in__tests__/components/conversation-events/chat/group-events.test.ts(pure logic, including thought hoisting) and__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx(rendering/interaction).
-
Home page workspace UX (local backend):
FolderBrowserModal's "Use this folder" button adds only the currently navigated directory as a single workspace (named by its basename). It no longer iteratessubdirsand adds each child as a separate workspace.- The
WorkspaceDropdownsticky footer now exposes both "+ Add Workspace" (opens the folder browser) and "Manage Workspaces" (opensManageWorkspacesModal, which lets users remove individual workspaces viauseWorkspacesStore.removeWorkspace). The Manage button is hidden when there are no workspaces yet. - The sidebar "+ New Conversation" trigger (
NewConversationButtoninsrc/components/features/conversation-panel/) opens a popover that is a flat list, not the home-screen combobox: a leading "No workspace" entry plus one entry per stored workspace, each clicking through touseCreateConversationimmediately (no separate Launch button). It mirrors the dropdown footer actions/pattern (+ Add Workspace,Manage Workspaces) locally rather than embeddingWorkspaceDropdownitself. useResolvedWorkspaces()now returnsisLoading/isErrorfor parent-directory scans;WorkspaceSelectionFormshould surface that state (status text and disabling the empty dropdown while parent results are still loading) instead of assuming the merged list is immediately ready.ManageWorkspacesModalshould require a confirmation step before removing either a saved workspace or a workspace parent; parent removals should mention the child-workspace impact, and tests should assert both the confirmation flow and that removing the selected workspace clears the launch selection.- In
useWorkspacesStore, keepclearWorkspaces()scoped to literal workspaces only; use explicit helpers likeclearWorkspaceParents()/clearAll()for broader resets so future callers do not accidentally wipe parent registrations.
-
Default LLM model β
DEFAULT_SETTINGS.llm_model("openhands/minimax-m2.7", defined insrc/services/settings.ts) is the canonical frontend default.buildConfiguredOpenHandsAgentSettingsinsrc/api/agent-server-adapter.tsalways sends this value explicitly when the resolvedllm.modelis absent, empty, or whitespace-only β the frontend never relies on the agent-server SDK's own default (gpt-5.5). If you change the default model, updateDEFAULT_SETTINGS.llm_modelinsrc/services/settings.tsand the checklist inspecs/llm-defaults.md. Spec:@spec LLD-001. -
Custom secrets are NOT auto-attached by the agent-server.
POST /api/conversationsonly persists what the client sends inrequest.secrets; the persisted secrets store (/api/settings/secrets) is never read at conversation start.buildStartConversationRequestWithEncryptedSettingsenumeratesSecretsService.getSecrets()and turns each entry into aLookupSecretwhoseurlpoints back at/api/settings/secrets/{name}and whoseheaderscarryX-Session-API-Keyfor auth. -
MCP page layout: MCP is a top-level nav entry at
/mcp(rendered bysrc/routes/mcp.tsx), shown right below "Skills" insrc/components/features/sidebar/sidebar.tsx.src/routes/mcp-settings.tsxre-exports the new page so the publishedMCPSettingslibrary symbol (insrc/components/settings/index.ts) keeps the same shape. The legacy/settings/mcpredirect was removed in issue #1337. Marketplace catalog data and MCP logo mappings live in the MCP-capable entries from@openhands/extensions/integrations; the Slack API catalog option should point athttps://github.com/zencoderai/slack-mcp-serverand use@zencoderai/slack-mcp-server. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw servername/commandfallback insrc/utils/mcp-marketplace-utils.ts+InstalledServerCard. Tavily is a regular stdio MCP entry (tavily-mcp+TAVILY_API_KEY), not a special built-in sentinel anymore. Components are colocated undersrc/components/features/mcp-page/and reuse the existingMCPServerFormfor the "Add custom server" / edit flow. -
MCP catalog runtime patching:
getMcpMarketplaceCatalog()insrc/utils/mcp-marketplace-utils.tspipes every catalog entry through patch functions before the UI sees it. Two patches exist:patchLinearEntry(rewrites the deprecated Linear SSE endpoint to streamable HTTP) andpatchGitHubEntry(rewrites thedocker runtransport to the nativegithub-mcp-server stdiobinary, only whengetDeploymentMode() === "docker"). ThegetDeploymentMode()helper is exported fromsrc/api/agent-server-adapter.tsand reads themodefield from the runtime services info. The Docker image pre-installs thegithub-mcp-serverGo binary at/usr/local/bin/github-mcp-servervia a dedicated Dockerfile download stage (github-mcp-download). This avoids a Docker-in-Docker requirement β GitHub is the only catalog entry that usesdockeras its stdio command; all others usenpxoruvx. When adding similar patches for other entries, follow the same pattern: guard onentry.id, check environment conditionally, spread immutably. -
Library packaging notes:
- Public npm entrypoints now come from
src/index.tsβsrc/lib/index.ts, with domain barrels undersrc/components/{conversation,terminal,browser,files,settings,sidebar}/index.ts. npm run buildremains the standalone app build (react-router build), whilenpm run build:librunsvite buildin library mode plustsc -p tsconfig.lib.jsonto emit.d.tsfiles intodist/.- The library build relies on
vite.config.tswithBUILD_LIB=true, preserved modules indist/, and packageexportsentries that map root/subpaths todist/**/*.jsplus matching declaration files. - Declaration emit needs
src/library-env.d.tsand the narrowedtsconfig.lib.json; broadsrc/**/*.tsxdeclaration builds pulled in route-only files and missed?react/window globals.
- Public npm entrypoints now come from
-
Bundle/dev-graph hygiene (Tier 1 cleanup landed):
src/i18n/translation.json(~1 MB) is imported only bysrc/i18n/resources.ts, whichsrc/i18n/index.tsre-exports astranslationResourcesfor the@openhands/agent-canvas/i18nsubpath. The re-export is aexport β¦ fromplus/* @__PURE__ */annotation, so rollup drops the JSON from the app build (prodcustom-toast-handlerschunk: 909 KB -> 74 KB;conversationchunk: 728 KB -> 392 KB). Do not move the JSON import back intosrc/i18n/index.tsβ that immediately re-bundles all translations into every chunk that importsi18n.- The environment-switch overlay is split: lightweight store/triggers live in
components/features/backends/environment-switch-store.ts; the React component lives inenvironment-switch-overlay.tsx(re-exports the store API for back-compat). Eagerly-mounted callers (e.g.backend-selector.tsx) MUST import trigger helpers from the store, not the overlay file. The overlay isReact.lazy'd fromroutes/root-layout.tsx. - Conditional UI is
React.lazy'd to keep eager graphs small. Current examples includeAlertBannerandCommandMenuinroot-layout.tsx,SettingsModalin the sidebar, and backend/onboarding modals inroot.tsx. Tests that assert on these mounted nodes may needawait screen.findByTestId(...)/waitFor(...)instead of synchronousgetByTestId(...). - The terminal tab (
components/features/terminal/terminal.tsx) isReact.lazy'd inconversation-tab-content.tsxalongside the other tabs, so xterm + addon-fit + xterm.css don't enter the conversation route's eager graph (they ship as a separateterminal-*.jschunk now). - Avoid importing app code through
#/components/conversation-events/chator itsevent-message-components/index.tsbarrel β they exist forlib/index.ts(npm subpath) consumers only. Internal callers use deep paths (./messages,./event-message-components/<name>,./event-content-helpers/should-render-event) so Vite dev doesn't fan out the barrel.
-
Backend dropdown connectivity indicator:
useBackendsHealth(src/hooks/query/use-backends-health.ts) polls each registered backend every 10s. Local backends validate the configured session key throughSettingsClientbefore callingServerClient.getServerInfo()and enforcing the compatibility floor. Cloud API-key backends usegetCurrentCloudApiKey(); cookie-auth Cloud backends usegetCloudOrganizations(). Verdicts are surfaced as a colored dot rendered throughDropdownOption.prefix; the trigger reads its prefix from the liveoptionsarray (not downshift's frozenselectedItem) so the indicator updates without remounting. The same dot is rendered in each row ofManageBackendsModal, which opts into a one-shot re-probe for previously disabled backends. Tests live in__tests__/hooks/query/use-backends-health.test.tsx, theconnection indicatorblock of__tests__/components/backends/backend-selector.test.tsx, and__tests__/components/backends/manage-backends-modal.test.tsx. -
Manage Backends modal:
src/components/features/backends/manage-backends-modal.tsxlets users edit (host/name/api-key/kind) and remove existing backends, plus add new ones inline via a "+ Add Backend" footer button that opens aBackendFormModal. Both the dropdown footer's "Add backend" and the manage modal's "+ Add Backend" reuseBackendFormModal(seebackend-form-modal.tsx), withmode="add"ormode="edit";AddBackendModalis now a thin compatibility wrapper forBackendFormModal mode="add". The modal is also auto-rendered (with a no-oponClose) bysrc/root.tsxwhen the active backend is unreachable, replacing the old full-screenMissingAgentServerNoticeonboarding screen. -
Conversation right-panel regression note:
ConversationTabsnow owns the moved refresh/build buttons, so__tests__/components/features/conversation/conversation-tabs.test.tsxshould cover that behavior directly. The drawer's open/closed state (isRightPanelShown/hasRightPanelToggled) is intentionally session-only: it always starts closed on app load (or on opening a fresh/existing conversation after a restart), but it survives in-app navigation because the ZustanduseConversationStorestays alive across React Router transitions. TheConversationStatelocalStorage blob (conversation-state-{id}) deliberately does not carry arightPanelShownfield βuseConversationLocalStorageStatedoes not expose asetRightPanelShownsetter,sanitizeStoredStatestrips the legacyrightPanelShownkey from older persisted blobs on read, andRightPanelToggle/useSelectConversationTabonly mutate the in-memory store. In tests, seed the Zustand store directly forselectedTab/isRightPanelShown/hasRightPanelToggled(the component sync effect currently restores onlyselectedTabfrom localStorage, so localStorage alone will not make a tab read as active or the drawer read as open). -
Changes tab /
FileDiffViewerdeleted-file note: the agent-server's/api/git/diffendpoint callspath.exists()first (seeopenhands-sdk/openhands/sdk/git/git_diff.pyβget_git_diff), so requesting a diff for aD(deleted) file returnsGitPathErrorβ HTTP 400 and trips the global QueryCache error toast.useUnifiedGitDiffdisables the query whentype === "D"andFileDiffViewerrenders a localized "file deleted" placeholder (DIFF_VIEWER$FILE_DELETED,data-testid="file-deleted-message") instead of the view-mode toolbar / Monaco editor for that case. -
Onboarding modal:
src/components/features/onboarding/onboarding-modal.tsxis rendered by<OnboardingHost />and normally gated by theopenhands-onboardedlocalStorage flag.OnboardingHostalso suppresses the modal, without writing that flag, when any active Cloud backend's settings report a usable LLM (non-empty model plus API-key or subscription auth); this readiness exception must not apply to Local backends. It tracks logical phases (backend,agent,setup,hello) rather than fixed numeric steps; the backend phase may be omitted for an already configured backend. Agent choices are derived fromACP_PROVIDERSplus OpenHands. The setup phase rendersSetupLlmStepfor OpenHands orSetupAcpSecretsStepfor ACP providers. Keep the phase-based navigation so adding or removing the backend slide cannot move users to the wrong step. -
Files tab diff-view default logic: keyed off
useHasAttachedSource()(src/hooks/use-has-attached-source.ts), which is true when the user explicitly attached either a repo (conversation.selected_repository) or a local workspace (getStoredConversationMetadata(id).selected_workspace, persisted bycreateConversationwhenworkingDirOverrideis supplied). The agent-server pre-initialises every conversation workspace as a git worktree for its own change tracking, so do NOT use a filesystem probe (git status/useUnifiedGetGitChanges) as the attachment signal β that was tried in earlier iterations and made every fresh no-attachment conversation incorrectly default to diff view. The companionuseHasGitCommitsprobe (src/hooks/query/use-has-git-commits.ts) then suppresses diff view for attached-but-empty cases (unborn HEAD, non-git workspace). -
Collapsible thinking:
ThinkActionevents and LLM extended reasoning (reasoning_content/thinking_blocksonActionEvent) are rendered as collapsible sections viaCollapsibleThinking(src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx). Collapsed by default to keep the chat compact β the thinking is often in English regardless of the user's conversation language. ThegetReasoningContent()helper inevent-thought-helpers.tsextracts the content, preferringreasoning_content(plain string) and falling back to Anthropicthinking_blocks. i18n keys:THINKING$TITLE,THINKING$EXPAND,THINKING$COLLAPSE. Tests:__tests__/components/conversation-events/chat/event-message-think-action.test.tsx. -
Agent delegation settings: the
Settings > Agentpage (src/routes/agent-settings.tsx) is intentionally NOT aSdkSectionPagewrapper. It mirrors upstream OpenHands#14418 β it flatMaps every section ofagent_settings_schemaand finds theenable_sub_agentsfield by key, so it works regardless of which section the real backend exposes the field in. Don't refactor it back toSdkSectionPageunless you also know the real backend's section name and add a fallback for the live "SDK schema unavailable" path. The toggle persists viaagent_settings_diff. Nav item lives inOSS_NAV_ITEMS(settings-nav.tsx) with the robot icon (SETTINGS$NAV_AGENT). The mock schema insettings-handlers.tsputs the field in ageneralsection. Client-side gate:getAgentTools()inagent-server-adapter.tsonly attachestask_tool_setto new conversations whenagent_settings.enable_sub_agents === true. Without that gate the agent server would still receive the tool whenever it advertised it in/api/server_info, so the toggle had no effect on running conversations. -
Settings naming is backend-aware today: local
/settingsis profile-oriented (use-settings-nav-items.tsrenames the first settings item/title/subtitle toLLM Profilesandchat-input-model.tsx/chat-input-actions.tsxlink there asLLM Profiles), while cloud keeps the genericLLM Settingscopy because cloud still edits raw settings rather than saved profiles. The local profile editor (llm-settings-local-view.tsx) should keep explicit create/edit profile headings plus helper text so users know they are saving a profile, not mutating the current conversation directly. -
ESLint config (flat, ESLint 9): the project uses
eslint.config.js(not.eslintrc) and runs oneslint@9.x, not 10. The constraint pinning us below 10 iseslint-plugin-react@7.37.x, which still callscontext.getFilename()at rule-load time β that API was removed in ESLint 10 and@eslint/compat'sfixupPluginRulesdoes NOT shim it. Don't try to bump eslint past 9 until eslint-plugin-react ships a v10-compatible release. Import rules come fromeslint-plugin-import-x(the maintained fork ofeslint-plugin-import) but are registered under bothimport-x/andimport/prefixes viaplugins: { import: importXPlugin, ... }so existing// eslint-disable-next-line import/...directives keep working.linterOptions.reportUnusedDisableDirectivesis set to"warn"(not "off") so stale airbnb-era disable comments still surface in lint output without failing CI. The TS-overrides block has anignores: ["src/hooks/query/query-keys.ts"]so theno-restricted-syntaxrule banning raw["settings", ...]query keys doesn't fire on the file that defines the helpers themselves. No.npmrc/legacy-peer-depsflag is needed β all our plugins declare ESLint 9 peer compatibility. -
Centralized config:
config/defaults.jsonis the single source of truth for version pins (agent-server, automation, automation SDK), port defaults, persistence paths, and package names. All consumers read from this file:- JS scripts (
dev-safe.mjs,dev-with-automation.mjs,check-sdk-version-sync.mjs) read it viaJSON.parse(readFileSync(...)). - Docker: a
config-genbuild stage converts the JSON to/opt/agent-canvas/defaults.env(shell-sourceable);entrypoint.shsources it at startup. - CI workflow: a
Read defaults from config/defaults.jsonstep usesnode -pto extract values into$GITHUB_OUTPUT. - Dockerfile ARG defaults are kept as fallbacks for local
docker buildwithout the CI workflow; CI always passes--build-argoverrides from the JSON. - To bump a version, edit
config/defaults.jsononly β the JS scripts, Docker build, and CI workflow all derive their values from it.
- JS scripts (
-
Docker all-in-one image:
.github/workflows/docker.ymlbuilds and publishesghcr.io/openhands/agent-canvasβ a combined image that bundles the agent-server (fromghcr.io/openhands/agent-server), the automation server (openhands-automationvia pip), and the agent-canvas frontend (static build). The Dockerfile lives atdocker/Dockerfile, the entrypoint atdocker/entrypoint.sh. The workflow structure mirrors the SDK repo'sserver.yml: abuild-and-push-imagematrix job (2 Γ arch: amd64 onubuntu-24.04, arm64 onubuntu-24.04-arm) pushes arch-suffixed tags, thenmerge-manifestscreates multi-arch manifests viadocker buildx imagetools create, thenconsolidate-build-infoaggregates artifacts, andupdate-pr-descriptionupdates the PR body (using<!-- AGENT_CANVAS_DOCKER_START -->/<!-- AGENT_CANVAS_DOCKER_END -->markers). The workflow triggers on push to main,v*tags (releases), PRs, andworkflow_dispatch. On release tags it also pushes semver tags (e.g.1.2.3,1.2,1,latest). Fork PRs are skipped (no GHCR auth). On PRs that link anOpenHands/software-agent-sdkPR in the description, the Docker workflow uses that SDK PR's published branch image (ghcr.io/openhands/agent-server:<branch-with-slashes-as-dashes>-python) as the agent-server base image unless aworkflow_dispatchinput explicitly overrides it. The image exposes port 8000 as a unified entry point:/api/automation/*β automation (:18001),/api/*β agent-server (:18000),/*β static frontend. The Dockerfile accepts the publicVITE_POSTHOG_API_KEYbuild arg; CI passes staging for PR/main images and production for tagged releases. The npm release workflow passes the same production key to both the app and library builds. The entrypoint auto-generates both the session API key andOH_SECRET_KEY(persisted to~/.openhands/agent-canvas/session-api-key.txtandsecret-key.txtrespectively) when none is provided, so the image runs secure by default. Users can override either via env var (OH_SECRET_KEY,SESSION_API_KEY/OH_SESSION_API_KEYS_0).scripts/dev-safe.mjsuses the samesecret-key.txtfile, so dev mode and Docker share the same key when both use the same~/.openhandsdirectory. -
Spec files live under
specs/. Spec IDs are stable β never renumber. Mark deprecated specs withstrikethrough. Tag implementation code and tests with// @spec BM-002 β Short titlecomments so specs are grep-able across the codebase (grep -rn '@spec BM-' src/ __tests__/). Place the comment on the line immediately above the relevant code block or test. When multiple tests cover the same spec, useit.eachif the test structure is identical. -
Release automation is trunk-based through release-please. Follow
.agents/skills/release.mdfor the current process. -
Electron desktop app (
npm run desktopfor dev /npm run build:desktopfor the binary) starts the same stack asdev-with-automation.mjsbut inside an Electron BrowserWindow. Two gotchas live here:-
Boot race vs. agent-server cold start:
dev-with-automation.mjsis a fire-and-forget launcher βmain()previously returned as soon aswaitForServicesaw the ingress proxy respond on/api/health, but ingress responds immediately while the agent-server behind it can still be downloading viauvx(first run pulls ~50 MB of Python + the SDK from PyPI, easily 30β90s).electron/main.mjsused to load the URL as soon as the proxy responded, so the React app booted, called/server_info, and got the "Request timeout" popup. Fix:main()now accepts anagentServerReadyTimeoutMsoption and returns{ config, agentServerReady };electron/main.mjsruns a two-stage wait β Stage 1 (waitForUrl) confirms the ingress proxy is up, Stage 2 (waitForAgentServer) hits${ingress}/server_infoand only accepts200(or401, which proves the proxy reached a real agent-server) before the BrowserWindow loads. Don't shorten the agent-server timeout below ~3 min β uvx cold start on slow connections genuinely takes that long. -
First-run feedback loop:
dev-with-automation.mjs::setServiceLogListener(cb)exposes a workspace-wide hook that firescb(name, line, level)for every line of every child-process stdout/stderr/exit.electron/main.mjs::handleServiceLogfilters for uvx install lines (Downloading...,Resolved N packages..., etc.) plus agent-server boot markers and forwards them toloading.htmlviasetLoadingStatus()βwindow.__setLoadingStatus(). The hook is best-effort and swallows listener errors β a buggy embedder must not be able to take down the dev stack.
-
-
Electron desktop packaging β
electron-builder.config.mjsusesdirectories.app: "electron"so electron/package.json is the app manifest. Even though electron/package.json has zerodependencies, app-builder-lib'scollectNodeModulesWithLoggingwalks UP from the app dir looking for the first npm workspace that resolves modules. The next dir in line is the project root, wherenpm list --jsonreports the full hoisted tree (~342 dirs, ~600 MB of Vite/React/Monaco/HeroUI), and electron-builder copies all of it intoResources/app/node_modules/. The walk is hardcoded inapp-builder-lib/out/util/appFileCopier.js::collectNodeModulesWithLoggingβ there is no config knob to disable it. Creating an emptyelectron/node_modules/does NOT help because the collector falls through to project root when it sees zero deps. The fix is theafterPackhook (stripBundledNodeModulesinelectron-builder.config.mjs): after electron-builder copies everything, the hookrm -rfsResources/app/node_modules/(handling macOS.appbundle layout and Linux/Windows flat resources/ layout), then copies back the dependency closure ofRUNTIME_PACKAGES(sirvfor static-server.mjs,httpxyfor proxy-utils.mjs/ingress.mjs β ~200 KB total). Effect:resources/app/drops from ~598 MB to ~7 MB; totallinux-unpacked/from ~1 GB to ~365 MB (the rest is Electron + Chromium + the bundleduvbinary). If a spawned backend script gains a new bare npm import, add the package toRUNTIME_PACKAGESβ otherwise that service crashes withERR_MODULE_NOT_FOUNDonly in the installed app. Testing trap: an app launched fromdist-electron/inside the repo resolves bare specifiers against the repo's ownnode_modules(Node ESM resolution walks up from the script file), so a missing runtime package is invisible there β verify packaged builds from a copy outside the repo tree (e.g./Applications). Don't add real deps to electron/package.json β any real dep would survive the strip and would also have to be hand-installed inside electron/ since the project root is npm-hoisted.build:desktop:universaland--linux/--win/--macvariants all run the same hook. -
Electron desktop app name in dev (macOS) β
npm run desktopshows the app as "Electron" in the Dock unlessscripts/brand-dev-electron.mjs(wired as thepredesktophook) has run. There are three independent name sources and they must all be set; getting one wrong looks like the fix silently not working. (1)app.nameβ Electron-internal, drives the menu bar, About panel andapp.getPath("userData"). It comes fromproductNameinelectron/package.json, read by Electron'sdefault_appin dev andlib/browser/initwhen packaged. Notedefault_apponly reads<arg>/package.json, sonpm run desktopmust point electron at theelectron/directory βelectron electron/main.mjsmakes it probeelectron/main.mjs/package.json, miss, and leaveapp.nameat the host bundle default. (2)CFBundleDisplayName/CFBundleNamein the running bundle's Info.plist β whatlsappinfoandNSRunningApplication.localizedNamereport. (3) The.appdirectory name β this is what the Dock tooltip actually shows. macOS prefers the bundle's filesystem name over the plist keys;/Applications/DBeaver.appdisplays as "DBeaver" despiteCFBundleName = "DBeaver Community". So patching only the plist is NOT enough β the script also renamesnode_modules/electron/dist/Electron.appβ<productName>.appand rewritesnode_modules/electron/path.txtto match (getElectronPath()innode_modules/electron/index.jsjoins path.txt ontodist/and silently re-downloads Electron ~100 MB if it doesn't resolve, so the two must move together).CFBundleExecutableis deliberately left asElectronβ/Applications/Antigravity.appships that exact value and still displays correctly, so it only affectsps/Activity Monitor. Editing the plist does not break code signing: Electron's dist is ad-hoc linker-signed (Info.plist=not bound,Sealed Resources=none), so the signature covers only the Mach-O.npm run build:desktopis unaffected by the rename β electron-builder packages from~/Library/Caches/electron/electron-v*.zip, never fromnode_modules/electron/dist. The packaged app never had the problem: electron-builder emits<productName>.appwith matching plist keys. Already-running instances keep the name they launched with, so quit and relaunch when verifying. -
Electron desktop
node/npm/npxPATH bridging β when the packaged.appis launched from Finder/Spotlight on macOS, the OS gives it a minimal PATH (/usr/bin:/bin). Homebrew, nvm, asdf installs of Node.js are invisible to spawned subprocesses. Two breakages flow from that: (1) backend launcher scripts that dospawn("node", ...)can't find Node; (2) most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.) usecommand: "npx", and when the agent-server tries to spawn them the missingnpxmakes the spawn fail with ENOENT β the SDK reports it as anerror_kind: "connection"MCP test failure, which the install modal renders asMCP$TEST_ERROR_CONNECTION("Could not reach the server. Check the URL and server type."), a misleading error since no URL is involved. First fix attempt β DOES NOT WORK for stdio MCPs: wrapnode/npm/npxwith thin shell scripts that run Electron withELECTRON_RUN_AS_NODE=1against the package's CLI JS. That bridges the ENOENT but stdio JSON-RPC servers spawned through the wrapper exit withMcpError: Connection closedbefore completing the MCP handshake β Electron-as-Node has subtly different stdin/stdout pipe semantics from a vanillanodebinary when used as a stdio child of a windowed process. Working fix: bundle the real Node.js distribution.scripts/download-node.mjsdownloads the officialnode-v<ver>-<platform>-<arch>tarball fromhttps://nodejs.org/dist/v<ver>/intoresources/node/(gitignored), prunesinclude/,share/, docs, andnode_modules/corepackto keep the size down (~130 MB on Linux x64, dominated by the Node binary itself). Default pin:NODE_BUNDLE_VERSION = "22.12.0"(the repo'sengines.nodefloor; every 22.x build shares the Electron 42 ABI); override withNODE_VERSION=.electron-builder.config.mjsshipsresources/node/as an extraResource β<Resources>/node/.electron/main.mjs::injectBundledNode()prepends the platform-appropriate bin dir toPATH(POSIX:<Resources>/node/bin; Windows:<Resources>/node/) so subsequent spawns ofnode/npm/npxresolve to real binaries with full stdio fidelity. It alsochmod +x's the binaries on POSIX because electron-builder doesn't always preserve the bit.injectBundledNode()is a no-op when!app.isPackaged(devnpm run desktopuses the developer's system node).build:desktopandbuild:desktop:universalboth rundownload-node.mjsafterdownload-uv.mjs. If the bundled dir is missing at runtime,injectBundledNode()logs a loud[desktop]warning instead of silently leaving PATH bare. -
Cloud conversation resume gating: when a cloud conversation is closed from the UI (
pauseCloudSandboxis called), the conversation'sconversation_urlis NOT cleared -- it still points to the old sandbox host.WebSocketProviderWrappermust suppress the URL (passnulltoConversationWebSocketProvider) whilesandbox_status === "PAUSED", otherwise the WebSocket immediately tries the stale URL before the sandbox wakes. Symmetrically,useActiveConversation's refetch interval must fast-poll (3 s) on both!conversation_urlANDsandbox_status === "PAUSED"-- checking only the missing URL would leave the hook on the 30 s interval while the sandbox is resuming. The resume sequence: navigate -> sandbox PAUSED detected ->resumeCloudSandboxcalled (inconversation.tsx) -> fast-poll detects RUNNING ->conversationUrlunblocked -> WebSocket connects.