{
  "published_snapshot": "2026-10-09",
  "source_repository": "https://github.com/OpenHands/OpenHands",
  "source_revision": "8793c111c68db4b24e4ec3e882612229368ee426",
  "source_directory": ".agents/skills/verify-openhands/references/feature-map",
  "families": 27,
  "behaviors": 743,
  "recipes_and_supporting_checks": 722,
  "captures": 44,
  "inventory": [
    {
      "id": "F01",
      "title": "First run, onboarding and sign-in",
      "file": "F01-first-run-and-sign-in.md",
      "page": "F01-first-run-and-sign-in.html",
      "sha256": "7c12491d17d943200284722a429f687794bcf6a61b94e9f9af3178ab8e252741",
      "behaviors": [
        {
          "id": "F01.first-run-gate",
          "description": "with no `openhands-onboarded` flag in localStorage, any URL (including `/conversations`) shows only `first-run-onboarding-screen` with the onboarding modal. There is no sidebar. Finishing or skipping sets the flag, and the app stays revealed after a reload."
        },
        {
          "id": "F01.telemetry-consent",
          "description": "on a connected local backend whose `user_consents_to_analytics` is `null`, an elevated \"Help improve OpenHands\" modal names the backend and host. It has a checked-by-default \"Send anonymous usage data\" box and \"Confirm preferences\". Escape does not dismiss it. The choice is saved to backend settings, and the modal does not return after a reload."
        },
        {
          "id": "F01.onboarding-modal",
          "description": "a segmented progress bar (3 steps, or 4 with the backend step) over a sliding rail. Neither Escape nor a click on the backdrop dismisses it. \"Skip for now\" shows on every step except the last, and Back/Next move between steps."
        },
        {
          "id": "F01.onboarding-backend-step",
          "description": "when no reachable backend exists (public mode), step 0 \"Add a backend\" prefills the name `Local` and the page origin as host. Next stays disabled until an API key is typed. A wrong key shows \"Could not connect to <host>\" / \"Invalid API key\" inline. The right key saves the backend, and the flow collapses to 3 steps on \"Choose your agent\" with no Back button. The first paint must not raise an error toast."
        },
        {
          "id": "F01.onboarding-choose-agent",
          "description": "radio tiles for OpenHands (preselected), Claude Code, Codex and Gemini CLI. Next saves the agent kind with a \"Settings saved\" toast."
        },
        {
          "id": "F01.onboarding-setup-llm",
          "description": "the embedded LLM form is prefilled with `openai/gpt-5.6-sol`. Typing a custom model and key then Next saves them. On a local backend it also creates and activates an LLM profile named after the model (`deepseek/deepseek-flash` → `deepseek-flash`)."
        },
        {
          "id": "F01.onboarding-repeat-endpoint",
          "description": "onboarding again (a new browser, the same backend) with the custom Base URL and key that are already saved creates a profile that keeps that Base URL, and the say-hello conversation reaches that endpoint. The All view prefills the saved Base URL."
        },
        {
          "id": "F01.onboarding-acp-secrets",
          "description": "choosing an ACP agent swaps the LLM step for \"Add your API keys\" with that provider's fields. When the host CLI is already logged in, a green \"already signed in\" banner makes them optional. Back returns to the agent step."
        },
        {
          "id": "F01.onboarding-say-hello",
          "description": "the last step has a prefilled message. An empty message disables send. Enter or the send button creates a conversation, navigates to `/conversations/<id>` and completes onboarding, and the agent replies. A recommended-automations list sits under an \"Or\" separator, and a \"Skip Getting Started checklist\" box sits below the modal."
        },
        {
          "id": "F01.onboarding-hello-close",
          "description": "the last step has Back and Close instead of \"Skip for now\". Close dismisses the modal without creating a conversation, sets the `openhands-onboarded` flag and leaves the app shell on `/`."
        },
        {
          "id": "F01.onboarding-recommended-automation",
          "description": "picking a card under \"Start from a proven workflow\" on the last step completes onboarding too. A card with its own setup form (for example \"Daily news digest\") navigates to `/automations/new/<id>` and opens that form. Nothing is created until the form is submitted."
        },
        {
          "id": "F01.onboarding-skip-checklist",
          "description": "the \"Skip Getting Started checklist\" box under the last step hides the sidebar **Getting started** card (F02) once the app is revealed. It is the same per-browser setting as the Settings → Application switch, and it is saved as soon as you tick it, preview mode included."
        },
        {
          "id": "F01.onboarding-skip",
          "description": "\"Skip for now\" closes the modal and sets the flag. In public mode, skipping before a key is stored lands on the API-key screen; skipping after the backend step lands in the app."
        },
        {
          "id": "F01.onboarding-preview",
          "description": "`?previewOnboardingStep=0..3` on any route opens the modal (`data-preview=\"true\"`) on that phase, even after onboarding. Values outside 0-3 are ignored. Skip and Close do nothing, so leave by navigating to a URL without the parameter."
        },
        {
          "id": "F01.onboarding-phone",
          "description": "every onboarding step fits a 390 px viewport without horizontal overflow."
        },
        {
          "id": "F01.api-key-entry",
          "description": "in public mode with no usable key (onboarding skipped, the last backend removed, or a stored key the server now rejects), a full-screen \"Add a backend\" card appears. The host is read-only and set to the origin, and Connect stays disabled until the name and key are filled. A wrong key shows \"Invalid API key. Please check the key and try again.\" The right key opens the app shell, which survives a reload, and the consent modal then names the new backend."
        },
        {
          "id": "F01.onboarding-cloud-login",
          "description": "the backend step's \"OpenHands Cloud\" column has \"Connect to OpenHands\" (device-flow login) and an \"Advanced\" toggle. The toggle reveals a Cloud Host field (placeholder `https://app.all-hands.dev`) for self-hosted Cloud deployments. The login itself is blocked: it needs an OpenHands Cloud account."
        },
        {
          "id": "F01.route-error-boundary",
          "description": "an unknown URL renders a \"Page not found\" page inside the app shell, with the sidebar and a **Home** link back to `/`, and logs no page errors."
        },
        {
          "id": "F01.document-title",
          "description": "the tab title is `OpenHands`. On a conversation it is `<status emoji> <conversation title> | OpenHands`."
        },
        {
          "id": "F01.bootstrap-loading",
          "description": "a centered spinner card shows while `/server_info` loads (transient, not-run)."
        },
        {
          "id": "F01.locked-cloud-first-run",
          "description": "on a canvas locked to an OpenHands Cloud host, first run shows only the Cloud login (Add backend) without a progress bar or close button (blocked)."
        },
        {
          "id": "F01.cookie-auth-redirect",
          "description": "OHE cookie-auth deployments probe the main-app session behind a spinner and redirect to its `/login` when it is missing (blocked)."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "First-run gate (`F01.first-run-gate`).",
          "body": "Run `control-openhands browser goto /conversations` and `control-openhands browser testids`. The list starts with `first-run-onboarding-screen`, `telemetry-consent-form` and `onboarding-modal`; `root-layout` is absent. Run `control-openhands browser screenshot --feature F01.first-run-gate --name fresh`. The screenshot shows the consent modal over \"Choose your agent\".",
          "ids": [
            "F01.first-run-gate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Consent (`F01.telemetry-consent`).",
          "body": "Run `control-openhands browser snapshot 'testid=telemetry-consent-form'`. It reads `This preference is saved for the local backend “Local” at http://127.0.0.1:<port>.` and the checkbox is `[checked]`. Run `control-openhands browser press Escape` and `control-openhands browser count 'testid=telemetry-consent-form'`; the count stays `1`. Run `control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'`, `control-openhands browser click 'testid=confirm-telemetry-preferences'`, `control-openhands browser wait 'testid=telemetry-consent-form' --state detached`, then `control-openhands api GET /api/settings`. It shows `\"user_consents_to_analytics\": false`. Run `control-openhands browser reload` and `control-openhands browser count 'testid=telemetry-consent-form'`; the count is `0`.",
          "ids": [
            "F01.telemetry-consent"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Modal shell (`F01.onboarding-modal`).",
          "body": "Run `control-openhands browser press Escape`, `control-openhands browser count 'testid=onboarding-modal'` (`1`) and `control-openhands browser attr 'testid=onboarding-modal' data-current-step` (`0`). Run `control-openhands browser mouse-click 40 40` on the bare backdrop; the count stays `1` and the step is unchanged. `onboarding-progress-step-0..2` exist and `-3` does not, because the backend is healthy.",
          "ids": [
            "F01.onboarding-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Choose agent (`F01.onboarding-choose-agent`).",
          "body": "Run `control-openhands browser attr 'testid=onboarding-agent-option-openhands' aria-checked` (`true`) and `control-openhands browser count 'testid=onboarding-agent-back'` (`0`). Then run `control-openhands browser click 'testid=onboarding-agent-option-codex'` and `control-openhands browser attr 'testid=onboarding-agent-option-codex' aria-checked` (`true`), and switch back with `control-openhands browser click 'testid=onboarding-agent-option-openhands'`. Run `control-openhands browser click 'testid=onboarding-agent-next'` and `control-openhands browser wait-text 'Settings saved'`; `data-current-step` becomes `1`. Run `control-openhands browser click 'testid=onboarding-llm-back'`; `data-current-step` is `0`. Click `testid=onboarding-agent-next` again.",
          "ids": [
            "F01.onboarding-choose-agent"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "LLM step (`F01.onboarding-setup-llm`).",
          "body": "Run `control-openhands browser snapshot 'testid=onboarding-step-setup-llm'`. It shows `Set up your LLM` and Basic tab comboboxes `OpenAI` / `gpt-5.6-sol`. Run `control-openhands browser click 'testid=onboarding-step-setup-llm >> testid=sdk-section-advanced-toggle'` and `control-openhands browser value 'testid=onboarding-step-setup-llm >> testid=llm-custom-model-input'` (`openai/gpt-5.6-sol`). Then run `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=llm-custom-model-input' deepseek/deepseek-flash`, `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=llm-api-key-input' --value-env DEEPSEEK_API_KEY` and `control-openhands browser click 'testid=onboarding-llm-next'`. Wait with `control-openhands browser wait '[data-testid=onboarding-modal][data-current-step=\"2\"]'`. Second view: `control-openhands llm show` lists profile `deepseek-flash` (`deepseek/deepseek-flash`, `api_key_set: true`) as `active_profile`.",
          "ids": [
            "F01.onboarding-setup-llm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Say hello (`F01.onboarding-say-hello`).",
          "body": "Run `control-openhands browser count 'testid=onboarding-skip'` (`0` on the last step), `control-openhands browser fill 'testid=onboarding-hello-input' ''` and `control-openhands browser enabled 'testid=onboarding-hello-input-form >> testid=submit-button'` (`false`). Then run `control-openhands browser fill 'testid=onboarding-hello-input' 'Reply with only the word hello. Do not run any tools.'` (`enabled` turns `true`), `control-openhands browser press Enter --selector 'testid=onboarding-hello-input'` and `control-openhands browser wait-url '/conversations/[0-9a-f-]+'`. Take `<id>` from `control-openhands browser url`. Run `control-openhands conversation wait <id> --timeout 180` and `control-openhands conversation events <id> --kinds MessageEvent`. The agent message is `hello`, and `control-openhands browser count 'testid=onboarding-modal'` is `0`. `control-openhands browser eval \"localStorage.getItem('openhands-onboarded')\"` returns `\"1\"`. After `control-openhands browser reload` and `control-openhands browser goto /`, `testid=first-run-onboarding-screen` and `testid=onboarding-modal` both count `0`.",
          "ids": [
            "F01.onboarding-say-hello"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Tab title (`F01.document-title`).",
          "body": "On `/` run `control-openhands browser url`; `title` is `OpenHands`. On `/conversations/<id>` (after a reload, once the title is generated) the title is `<emoji> <conversation title> | OpenHands`, for example `✅ … | OpenHands` for a finished conversation.",
          "ids": [
            "F01.document-title"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Preview (`F01.onboarding-preview`).",
          "body": "Run `control-openhands browser goto '/?previewOnboardingStep=3'`, then `control-openhands browser attr 'testid=onboarding-modal' data-preview` (`true`) and `control-openhands browser count 'testid=onboarding-step-say-hello'` (`1`). Step `2` shows the LLM slide. Steps `0` and `1` both show \"Choose your agent\" when the backend is healthy. `/?previewOnboardingStep=9` renders no modal. `control-openhands browser goto '/settings/app?previewOnboardingStep=3'` works too. Run `control-openhands browser goto '/?previewOnboardingStep=1'` and `control-openhands browser click 'testid=onboarding-skip'`; the modal stays (`count` `1`). `control-openhands browser goto /` shows no modal.",
          "ids": [
            "F01.onboarding-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Skip checklist box (`F01.onboarding-skip-checklist`).",
          "body": "Run `control-openhands browser goto /conversations` and `control-openhands browser count 'testid=sidebar-onboarding-checklist'` (`1`). Run `control-openhands browser goto '/?previewOnboardingStep=3'`, then `control-openhands browser click 'text=Skip Getting Started checklist'`. `control-openhands browser eval \"document.querySelector('[data-testid=onboarding-skip-getting-started-checklist]').checked\"` is `true`. Run `control-openhands browser goto /conversations` and `control-openhands browser reload`; the checklist count is `0`. On `/settings/app`, `control-openhands browser eval \"document.querySelector('[data-testid=show-getting-started-checklist-switch]').checked\"` is `false`. Restore it: go back to `/?previewOnboardingStep=3`, where the box now reads `true`, click the same text again, and the checklist count on `/conversations` is `1`.",
          "ids": [
            "F01.onboarding-skip-checklist"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Phone (`F01.onboarding-phone`).",
          "body": "Run `control-openhands browser viewport phone`. Then for N in 1, 2 and 3 run `control-openhands browser goto '/?previewOnboardingStep=N'`, `control-openhands browser bbox 'testid=onboarding-modal'` and `control-openhands browser screenshot --feature F01.onboarding-phone --name step-N`. The modal is 351 px wide with `insideViewport` `true` and `pageHorizontalOverflow` `false`, and Back/Next or Back/Close are visible. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F01.onboarding-phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Error page (`F01.route-error-boundary`).",
          "body": "Run `control-openhands browser errors --clear`, `control-openhands browser goto /this-route-does-not-exist` and `control-openhands browser snapshot 'testid=not-found-screen'`. It shows `heading \"Page not found\"`, the paragraph `This address does not match any page. Check the URL, or go back to the home page.` and `link \"Home\"` (`/url: /`). `control-openhands browser count 'aside[data-collapsed]'` is `1`: the sidebar stays. `control-openhands browser screenshot --feature F01.route-error-boundary --name not-found` shows the message and the Home button centered beside the sidebar. `control-openhands browser errors --app-only` reports `pageErrors` `0` and `appErrors` `0`. Run `control-openhands browser click 'testid=not-found-home-link' --expect-url '/$'`; `control-openhands browser count 'testid=home-screen'` is `1`.",
          "ids": [
            "F01.route-error-boundary"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Onboarding again with the saved endpoint (`F01.onboarding-repeat-endpoint`).",
          "body": "Two more first runs on this stack, both through the All view, which shows the Base URL field. Pass 1 saves a custom endpoint: `control-openhands browser reset`, `control-openhands browser goto /`, `control-openhands browser click 'testid=onboarding-agent-next'`, `control-openhands browser wait '[data-testid=onboarding-modal][data-current-step=\"1\"]'`, `control-openhands browser click 'testid=onboarding-step-setup-llm >> testid=sdk-section-all-toggle'` and `control-openhands browser value 'testid=onboarding-step-setup-llm >> testid=base-url-input'` (empty). Run `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=llm-custom-model-input' openai/deepseek-chat`, `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=base-url-input' https://api.deepseek.com/v1`, `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=llm-api-key-input' --value-env DEEPSEEK_API_KEY`, `control-openhands browser click 'testid=onboarding-llm-next'` and `control-openhands browser wait '[data-testid=onboarding-modal][data-current-step=\"2\"]'`. `control-openhands llm show` lists `deepseek-chat` (`openai/deepseek-chat`, `base_url` `https://api.deepseek.com/v1`) as `active_profile`. Say hello as above (`browser fill 'testid=onboarding-hello-input' 'Reply with only the word hello. Do not run any tools.'`, `browser press Enter --selector 'testid=onboarding-hello-input'`, `browser wait-url '/conversations/[0-9a-f-]+'`), then `control-openhands conversation wait <id> --timeout 180` is `finished` and the agent `MessageEvent` is `hello`. Pass 2 types the same endpoint again: repeat the steps up to the All view. `browser value 'testid=onboarding-step-setup-llm >> testid=base-url-input'` is now `https://api.deepseek.com/v1`, the saved value. Fill the model `openai/deepseek-v4-flash` (a DeepSeek alias, so the new profile gets its own name), the same Base URL and the key, then Next and wait for step 2 (`control-openhands browser screenshot 'testid=onboarding-step-setup-llm' --feature F01.onboarding-repeat-endpoint --name second-pass-form` before Next). Expected: `control-openhands llm show` lists `deepseek-v4-flash` with `base_url` `https://api.deepseek.com/v1`, and the say-hello conversation finishes with `hello`. Known failure (reproduced 2026-10-08 at `53c8b4d`): the profile is saved with `base_url` `null`. The say-hello conversation then ends in `error`, and `control-openhands conversation events <id> --last 10` shows `ConversationErrorEvent` `LLMAuthenticationError: litellm.AuthenticationError: ... OpenAIException - Incorrect API key provided`: the DeepSeek key went to OpenAI. The chat shows `Your LLM API key appears to be invalid or has expired.` (`browser screenshot --feature F01.onboarding-repeat-endpoint --name second-pass-error`). Issue #17884, fix in #17889. Restore: run `control-openhands llm preset deepseek`. It also points the `default` agent profile back at `deepseek-flash`: its output has `repointed` from `deepseek-v4-flash` to `deepseek-flash`. Then `control-openhands api DELETE /api/profiles/deepseek-v4-flash --write` and `.../deepseek-chat --write` answer `200`. Delete the two say-hello conversations with Clean up's steps.",
          "ids": [
            "F01.onboarding-repeat-endpoint"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Clean up.",
          "body": "Delete the hello conversation from its header menu: `control-openhands browser goto /conversations/<id>` (the hello conversation's `<id>` from Say hello; the Error page bullet left `/`), `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'`, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=delete-button'`, `control-openhands browser click 'role=button[name=\"Confirm Delete\"]'`. `control-openhands conversation list` then shows `count` `0`.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Backend step (`F01.onboarding-backend-step`).",
          "body": "Run `control-openhands browser goto /`, `sleep 12` (gives a late toast time to appear), `control-openhands browser toasts --history`, `control-openhands browser screenshot --feature F01.onboarding-backend-step --name fresh` and `control-openhands browser snapshot 'testid=onboarding-step-check-backend'`. The toast history is `[]`, and the screenshot shows no toast. The snapshot shows `Add a backend`, name `Local`, host `http://127.0.0.1:<port>`, Type `Local` checked, `button \"Next\" [disabled]`, and an \"OpenHands Cloud\" column with \"Connect to OpenHands\". Run `control-openhands browser fill 'testid=onboarding-backend-api-key' qa-wrong-key`, `control-openhands browser click 'testid=onboarding-backend-next'` and `control-openhands browser text 'testid=onboarding-backend-error'`. It reads `Could not connect to http://127.0.0.1:<port>` / `Invalid API key`, and `data-current-step` stays `0`. Run `control-openhands browser fill 'testid=onboarding-backend-api-key' --value-file \"$OH_VERIFY_RUN/private/session-key\"`, `control-openhands browser click 'testid=onboarding-backend-next'` and `control-openhands browser wait 'testid=telemetry-consent-form'`. The modal is on \"Choose your agent\" with `data-current-step` `0`, `testid=onboarding-progress-step-3` and `testid=onboarding-agent-back` both count `0`, and the consent form names `“Local”`. Answer it with `control-openhands browser click 'testid=confirm-telemetry-preferences'`.",
          "ids": [
            "F01.onboarding-backend-step"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "ACP credentials (`F01.onboarding-acp-secrets`).",
          "body": "Run `control-openhands browser click 'testid=onboarding-agent-option-claude-code'`, `control-openhands browser click 'testid=onboarding-agent-next'` and `control-openhands browser wait-text 'Settings saved'`. Then run `control-openhands browser snapshot 'testid=onboarding-step-setup-acp-secrets'`. It shows `Add your API keys` with `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL`, and `control-openhands api GET /api/settings` shows `\"agent_kind\": \"acp\"`. Run `control-openhands browser click 'testid=onboarding-acp-secrets-back'`, `control-openhands browser click 'testid=onboarding-agent-option-openhands'` and `control-openhands browser click 'testid=onboarding-agent-next'`. `testid=onboarding-step-setup-llm` is shown and `agent_kind` is back to `openhands`.",
          "ids": [
            "F01.onboarding-acp-secrets"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Skip into the app (`F01.onboarding-skip`).",
          "body": "Run `control-openhands browser click 'testid=onboarding-skip'`, `control-openhands browser wait 'testid=root-layout'`, `control-openhands browser reload`, `control-openhands browser count 'testid=onboarding-modal'` (`0`) and `control-openhands browser count 'testid=api-key-entry-screen'` (`0`).",
          "ids": [
            "F01.onboarding-skip"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Last backend removed (`F01.api-key-entry`).",
          "body": "Run `control-openhands browser click 'testid=backend-selector'`, `control-openhands browser click 'testid=manage-backends-menu-item'`, `control-openhands browser click 'testid=manage-backends-remove-Local'` and `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`. `control-openhands browser wait 'testid=api-key-entry-screen'` succeeds. Restore with the Connect recipe below.",
          "ids": [
            "F01.api-key-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Skip to API-key screen (`F01.onboarding-skip`, `F01.api-key-entry`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser click 'testid=onboarding-skip'` and `control-openhands browser wait 'testid=api-key-entry-screen'`. After `sleep 12`, `control-openhands browser toasts --history` is `[]`: the screen raises no error toast. Run `control-openhands browser reload`, `control-openhands browser wait 'testid=api-key-entry-screen'`, `sleep 12` and `control-openhands browser toasts --history` again: still `[]` when the API-key screen is the first paint (the toast these checks guard against reads `No backend is configured.`; `control-openhands browser count 'text=No backend is configured'` is `0`). Run `control-openhands browser value 'testid=api-key-entry-host'` (the origin; the field is disabled) and `control-openhands browser enabled 'testid=api-key-entry-submit'` (`false`). Fill only the key with `control-openhands browser fill 'testid=api-key-entry-api-key' qa-wrong-key`; `enabled` is still `false` because Host Name is required. Run `control-openhands browser fill 'testid=api-key-entry-name' 'QA Public'` (`enabled` turns `true`), `control-openhands browser click 'testid=api-key-entry-submit'` and `control-openhands browser text 'testid=api-key-entry-status'`. It reads `Invalid API key. Please check the key and try again.`, the screen stays, and `control-openhands browser screenshot --feature F01.api-key-entry --name wrong-key` shows the red line above Connect. While the screen is still up, run `control-openhands browser viewport phone` and `control-openhands browser bbox 'testid=api-key-entry-form'`. It gives `insideViewport` `true` and `pageHorizontalOverflow` `false`. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F01.onboarding-skip",
            "F01.api-key-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Connect (`F01.api-key-entry`).",
          "body": "Run `control-openhands browser fill 'testid=api-key-entry-name' 'QA Public'`, `control-openhands browser fill 'testid=api-key-entry-api-key' --value-file \"$OH_VERIFY_RUN/private/session-key\"`, `control-openhands browser click 'testid=api-key-entry-submit'` and `control-openhands browser wait 'testid=api-key-entry-screen' --state detached`. `testid=root-layout` is shown, the consent form names `“QA Public”`, and `control-openhands browser snapshot 'testid=backend-selector'` shows `combobox \"QA Public\"`. After `control-openhands browser reload`, `testid=api-key-entry-screen` counts `0` and `testid=root-layout` counts `1`.",
          "ids": [
            "F01.api-key-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Stale key re-prompt (`F01.api-key-entry`).",
          "body": "Answer consent if it is open (`control-openhands browser click 'testid=confirm-telemetry-preferences'`). Run `control-openhands restart --rotate-key` (`rotatedKey` `true`), then `control-openhands browser reload` and `control-openhands browser wait 'testid=api-key-entry-screen'`; `testid=root-layout` counts `0`. Connect again as above with the new `$OH_VERIFY_RUN/private/session-key`. After a reload, `testid=root-layout` counts `1`, and `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).length\"` is `1`, so no duplicate backend is added.",
          "ids": [
            "F01.api-key-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Close on the last step (`F01.onboarding-hello-close`).",
          "body": "Run `control-openhands browser reset` and `control-openhands browser goto /`. Walk to the last step: `control-openhands browser fill 'testid=onboarding-backend-api-key' --value-file \"$OH_VERIFY_RUN/private/session-key\"`, `control-openhands browser click 'testid=onboarding-backend-next'`, `control-openhands browser wait 'testid=onboarding-step-choose-agent'` (consent does not return: the backend already has an answer), `control-openhands browser click 'testid=onboarding-agent-next'` and `control-openhands browser wait '[data-testid=onboarding-modal][data-current-step=\"1\"]'`. Then fill the key with `control-openhands browser fill 'testid=onboarding-step-setup-llm >> testid=llm-api-key-input' --value-env DEEPSEEK_API_KEY`, open `testid=sdk-section-advanced-toggle` and fill `llm-custom-model-input` with `deepseek/deepseek-flash` as in the LLM step. Run `control-openhands browser click 'testid=onboarding-llm-next'` and `control-openhands browser wait '[data-testid=onboarding-modal][data-current-step=\"2\"]'`. Check `control-openhands conversation list` (`count` `0`), then run `control-openhands browser click 'testid=onboarding-hello-close'` and `control-openhands browser wait 'testid=onboarding-modal' --state detached`. `testid=root-layout` counts `1`, the URL is `/` and `localStorage.getItem('openhands-onboarded')` is `\"1\"`. `conversation list` still shows `count` `0`, and after `browser reload` the modal count is `0`.",
          "ids": [
            "F01.onboarding-hello-close"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Recommended automation (`F01.onboarding-recommended-automation`).",
          "body": "Run `control-openhands browser reset`, `control-openhands browser goto /`, and walk to the last step as above (the LLM step's Next works without retyping the key once the profile is saved). Run `control-openhands browser click 'testid=recommended-automation-card-news-digest' --expect-url '/automations/new/news-digest(\\?|$)'`. `testid=onboarding-modal` counts `0`, `openhands-onboarded` is `\"1\"`, and the \"Daily news digest\" setup form (`testid=setup-dialog`) is open. Leave without creating anything: `control-openhands browser click 'testid=setup-dialog-close'` and `control-openhands browser wait 'testid=setup-dialog' --state detached` (the URL becomes `/`). `control-openhands api GET /api/automation/v1` shows `\"total\": 0`.",
          "ids": [
            "F01.onboarding-recommended-automation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Cloud login column (`F01.onboarding-cloud-login`).",
          "body": "Run `control-openhands browser reset`, `control-openhands browser goto /` and `control-openhands browser click 'testid=onboarding-backend-advanced-toggle'`. `browser attr 'testid=onboarding-backend-advanced-toggle' aria-expanded` is `true`, and `browser attr 'testid=onboarding-backend-cloud-host' placeholder` is `https://app.all-hands.dev`. `browser text 'testid=onboarding-backend-advanced-panel'` mentions self-hosted Cloud deployments. Do not click `testid=onboarding-backend-login-button`: the device-flow login is blocked without an OpenHands Cloud account.",
          "ids": [
            "F01.onboarding-cloud-login"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Locked Cloud first run (`F01.locked-cloud-first-run`).",
          "body": "Blocked: needs a build locked to a Cloud host (`VITE_LOCK_TO_CLOUD` or `static-server --lock-to-cloud`) and an OpenHands Cloud account. `launch` has no such flag.",
          "ids": [
            "F01.locked-cloud-first-run"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Cookie-auth redirect (`F01.cookie-auth-redirect`).",
          "body": "Blocked: needs an OHE cookie-auth deployment.",
          "ids": [
            "F01.cookie-auth-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Bootstrap spinner (`F01.bootstrap-loading`).",
          "body": "Not-run: it lasts under a second on a healthy stack, and slowing `/server_info` would mean intercepting requests.",
          "ids": [
            "F01.bootstrap-loading"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F02",
      "title": "App shell, sidebar and command menu",
      "file": "F02-app-shell.md",
      "page": "F02-app-shell.html",
      "sha256": "1b509a6e66aff733dd6b66d2915f56b7a49e957fcaa7ef8884810f3be471321f",
      "behaviors": [
        {
          "id": "F02.nav-links",
          "description": "the logo and **New Chat** open `/conversations`. **Customize** opens `/customize`, which redirects to `/mcp` on desktop, and stays highlighted and marked `aria-current=\"page\"` on `/skills`, `/plugins`, `/apps` and `/mcp`. **Automate** opens `/automations`. Canvas extension pages add their own rows (`sidebar-canvas-extension-<ext>-<id>`)."
        },
        {
          "id": "F02.pin-as-home",
          "description": "hovering Customize or Automate reveals a pin. Pinning makes `/` redirect to that page, pinning the other one moves the pin, and unpinning restores the default home. The pin is stored per backend and org."
        },
        {
          "id": "F02.sidebar-collapse",
          "description": "the chevron collapses the rail to a 60 px icon rail with hover tooltips, a Settings icon and a backend icon with a status dot that opens a backend popover. The checklist is hidden while collapsed. The state survives a reload. To expand, click the chevron that appears when you hover the logo, or click empty rail space."
        },
        {
          "id": "F02.settings-link",
          "description": "the gear next to the backend selector, and the collapsed Settings icon, open `/settings` in the same tab for a local backend. On desktop that redirects to `/settings/agents`."
        },
        {
          "id": "F02.conversation-list",
          "description": "the expanded rail embeds the conversation list (`conversation-panel`). The list itself belongs to the conversations family."
        },
        {
          "id": "F02.command-menu-open-close",
          "description": "Control+k, Meta+k and the sidebar **Search commands** row (also the search icon on the collapsed rail) open a modal `Command menu` dialog with the input focused. Escape or the backdrop closes it, and the query is cleared on close. Escape returns focus to the element that had it before the menu opened (the **Search commands** row when it opened the menu). The row and the menu header show the shortcut as `Ctrl+K` off Apple platforms and `⌘K` on macOS and iOS."
        },
        {
          "id": "F02.command-menu-focus",
          "description": "the shortcut works while typing in another field (the composer) and moves focus to the search input. Tab moves focus to the first option. Escape closes the menu from any focused element inside it and returns focus to the composer."
        },
        {
          "id": "F02.command-menu-items",
          "description": "the menu shows Navigation (New chat, Customize, Automations, MCP servers), Settings (Settings, Agent settings, LLM profiles, Model Router, Condenser, Agent Context, Verification, Application, Secrets) and Actions (Toggle sidebar). Each **Go** item navigates to its route."
        },
        {
          "id": "F02.command-menu-toggle-sidebar",
          "description": "the **Toggle sidebar** action collapses or expands the desktop rail."
        },
        {
          "id": "F02.command-menu-search",
          "description": "every search term must match the title, description or keywords. **No commands found** is the empty state. The X button clears the query. Arrow keys wrap around the list, hovering an item selects it, Enter runs the selected item, and Enter does nothing when nothing matches."
        },
        {
          "id": "F02.command-menu-new-tab",
          "description": "a middle-click or modifier-click on a **Go** item opens the route in a new tab and leaves the menu open."
        },
        {
          "id": "F02.command-menu-phone",
          "description": "at 390 px the menu fits the screen and opens from the drawer's Search row. Escape closes the menu and leaves the drawer open. Running an item closes the drawer."
        },
        {
          "id": "F02.mobile-drawer",
          "description": "at phone width the rail is hidden and a hamburger (`sidebar-mobile-menu-toggle`) opens a 300 px drawer. The hamburger is in the top bar, or in the chat header on conversation pages. The drawer closes on its chevron, Escape, a backdrop click or any route change."
        },
        {
          "id": "F02.mobile-top-bar",
          "description": "at phone width, settings sub-pages and Customize detail pages (`/skills`, `/mcp`, `/plugins`, `/apps`) show a Back chevron that returns to `/settings` or `/customize`."
        },
        {
          "id": "F02.checklist",
          "description": "after onboarding, the expanded rail shows a **Getting started** card with **N complete** and six items. They link to `/settings/llm`, `/conversations`, `/automations`, `/settings/agents`, `/mcp` and the external Slack invite."
        },
        {
          "id": "F02.checklist-item-links",
          "description": "clicking an item's row navigates in the same tab to its route (for example **Schedule a task** opens `/automations`) without marking the item done."
        },
        {
          "id": "F02.checklist-progress",
          "description": "items get a check and a strikethrough when done. The LLM item completes when a profile is configured, Start your first chat when a conversation exists, Customize your agent after a visit to `/settings/agents`, and Join Slack on click."
        },
        {
          "id": "F02.checklist-preview",
          "description": "hovering an item shows a preview with a description, a **Documentation** link and an action button that navigates."
        },
        {
          "id": "F02.checklist-minimize",
          "description": "the card header minimizes and expands the card, and the state survives a reload."
        },
        {
          "id": "F02.checklist-hide",
          "description": "the **Show Getting Started checklist** switch in Settings → Application hides the card (it stays hidden after a reload) and can show it again."
        },
        {
          "id": "F02.checklist-all-complete",
          "description": "the card disappears once all six items are complete. Completion is recomputed from current data, so deleting the automation or the MCP server brings the card back."
        },
        {
          "id": "F02.super-admin-setup-guide",
          "description": "on an OpenHands Enterprise (cloud) backend with `ENABLE_SUPER_ADMIN`, the first Super Admin (`/me` permissions include `manage_super_admins`) sees a floating **Setup guide** panel in the lower-right corner with `n/4` progress, the steps Add an LLM, Choose an automation template, Add an integration, Invite users and Optional: SAML / instance, a `Next: <step>` status and a **Start** link for that step (the positive Enterprise path remains unverified here). Close collapses it to a pill that reopens it. While the guide loads or shows, the **Getting started** card is hidden. Local backends never show it and make no `/api/admin/setup-state` request (#17969)."
        },
        {
          "id": "F02.update-tile",
          "description": "when npm `latest` is newer than the running version, a **New version** tile (`agent-canvas-version-tile`) opens an update modal with the npm and Docker commands. The tile is never shown in locked-Cloud mode."
        },
        {
          "id": "F02.alert-banner",
          "description": "when the server config reports maintenance, faulty models or an error message, a dismissible banner (`alert-banner`) appears at the top of the content area."
        },
        {
          "id": "F02.settings-404-modal",
          "description": "if the backend's settings return 404, the LLM settings modal opens by itself. Other settings fetch errors show a toast."
        },
        {
          "id": "F02.error-toasts",
          "description": "failed requests show a top-right error toast with the product or server message, and the toast closes by itself."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Sidebar links (`F02.nav-links`).",
          "body": "Run `control-openhands browser goto /conversations`, `control-openhands browser click 'testid=sidebar-skills-link'`, then `control-openhands browser wait-url '/mcp$'`. The URL ends in `/mcp` because desktop `/customize` redirects there. Run `control-openhands browser attr 'testid=sidebar-skills-link' class`; the class contains `bg-tertiary` (the active style), and `control-openhands browser attr 'testid=sidebar-skills-link' aria-current` is `page`. Repeat `browser goto` with `/skills`, `/plugins` and `/apps`; each time the class still contains `bg-tertiary` and `aria-current` is still `page`. Run `control-openhands browser click 'testid=sidebar-automations-link'`, `control-openhands browser url` (ends in `/automations`) and `control-openhands browser attr 'testid=sidebar-automations-link' aria-current` (`page`); `control-openhands browser attr 'testid=sidebar-skills-link' aria-current` is now `null`. Then run `control-openhands browser click 'testid=sidebar-conversations-link'` and `control-openhands browser click 'aside[data-collapsed] >> role=link[name=\"OpenHands Logo\"]'`; both leave the URL at `/conversations`.",
          "ids": [
            "F02.nav-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Pin as home (`F02.pin-as-home`).",
          "body": "From `/conversations` run `control-openhands browser visible 'testid=sidebar-pin-home-toggle-customize'` (`false`), `control-openhands browser hover 'testid=sidebar-skills-link'`, the same `visible` (`true`), then `control-openhands browser click 'testid=sidebar-pin-home-toggle-customize'` and `control-openhands browser attr 'testid=sidebar-pin-home-toggle-customize' aria-pressed` (`true`). Run `control-openhands browser goto /` and `control-openhands browser wait-url '/mcp$'`; the URL ends in `/mcp`. Run `control-openhands browser screenshot 'aside[data-collapsed]' --feature F02.pin-as-home --name customize-pinned`; the screenshot shows a filled pin on the Customize row with no hover. Move the pin: run `control-openhands browser hover 'testid=sidebar-automations-link'` and `control-openhands browser click 'testid=sidebar-pin-home-toggle-automations'`. The Customize pin's `aria-pressed` becomes `false`, and `control-openhands browser goto /` lands on `/automations`. Unpin with `control-openhands browser click 'testid=sidebar-pin-home-toggle-automations'`, then run `control-openhands browser goto /` and `control-openhands browser count 'testid=home-screen'`. The URL stays `/` and the count is `1`. `control-openhands browser storage --values` shows `oh:pinned-home-route:<backend>:<org>` as `null`.",
          "ids": [
            "F02.pin-as-home"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Collapse and expand (`F02.sidebar-collapse`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-collapse-toggle'` and `control-openhands browser attr 'aside[data-collapsed]' data-collapsed` (`true`). Run `control-openhands browser reload`; the attribute is still `true`, `browser storage --values` shows `openhands-sidebar` with `\"collapsed\":true`, and `control-openhands browser bbox 'aside[data-collapsed]'` reports width `60`. `control-openhands browser count 'testid=sidebar-onboarding-checklist'` is `0`. For the tooltip, hover one icon, then another: `control-openhands browser hover 'testid=sidebar-automations-link'`, `control-openhands browser hover 'testid=sidebar-skills-link'`, then `control-openhands browser wait 'role=tooltip'` and `control-openhands browser text 'role=tooltip'` (`Customize`). For the backend popover, run `control-openhands browser hover 'testid=collapsed-backend-selector-link'`; its `aria-expanded` is `true`, `browser testids --filter backend` lists `add-backend-menu-item` and `manage-backends-menu-item`, and `control-openhands browser screenshot --feature F02.sidebar-collapse --name backend-popover` shows the popover. Expand by clicking empty rail space with `control-openhands browser click 'aside[data-collapsed]'` (`data-collapsed` becomes `false`). Collapse again, then run `control-openhands browser hover 'aside[data-collapsed] >> role=link[name=\"OpenHands Logo\"]'`; the toggle's class has `opacity-100` and its `aria-label` is `Expand sidebar`. `control-openhands browser click 'testid=sidebar-collapse-toggle'` expands the rail.",
          "ids": [
            "F02.sidebar-collapse"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Settings gear (`F02.settings-link`).",
          "body": "The previous bullet expanded the rail: collapse it with `control-openhands browser click 'testid=sidebar-collapse-toggle'`, then run `control-openhands browser click 'testid=collapsed-settings-link'` and `control-openhands browser wait-url '/settings'`. Expand it again with `control-openhands browser click 'testid=sidebar-collapse-toggle'`, then run `control-openhands browser goto /conversations`, `control-openhands browser click 'testid=backend-selector-settings-link'` and `control-openhands browser url`; the URL ends in `/settings/agents`. `control-openhands browser attr 'testid=backend-selector-settings-link' target` is `null` (same tab).",
          "ids": [
            "F02.settings-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Conversation list (`F02.conversation-list`).",
          "body": "After `conversation start` (preconditions), run `control-openhands browser goto /conversations` and `control-openhands browser testids 'testid=conversation-panel'`. The list includes `conversation-card`.",
          "ids": [
            "F02.conversation-list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Open and close the menu (`F02.command-menu-open-close`).",
          "body": "From `/conversations` run `control-openhands browser text 'testid=command-menu-trigger'`. It reads `Search commands` and `CTRL+K` on Linux and Windows (the hint is `Ctrl+K`, drawn in capitals; macOS and iOS show `⌘K`). Run `control-openhands browser press Control+k`, then `control-openhands browser visible 'testid=command-menu'` (`true`), `control-openhands browser eval \"document.activeElement && document.activeElement.id\"` (`command-menu-search`) and `control-openhands browser text 'testid=command-menu >> css=kbd'` (`CTRL+K`, the header hint). `control-openhands browser screenshot --feature F02.command-menu-open-close --name open` shows the dimmed, blurred backdrop. Run `control-openhands browser press Escape`; `browser count 'testid=command-menu'` is `0`. Run `control-openhands browser press Meta+k` (count `1`), `control-openhands browser type 'testid=command-menu >> role=combobox' secr`, then `control-openhands browser click 'testid=command-menu >> role=button[name=\"Close command menu\"]'` (the backdrop; count `0`). Run `control-openhands browser click 'testid=command-menu-trigger'` and `control-openhands browser value 'testid=command-menu >> role=combobox'`; the value is `\"\"` because the query was cleared on close. Close it with `control-openhands browser press Escape`; focus is back on the row (`control-openhands browser eval \"document.activeElement.dataset.testid\"` is `command-menu-trigger`). Collapsed rail: run `control-openhands browser click 'testid=sidebar-collapse-toggle'`, `control-openhands browser click 'testid=command-menu-trigger'` (the search icon; count `1`), `control-openhands browser press Escape`, then expand with `control-openhands browser click 'aside[data-collapsed]'`.",
          "ids": [
            "F02.command-menu-open-close"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Every item navigates (`F02.command-menu-items`).",
          "body": "The previous bullet closed the menu: run `control-openhands browser press Control+k`, then `control-openhands browser snapshot 'testid=command-menu' --feature F02.command-menu-items --name all-items`; it lists 14 options under Navigation, Settings and Actions, with Model Router after LLM profiles and Agent Context after Condenser settings. Close it with `control-openhands browser press Escape` and rest the pointer off the list with `control-openhands browser hover 'testid=command-menu-trigger'` (see Gotchas). For each item run `control-openhands browser goto /conversations`, `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' '<term>'`, `control-openhands browser press Enter` and `control-openhands browser wait-url '<route>$'`. Expected routes: `New chat` → `/conversations`, `Customize` → `/mcp` (via `/customize`), `Automations` → `/automations`, `MCP servers` → `/mcp`, `Settings overview` → `/settings/agents` (via `/settings`), `Agent settings` → `/settings/agents`, `LLM profiles` → `/settings/llm`, `Model Router` → `/settings/meta-llm`, `Condenser` → `/settings/condenser`, `Agent Context` → `/settings/agent-context`, `Verification` → `/settings/verification`, `Application` → `/settings/app`, `Secrets` → `/settings/secrets`. `browser count 'testid=command-menu'` is `0` after each run. Click path: `control-openhands browser goto /settings/app`, open the menu, type `new chat`, then `control-openhands browser click 'testid=command-menu >> role=option[name=/New chat/]'`; the URL becomes `/conversations`.",
          "ids": [
            "F02.command-menu-items"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Toggle sidebar (`F02.command-menu-toggle-sidebar`).",
          "body": "Run `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' toggle`, `control-openhands browser press Enter`, then `control-openhands browser attr 'aside[data-collapsed]' data-collapsed` (`true`). Repeat with the term `sidebar`; the value returns to `false`.",
          "ids": [
            "F02.command-menu-toggle-sidebar"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Search, empty state, keyboard (`F02.command-menu-search`).",
          "body": "The Toggle sidebar action closed the menu: open it with `control-openhands browser press Control+k`, then run `control-openhands browser fill 'testid=command-menu >> role=combobox' theme` and `control-openhands browser count 'testid=command-menu >> role=option'` (`1`, Application, matched by its keywords). The term `settings secret` leaves `1` option, `secret automations` leaves `0` (all terms must match) and `model` leaves `3` (MCP servers, LLM profiles, Model Router). Fill `qa-zzz-nothing`, then run `control-openhands browser text 'testid=command-menu >> role=listbox'` (`No commands found` / `Try a page, action, or setting name.`) and `control-openhands browser screenshot --feature F02.command-menu-search --name no-results`. Run `control-openhands browser press Enter`; the menu stays open and the URL does not change. Run `control-openhands browser click 'testid=command-menu >> role=button[name=\"Clear search\"]'`; the value is `\"\"` and the count is back to `14`. Keyboard: `control-openhands browser attr 'testid=command-menu >> role=combobox' aria-activedescendant` starts at `command-menu-option-new-chat`. `control-openhands browser press ArrowUp` wraps to `command-menu-option-toggle-sidebar`, and `ArrowDown` returns to `new-chat`, then moves to `customize`. `control-openhands browser hover 'testid=command-menu >> role=option[name=/Secrets settings/]'` sets the value to `command-menu-option-secrets-settings`.",
          "ids": [
            "F02.command-menu-search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Open in a new tab (`F02.command-menu-new-tab`).",
          "body": "With the menu open and `secrets` typed, `control-openhands browser attr 'testid=command-menu >> role=option[name=/Secrets settings/]' href` is `/settings/secrets`. Run `control-openhands browser click 'testid=command-menu >> role=option[name=/Secrets settings/]' --button middle` and `control-openhands browser tabs`; tab 1 is `/settings/secrets`, tab 0 keeps its URL, and `browser count 'testid=command-menu'` is still `1`. Run `control-openhands browser close-tab 1`. The modifier path works the same: `control-openhands browser click 'testid=command-menu >> role=option[name=/Secrets settings/]' --modifiers Control` opens tab 1 with the menu still open; close that tab too. The click leaves focus on the option; `control-openhands browser press Escape` still closes the menu (count `0`).",
          "ids": [
            "F02.command-menu-new-tab"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Keyboard focus (`F02.command-menu-focus`).",
          "body": "From `/conversations` run `control-openhands browser click 'testid=chat-input'`, `control-openhands browser press Control+k`, `browser count 'testid=command-menu'` (`1`) and `control-openhands browser eval \"document.activeElement && document.activeElement.id\"` (`command-menu-search`): the shortcut opens the menu from inside the composer. Run `control-openhands browser press Tab`; the same `eval` returns `command-menu-option-new-chat`. `control-openhands browser press Escape` closes the menu from the option (count `0`), and `control-openhands browser eval \"document.activeElement.dataset.testid\"` returns `chat-input`: focus is back in the composer.",
          "ids": [
            "F02.command-menu-focus"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Phone drawer (`F02.mobile-drawer`).",
          "body": "Run `control-openhands browser viewport phone` and `control-openhands browser goto /conversations`. `control-openhands browser visible 'aside[data-collapsed]'` is `false`. Run `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'` and `control-openhands browser wait 'testid=sidebar-mobile-drawer'`. `control-openhands browser bbox 'testid=sidebar-mobile-drawer'` gives width `300`, `insideViewport` `true` and `pageHorizontalOverflow` `false`, and `control-openhands browser screenshot --feature F02.mobile-drawer --name open` shows the full rail. Close paths, each followed by `control-openhands browser wait 'testid=sidebar-mobile-drawer' --state detached`: `control-openhands browser click 'testid=sidebar-mobile-drawer-close'`; `control-openhands browser press Escape`; `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=sidebar-automations-link'` (route change; the URL becomes `/automations`); and a backdrop click to the right of the drawer, `control-openhands browser mouse-click 350 400` (on `/conversations`, `control-openhands browser click 'testid=submit-button' --force` also lands on the backdrop, because the empty composer's send button sits at x≈326 under it). On a conversation page (`browser goto` the URL from `conversation start`), `control-openhands browser bbox 'testid=sidebar-mobile-menu-toggle'` is at the top-left of the chat header. `control-openhands browser screenshot --feature F02.mobile-drawer --name conversation-header` shows it beside the title, and clicking it opens the drawer. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F02.mobile-drawer"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Back chevron (`F02.mobile-top-bar`).",
          "body": "The Phone drawer bullet returned to desktop: run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/llm` and `control-openhands browser attr 'testid=sidebar-mobile-back-button' aria-label` (`Settings`). Then run `control-openhands browser click 'testid=sidebar-mobile-back-button'` and `control-openhands browser url`; the URL is `/settings`, and on that page `browser count 'testid=sidebar-mobile-back-button'` is `0`. `control-openhands browser screenshot --feature F02.mobile-top-bar --name settings-root` shows only the hamburger above the settings menu. Do the same from `/skills`: the label is `Customize` and the click lands on `/customize`, which on phone is the hub (`extensions-mobile-hub`).",
          "ids": [
            "F02.mobile-top-bar"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Phone command menu (`F02.command-menu-phone`).",
          "body": "At phone width open the drawer, then run `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=command-menu-trigger'`. `control-openhands browser bbox 'testid=command-menu >> role=listbox'` reports `insideViewport` `true` and `pageHorizontalOverflow` `false`, and `control-openhands browser screenshot --feature F02.command-menu-phone --name open` shows it. `control-openhands browser press Escape` closes only the menu: `browser count 'testid=command-menu'` is `0` and `control-openhands browser visible 'testid=sidebar-mobile-drawer'` is still `true`. Reopen it with `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=command-menu-trigger'`, type `secrets` and press Enter; `control-openhands browser wait-url 'secrets$'` passes and `control-openhands browser wait 'testid=sidebar-mobile-drawer' --state detached` passes.",
          "ids": [
            "F02.command-menu-phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Checklist card (`F02.checklist`).",
          "body": "The phone bullets above leave the phone viewport on `/settings/secrets`: run `control-openhands browser viewport desktop`, `control-openhands browser goto /conversations`, then `control-openhands browser count 'testid=sidebar-onboarding-checklist >> role=listitem'` (`6`) and `control-openhands browser text 'testid=sidebar-onboarding-checklist'`. The first lines are `Getting started` and `N complete`. `control-openhands browser snapshot 'aside[data-collapsed]'` shows the six links: `/settings/llm`, `/conversations`, `/automations`, `/settings/agents`, `/mcp` and `https://openhands.dev/joinslack`.",
          "ids": [
            "F02.checklist"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Item links (`F02.checklist-item-links`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-onboarding-checklist-item-schedule-task' --expect-url '/automations(\\?|$)'` and `control-openhands browser click 'testid=sidebar-onboarding-checklist-item-configure-llm' --expect-url '/settings/llm(\\?|$)'`; both pass, `browser tabs` still lists one tab, and `browser count 'testid=sidebar-onboarding-checklist-item-schedule-task >> css=span.line-through'` stays `0` (visiting is not completing).",
          "ids": [
            "F02.checklist-item-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Progress (`F02.checklist-progress`).",
          "body": "Each completed item renders a struck-through label, so `control-openhands browser count 'testid=sidebar-onboarding-checklist-item-<id> >> css=span.line-through'` is `1`. Run `control-openhands browser goto /settings/agents`, then come back; `customize-agent` is done. Run `control-openhands browser click 'testid=sidebar-onboarding-checklist-item-join-slack'`; `join-slack` is done and `browser tabs` shows an external Slack tab, which you close with `control-openhands browser close-tab 1`. After `llm preset deepseek`, run `control-openhands browser reload` (the open page does not refetch settings written through the API), then `control-openhands browser wait 'testid=sidebar-onboarding-checklist-item-configure-llm >> css=span.line-through' --timeout 15000`. After `conversation start`, `start-conversation` is done. The header count rises each time (`4 complete` after these four).",
          "ids": [
            "F02.checklist-progress"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Item preview (`F02.checklist-preview`).",
          "body": "Run `control-openhands browser hover 'testid=sidebar-onboarding-checklist-item-join-slack'`, `control-openhands browser hover 'testid=sidebar-onboarding-checklist-item-connect-mcp'` and `control-openhands browser wait 'testid=sidebar-onboarding-checklist-preview-connect-mcp' --timeout 5000`. Then run `control-openhands browser text 'testid=sidebar-onboarding-checklist-preview-connect-mcp'`; it reads the title, the description, `Documentation` and `Connect`. `control-openhands browser attr 'testid=sidebar-onboarding-checklist-preview-docs-connect-mcp' href` is `https://docs.openhands.dev/overview/model-context-protocol`. Take `control-openhands browser screenshot --feature F02.checklist-preview --name connect-mcp`, then run `control-openhands browser click 'testid=sidebar-onboarding-checklist-preview-action-connect-mcp'` and `control-openhands browser wait-url '/mcp$'`.",
          "ids": [
            "F02.checklist-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Minimize (`F02.checklist-minimize`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-onboarding-checklist-toggle'`, `control-openhands browser attr 'testid=sidebar-onboarding-checklist' data-minimized` (`true`) and `browser count 'testid=sidebar-onboarding-checklist >> role=listitem'` (`0`). After `control-openhands browser reload` the attribute is still `true` and the toggle's `aria-label` is `Expand checklist`. Click the toggle again to restore `false`.",
          "ids": [
            "F02.checklist-minimize"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Hide from settings (`F02.checklist-hide`).",
          "body": "Run `control-openhands browser goto /settings/app` and `control-openhands browser click 'text=Show Getting Started checklist'`; `browser count 'testid=sidebar-onboarding-checklist'` is `0`. After `control-openhands browser reload` the count is still `0`, and `control-openhands browser eval \"document.querySelector('[data-testid=show-getting-started-checklist-switch]').checked\"` is `false`. Click the same text again; the count is `1`.",
          "ids": [
            "F02.checklist-hide"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "All complete (`F02.checklist-all-complete`).",
          "body": "Start with the four items above done (`5 complete` would mean one fixture is left over). Arrange two fixtures: a minimal stdio MCP server, `control-openhands fixture file --name qa-f02-mcp.py --content \"$(printf '%s\\n' 'import sys,json' 'for line in sys.stdin:' '    m=json.loads(line)' '    if \"id\" not in m: continue' '    r={}' '    if m.get(\"method\")==\"initialize\": r={\"protocolVersion\":m[\"params\"].get(\"protocolVersion\",\"2024-11-05\"),\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"qa\",\"version\":\"1\"}}' '    elif m.get(\"method\")==\"tools/list\": r={\"tools\":[]}' '    sys.stdout.write(json.dumps({\"jsonrpc\":\"2.0\",\"id\":m[\"id\"],\"result\":r})+\"\\n\"); sys.stdout.flush()')\"`, and `control-openhands fixture file --name qa-f02.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F02 Done\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":false,\"prompt\":\"Reply with the single word: pong. Do not run any tools.\"}}'`. Add the server in the UI: `control-openhands browser goto /mcp`, `control-openhands browser click 'testid=mcp-add-custom-server'`, `control-openhands browser click 'testid=server-type-dropdown'`, `control-openhands browser click 'role=option[name=\"STDIO\"]'`, `control-openhands browser fill 'testid=name-input' qa_f02_mcp`, `control-openhands browser fill 'testid=command-input' python3`, `control-openhands browser fill 'testid=args-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f02-mcp.py\"`, `control-openhands browser click 'testid=mcp-custom-editor >> testid=submit-button'` and `control-openhands browser wait 'testid=mcp-custom-editor' --state detached`. The header reads `5 complete`. Import the automation: `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-import-automation'`, `control-openhands browser upload 'testid=automations-import-file' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f02.automation.json\"`, `control-openhands browser click 'testid=import-automation-confirm'` and `control-openhands browser wait 'testid=import-automation-modal' --state detached`. Now `browser count 'testid=sidebar-onboarding-checklist'` is `0`, still `0` after `control-openhands browser reload`, and `control-openhands browser screenshot 'aside[data-collapsed]' --feature F02.checklist-all-complete --name rail` shows the rail without the card. Restore and prove it comes back: open the card's kebab with `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F02 Done >> role=button[name=\"Automation actions\"]'`, then `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Delete\"]'`, `control-openhands browser click 'role=heading[name=\"Delete automation\"] >> xpath=.. >> role=button[name=\"Delete\"]'` and `control-openhands browser reload`; the card count is `1` again (`5 complete`). Then `control-openhands browser goto /mcp`, `control-openhands browser click 'testid=mcp-server-detail-qa_f02_mcp'`, `control-openhands browser click 'testid=mcp-custom-editor-delete'`, `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'` and `control-openhands browser reload`; `testid=mcp-installed-empty` is back and the card reads `4 complete`.",
          "ids": [
            "F02.checklist-all-complete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Setup guide (`F02.super-admin-setup-guide`).",
          "body": "Local: `control-openhands browser count 'testid=super-admin-setup-guide'` is `0` at `desktop` and `phone`, `browser count 'testid=sidebar-onboarding-checklist'` is `1`, and `control-openhands browser network --filter setup-state` reports `total` `0`. The positive path is blocked locally: it needs an OHE backend with `ENABLE_SUPER_ADMIN` whose `/api/admin/setup-state` returns a `guide_org_id`, `guide_dismissed` `false` and at least one unfinished required step, signed in as the first Super Admin.",
          "ids": [
            "F02.super-admin-setup-guide"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Update tile (`F02.update-tile`).",
          "body": "The tile is blocked in an up-to-date checkout or without registry access. Check with `control-openhands browser count 'testid=agent-canvas-version-tile'`. When the count is `1`, click it; the modal has `agent-canvas-update-command-npm`, `agent-canvas-update-command-docker` and `agent-canvas-update-check-button`. The manual check runs either way: on `/settings/app` (`control-openhands browser goto /settings/app`; the previous bullet ends on `/mcp`), `testid=agent-canvas-update-badge` reads `Up to date` (or `New version`), `control-openhands browser click 'testid=agent-canvas-update-toggle'` opens `agent-canvas-update-modal`, and `control-openhands browser click 'testid=agent-canvas-update-check-button'` reports the result in `agent-canvas-update-status` (`You're running the latest version. What's new` when current). Close it with `control-openhands browser click 'testid=close-agent-canvas-update-modal'`.",
          "ids": [
            "F02.update-tile"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Error toast (`F02.error-toasts`).",
          "body": "Run `control-openhands browser goto /conversations/00000000-0000-0000-0000-000000000001` and `control-openhands browser wait-text 'This conversation does not exist'`. The URL becomes `/conversations`, and `control-openhands browser screenshot --feature F02.error-toasts --name missing-conversation` shows a top-right error toast. It is the only toast: `control-openhands browser toasts` lists just the \"This conversation does not exist, or you do not have permission to access it…\" text, and `control-openhands browser toasts --history` has no entry containing `this UI does not understand`. `control-openhands browser wait 'text=This conversation does not exist' --state hidden` passes within 30 s.",
          "ids": [
            "F02.error-toasts"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Check for errors.",
          "body": "Run `control-openhands browser errors --app-only` after each group; it reports `pageErrors` `0` and `appErrors` `0`. External `registry.npmjs.org` and `cdn.simpleicons.org` failures are sandbox noise.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Restore.",
          "body": "Leave the sidebar expanded, unpinned, with the checklist shown and expanded, at `browser viewport desktop`.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F03",
      "title": "Home and starting work",
      "file": "F03-home.md",
      "page": "F03-home.html",
      "sha256": "c3b008530d7172126762fb43cd93cd9f399508c1ee6684be5c8f1ea61243cb71",
      "behaviors": [
        {
          "id": "F03.home-entry-points",
          "description": "`/`, `/conversations`, the sidebar **New Chat** and the command-menu item **New chat** show the launcher (`home-chat-launcher`) with the heading, subtitle and the `Describe an engineering task…` composer."
        },
        {
          "id": "F03.llm-not-configured-banner",
          "description": "with no usable LLM, a `role=alert` banner reads `Your LLM isn't set up yet, so conversations won't run. Finish setup to get started.`; **Set up LLM** opens `/settings/llm`; the banner is gone once a profile with a key is active."
        },
        {
          "id": "F03.composer-blocked-without-llm",
          "description": "with no LLM, Send is disabled and Enter creates nothing (the text stays editable)."
        },
        {
          "id": "F03.draft-persistence",
          "description": "the home draft survives a reload and is cleared after a conversation is created."
        },
        {
          "id": "F03.home-model-command",
          "description": "`/model <profile>` sent from home activates that LLM profile without creating a conversation."
        },
        {
          "id": "F03.launch-in-workspace",
          "description": "submitting with a workspace picked shows `Creating conversation…`, opens `/conversations/<id>`, and the agent runs in that folder."
        },
        {
          "id": "F03.launch-without-workspace",
          "description": "with nothing picked, the conversation runs in a fresh per-conversation folder under the server home."
        },
        {
          "id": "F03.create-error-toast",
          "description": "when creation fails, the loading toast is replaced by one error toast and the prompt is not lost."
        },
        {
          "id": "F03.plugin-picker",
          "description": "**Plugins** opens **Attach plugins** (catalog, search, Add/Remove switch, `N selected`, Done and X); the pill then shows a count badge."
        },
        {
          "id": "F03.launch-with-plugin",
          "description": "a conversation started with a plugin attached loads it; the selection resets afterwards."
        },
        {
          "id": "F03.open-workspace-dialog",
          "description": "**Open Workspace** opens a dialog with a workspace combobox; Confirm stays disabled until a pick; confirming swaps the pill for a preview bar that reopens the dialog; X and Escape close it."
        },
        {
          "id": "F03.workspace-dropdown-search",
          "description": "typing filters workspaces; with more than one group, workspaces are grouped by parent with an **Other** group; no match (or no workspaces) shows `No workspaces yet`."
        },
        {
          "id": "F03.selection-after-reload",
          "description": "after a reload the preview bar is gone, but the dialog preselects the last workspace picked in its dropdown, confirmed or not: a pick closed with the X survives leaving for another page and coming back, and a reload."
        },
        {
          "id": "F03.workspace-mode",
          "description": "the preview bar's mode menu switches **Local Repo** / **New Worktree**; the choice persists across reloads."
        },
        {
          "id": "F03.folder-browser",
          "description": "**+ Add Workspace** opens **Add workspaces** at the server home with Favorites/Locations, Up and the current path; **Add this directory** adds and selects the folder, **Add all subdirectories** adds a parent whose children become workspaces, **Cancel** adds nothing."
        },
        {
          "id": "F03.folder-browser-locations",
          "description": "the browser's **Favorites** (Home, plus `workspace` once a no-folder conversation exists) and **Locations** (`/`) jump to that folder; Up is disabled at `/`."
        },
        {
          "id": "F03.manage-workspaces",
          "description": "**Manage Workspaces** lists workspaces and parents (with children); every Remove asks for confirmation; Cancel keeps, Confirm removes for good and clears the selection; with nothing left it reads `You don't have any workspaces yet.`"
        },
        {
          "id": "F03.open-repository-dialog",
          "description": "on a Cloud backend, **Open repository** picks provider, repository and branch (blocked locally)."
        },
        {
          "id": "F03.isolated-workspace-notice",
          "description": "on an isolated-runtime backend a status line explains the new isolated workspace and offers **Clear host workspace** (blocked locally)."
        },
        {
          "id": "F03.workspaces-unsupported",
          "description": "on an Agent Server without workspace APIs the pill is disabled with a tooltip (blocked locally)."
        },
        {
          "id": "F03.recommended-automations-rail",
          "description": "a horizontally scrolling rail of recommended automations with edge fades; cards with a setup form open `/automations/new/<id>`."
        },
        {
          "id": "F03.recommended-responder-choice",
          "description": "GitHub/Slack responder cards on a local backend first ask **Choose how your responder should run**."
        },
        {
          "id": "F03.responder-cloud-option",
          "description": "in that dialog **Open OpenHands Cloud integrations** opens `https://app.all-hands.dev/settings/integrations` in a new tab, closes the dialog and saves no secret; X closes it without a choice."
        },
        {
          "id": "F03.recommended-missing-integration",
          "description": "a card whose required integration is missing opens that integration's install dialog first."
        },
        {
          "id": "F03.recommended-hides-added",
          "description": "a catalog automation that already exists (matched by name, enabled or not) drops off the rail; deleting it brings the card back."
        },
        {
          "id": "F03.recommended-prompt-launch",
          "description": "a card with no setup form and no missing integration creates a conversation that sends the automation prompt (blocked locally)."
        },
        {
          "id": "F03.home-automations-list",
          "description": "**Automations** lists enabled automations with schedule, last run and status, a sparkline, **+ Add**, and `No automations yet. Add one to automate repetitive work.` when empty."
        },
        {
          "id": "F03.home-automation-links",
          "description": "a row opens `/automations/<id>`, a sparkline bar opens `/automations/<id>?run=<runId>`, **+ Add** opens `/automations`."
        },
        {
          "id": "F03.home-run-tooltip",
          "description": "hovering a row shows its trigger, status, last run and task."
        },
        {
          "id": "F03.home-row-menu",
          "description": "the row kebab offers Run now, View, Edit, Turn off and Pin to dashboard; Run now toasts `Automation dispatched` and is disabled while a run is in flight; Edit saves with `Automation updated`."
        },
        {
          "id": "F03.cancel-in-flight-run",
          "description": "**Cancel run** appears while a run is pending or running and cancels it (`Automation run cancelled`)."
        },
        {
          "id": "F03.turn-off-confirmation",
          "description": "Turn off asks `Turn off \"<name>\"? It will stop running on its schedule until you turn it back on.`; Cancel keeps it on, Turn off disables it and drops it from home."
        },
        {
          "id": "F03.pinned-automations-grid",
          "description": "**Pin to dashboard** adds a card to **Pinned automations** (prompt, pills, status strip, Runs); the title opens the detail page; a deleted automation's card disappears."
        },
        {
          "id": "F03.pinned-card-menu",
          "description": "a pinned card's kebab has the same actions with **Unpin from dashboard**."
        },
        {
          "id": "F03.pinned-card-links",
          "description": "a pinned card's sparkline bar opens `/automations/<id>?run=<runId>`, and Enter on its focused title opens the detail page."
        },
        {
          "id": "F03.pinned-reorder-drag",
          "description": "dragging a pinned card's title row (`Reorder <name>`) onto another card reorders the pins, and the order survives a reload."
        },
        {
          "id": "F03.home-automations-view-more",
          "description": "past 10 rows **View more**/**View all**, past 6 pins **View more**/**View less** (not arranged)."
        },
        {
          "id": "F03.automations-backend-down",
          "description": "with the automation service down, the rail, pinned grid and list are hidden and the composer still works."
        },
        {
          "id": "F03.phone",
          "description": "at 390 px the banner, launcher, rail, pinned grid, list and the workspace dialogs fit without horizontal overflow, and folder names stay readable."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "No-LLM banner (`F03.llm-not-configured-banner`).",
          "body": "On a fresh run after `onboard --skip`, run `control-openhands browser text 'testid=home-llm-not-configured-banner'`: `Your LLM isn't set up yet, so conversations won't run. Finish setup to get started.` and `Set up LLM`; `control-openhands browser screenshot --feature F03.llm-not-configured-banner --name banner`. At phone width (`control-openhands browser viewport phone`), `control-openhands browser bbox 'testid=home-llm-not-configured-banner'` is inside the viewport (`F03.phone`; the banner exists only before `llm preset`); return with `control-openhands browser viewport desktop`. `control-openhands browser click 'testid=home-llm-not-configured-action' --expect-url '/settings/llm'` lands on `/settings/llm`. Run the blocked-composer bullet and the draft bullet next (they also need no LLM); only then `control-openhands llm preset deepseek`, `control-openhands browser goto /` and `control-openhands browser count 'testid=home-llm-not-configured-banner'` is `0`.",
          "ids": [
            "F03.llm-not-configured-banner"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Blocked composer (`F03.composer-blocked-without-llm`).",
          "body": "Still without an LLM, run `control-openhands browser goto /` (the banner's action left `/settings/llm`), `control-openhands browser type 'testid=chat-input' 'QA no llm'`, `control-openhands browser enabled 'testid=home-chat-launcher >> testid=submit-button'` (`false`), `control-openhands browser press Enter --selector 'testid=chat-input'`, then `control-openhands browser url` (still `/`) and `control-openhands api GET '/api/conversations/search?limit=5'` (`items` is empty).",
          "ids": [
            "F03.composer-blocked-without-llm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Draft (`F03.draft-persistence`).",
          "body": "With text in the home composer, `control-openhands browser reload` and `control-openhands browser text 'testid=chat-input'` returns the same text; `control-openhands browser storage --session` lists `oh:home-prompt-draft`. That the draft clears after a successful launch is checked in the first launch (Launch without a folder).",
          "ids": [
            "F03.draft-persistence"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Entry points (`F03.home-entry-points`).",
          "body": "`control-openhands browser goto /conversations` and `control-openhands browser count 'testid=home-chat-launcher'` is `1` (same for `/`). From `/settings/secrets` (`control-openhands browser goto /settings/secrets`), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' 'New chat'`, `control-openhands browser snapshot 'testid=command-menu'` (one option `New chat Start a fresh OpenHands conversation. Go` [selected]), `control-openhands browser press Enter`, `control-openhands browser url`: `/conversations`. From `/settings/secrets` (`control-openhands browser goto /settings/secrets` again), `control-openhands browser click 'testid=sidebar-conversations-link' --expect-url '/conversations(\\?|$)'` and `browser count 'testid=home-chat-launcher'` is `1`.",
          "ids": [
            "F03.home-entry-points"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "`/model` on home (`F03.home-model-command`).",
          "body": "`control-openhands api GET /api/profiles` shows `active_profile` `deepseek-flash`. Run `control-openhands browser goto /` (Entry points ended on `/conversations`). The draft from the earlier bullets is still in the composer: clear it with `control-openhands browser fill 'testid=chat-input' ''` (typing would append). Run `control-openhands browser type 'testid=chat-input' '/model deepseek-pro'`, `control-openhands browser press Escape --selector 'testid=chat-input'` (closes the slash menu), `control-openhands browser press Enter --selector 'testid=chat-input'`. The URL stays `/`, `chat-input` is empty, the composer's model pill (`browser text 'testid=chat-input-llm-profile'`) reads `deepseek-pro`, `api GET /api/profiles` shows `active_profile` `deepseek-pro`, and `api GET '/api/conversations/search?limit=20'` lists no new conversation. Restore with the same steps and `/model deepseek-flash`.",
          "ids": [
            "F03.home-model-command"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Launch without a folder (`F03.launch-without-workspace`, `F03.draft-persistence`).",
          "body": "Run `control-openhands browser goto /conversations` (no workspace is picked yet; the pill reads Open Workspace), then fill `chat-input` with `Reply with only the word ok.` and click `testid=home-chat-launcher >> testid=submit-button` with `--expect-url '/conversations/[^/]+$'`. `control-openhands conversation wait <id> --timeout 180` reports `workspace` `<run>/private/home/workspace/project/<id without dashes>`. This is the first successful launch: `control-openhands browser click 'testid=sidebar-conversations-link' --expect-url '/conversations$'` shows an empty `chat-input` and `browser storage --session` no longer lists `oh:home-prompt-draft`.",
          "ids": [
            "F03.launch-without-workspace",
            "F03.draft-persistence"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Plugins (`F03.plugin-picker`).",
          "body": "Run `control-openhands browser goto /conversations` (a fresh page), `control-openhands browser click 'testid=open-plugin-picker'` and `control-openhands browser wait 'testid=plugin-picker-loading' --state hidden --timeout 60000`; cards `plugin-picker-card-<name>` are listed. `control-openhands browser fill 'testid=plugin-picker-search-input' weather` leaves one card (`control-openhands browser count '[data-testid^=\"plugin-picker-card-\"]'` is `1`); `zzz-none` makes `browser text 'testid=plugin-picker'` read `No plugins available.` With `weather` again, `control-openhands browser click 'testid=plugin-picker-toggle-city-weather' --hover-first 150`: the footer reads `1 selected` and `control-openhands browser attr 'testid=plugin-picker-toggle-city-weather' aria-label` is `Remove`. `control-openhands browser click 'testid=plugin-picker-done'`, then `control-openhands browser text 'testid=plugin-picker-count'` is `1`; reopening keeps the switch checked, and `testid=plugin-picker-close` also closes it. On hover-swapping cards, use `--hover-first 150` for subsequent Add/Remove clicks too and assert both the label and `aria-checked`; the shared index gotcha explains why a plain mouse click can be swallowed (including at phone width).",
          "ids": [
            "F03.plugin-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Plugin reaches the agent (`F03.launch-with-plugin`).",
          "body": "With `city-weather` attached, start the no-folder launch above without its `goto` (a page load drops the attached plugin), then `control-openhands api GET '/api/conversations/<id>/events/search?limit=20'`: the system prompt contains `city-weather:now` (a conversation started without the plugin has none). Back on `/conversations` (`control-openhands browser goto /conversations`), `control-openhands browser count 'testid=plugin-picker-count'` is `0`.",
          "ids": [
            "F03.launch-with-plugin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Creation failure (`F03.create-error-toast`).",
          "body": "Fill `chat-input` with `QA error path`, run `control-openhands service stop agent-server`, then clear the observation window with `control-openhands browser toasts --clear`. Start genuine temporal evidence before submitting: `control-openhands browser record start --feature F03.create-error-toast --name failed-launch`. Run `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --observe 'role=status' --observe-ms 500`, then immediately `control-openhands browser screenshot --feature F03.create-error-toast --name toasts` (this early still may show the loading state). Keep recording and wait for the real error with `control-openhands browser wait-text 'HTTP request failed (502 Bad Gateway)' --timeout 15000`, then `control-openhands browser wait 'role=status >> has-text=Creating conversation…' --state hidden --timeout 15000`. `browser toasts` and `browser toasts --history` show `Creating conversation…` replaced by exactly one error toast, such as `HTTP request failed (502 Bad Gateway): \"Bad Gateway: connect ECONNREFUSED 127.0.0.1:<port+1>\"` (transport detail is preserved by design, #17985). Count every new error toast in this window; an additional toast is a failure to record, not one to discard. `browser text 'testid=chat-input'` still reads `QA error path` and `browser enabled 'testid=home-chat-launcher >> testid=submit-button'` is `true`. Stop the recording with `control-openhands browser record stop`; inspect its loading→error transition and recovered prompt, paired with the full screenshot. Bring the stack back with `control-openhands restart` and `control-openhands browser reload`; the prompt remains `QA error path`.",
          "ids": [
            "F03.create-error-toast"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Folder browser (`F03.folder-browser`).",
          "body": "Run `control-openhands browser click 'testid=open-workspace-button'`; before any workspace is picked, `control-openhands browser snapshot 'role=dialog'` shows `Open Workspace`, `combobox \"Select a workspace\"` and `button \"Confirm\" [disabled]` (`F03.open-workspace-dialog`). Run `control-openhands browser click 'testid=workspace-dropdown'`; with no saved workspace, `control-openhands browser text 'testid=workspace-dropdown-empty'` is `No workspaces yet`, above **+ Add Workspace** (`F03.workspace-dropdown-search`; `browser screenshot --feature F03.workspace-dropdown-search --name empty-list`). Run `control-openhands browser click 'testid=add-workspaces-button'`. `control-openhands browser text 'testid=folder-browser-current-path'` is `<run>/private/home`. Run `control-openhands browser click 'testid=folder-browser-up'` twice (now `<run>`) and `control-openhands browser click 'testid=folder-browser-entry-workspace'`; `control-openhands browser text 'testid=folder-browser-list'` lists `qa-f03-folder` and `qa-f03-repo` as `Folder` (`browser screenshot --feature F03.folder-browser --name workspace-dir`). Then, reopening the browser each time with `browser click 'testid=workspace-dropdown'` and `browser click 'testid=add-workspaces-button'` (the Open Workspace dialog stays open behind it; the browser always restarts at `<run>/private/home`, so repeat Up twice and `folder-browser-entry-workspace`):",
          "ids": [
            "F03.folder-browser"
          ],
          "children": [
            "`control-openhands browser click 'testid=folder-browser-cancel'`: `browser count 'testid=folder-browser-modal'` is `0` and the selection is unchanged.",
            "`control-openhands browser click 'testid=folder-browser-add-all-subdirs'`: the browser closes; after `browser click 'testid=workspace-dropdown'` the dropdown offers `qa-f03-folder` and `qa-f03-repo` (`browser snapshot 'role=dialog'`: `option \"qa-f03-folder\"`, `option \"qa-f03-repo\"`, `button \"Manage Workspaces\"`).",
            "`control-openhands browser click 'testid=folder-browser-use'` (Add this directory) at `<run>/workspace`: `control-openhands browser value 'testid=workspace-dropdown'` is `workspace` (auto-selected)."
          ]
        },
        {
          "anchor": "recipe-011",
          "label": "Favorites and locations (`F03.folder-browser-locations`).",
          "body": "With the browser open, `control-openhands browser testids 'testid=folder-browser-sidebar'` lists `folder-browser-sidebar-home`, `folder-browser-sidebar-/` and, after any no-folder conversation, `folder-browser-sidebar-workspace`. `control-openhands browser click 'testid=folder-browser-sidebar-/'` makes `browser text 'testid=folder-browser-current-path'` read `/` and `control-openhands browser enabled 'testid=folder-browser-up'` `false`; `folder-browser-sidebar-workspace` goes to `<run>/private/home/workspace` and `folder-browser-sidebar-home` back to `<run>/private/home`. Close with `testid=folder-browser-cancel`.",
          "ids": [
            "F03.folder-browser-locations"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Open Workspace (`F03.open-workspace-dialog`).",
          "body": "The dialog is still open from the previous bullets, with `workspace` selected (its disabled Confirm before a pick is checked at the start of Folder browser). Run `control-openhands browser click 'testid=workspace-dropdown'`, `control-openhands browser click 'testid=workspace-dropdown-menu >> role=option[name=\"qa-f03-repo\"]'`, `control-openhands browser enabled 'testid=workspace-launch-button'` (`true`), `control-openhands browser click 'testid=workspace-launch-button'`. The dialog closes and `control-openhands browser text 'testid=home-git-control-bar-preview'` is `qa-f03-repo` and `Local Repo` (`browser screenshot --feature F03.open-workspace-dialog --name preview-bar`). `control-openhands browser click 'testid=home-git-control-bar-preview >> role=button[name*=\"qa-f03-repo\"]'` reopens the dialog with `browser value 'testid=workspace-dropdown'` `qa-f03-repo`; `control-openhands browser click 'testid=close-open-workspace-dialog'` closes it, and so does `control-openhands browser press Escape`.",
          "ids": [
            "F03.open-workspace-dialog"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Launch in a folder (`F03.launch-in-workspace`).",
          "body": "Open Workspace (above) left `qa-f03-repo` confirmed in the preview bar. Run `control-openhands browser fill 'testid=chat-input' 'Run pwd and reply with only its output.'` and `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --observe 'text=Creating conversation' --expect-url '/conversations/[^/]+$'`. `observed` shows `Creating conversation…` then `<absent>`; the returned URL holds `<id>`. `control-openhands conversation wait <id> --timeout 180` reports `workspace` `<run>/workspace/qa-f03-repo`, and `control-openhands conversation events <id> --kinds ObservationEvent,MessageEvent` shows the terminal observation and the reply `<run>/workspace/qa-f03-repo`.",
          "ids": [
            "F03.launch-in-workspace"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Search, groups and empty (`F03.workspace-dropdown-search`).",
          "body": "Run `control-openhands browser goto /` (Launch in a folder left its conversation), `control-openhands browser click 'testid=open-workspace-button'` and `control-openhands browser click 'testid=workspace-dropdown'`. With the dropdown open, `control-openhands browser fill 'testid=workspace-dropdown' repo` and `control-openhands browser text 'testid=workspace-dropdown-menu'` is `qa-f03-repo`. With the parent and the standalone `workspace` saved, the menu text is `workspace`, `qa-f03-folder`, `qa-f03-repo`, `Other`, `workspace` and `control-openhands browser count 'testid=workspace-group-header'` is `2` (`--name grouped`). `control-openhands browser fill 'testid=workspace-dropdown' zzz-none` shows the empty state: `control-openhands browser text 'testid=workspace-dropdown-empty'` is `No workspaces yet` (`--name no-match`) and `control-openhands browser count 'testid=add-workspaces-button'` is still `1`. Clear with `control-openhands browser fill 'testid=workspace-dropdown' ''`; `browser count 'testid=workspace-dropdown-empty'` is `0` again.",
          "ids": [
            "F03.workspace-dropdown-search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Reload (`F03.selection-after-reload`).",
          "body": "After confirming qa-f03-repo, `control-openhands browser reload`; `control-openhands browser count 'testid=home-git-control-bar-preview'` is `0` and the pill is back. Click it; `control-openhands browser value 'testid=workspace-dropdown'` is `qa-f03-repo` (sessionStorage `oh:home-selected-workspace-path`). An unconfirmed pick persists the same way: `control-openhands browser click 'testid=workspace-dropdown'`, `control-openhands browser click 'testid=workspace-dropdown-menu >> role=option[name=\"qa-f03-folder\"]'` (value `qa-f03-folder`), then `control-openhands browser click 'testid=close-open-workspace-dialog'` (never Confirm or Launch): the preview count stays `0` and `control-openhands browser storage --session --values` shows `oh:home-selected-workspace-path` ending in `/workspace/qa-f03-folder`. Leave and come back with `control-openhands browser goto /settings` (lands on `/settings/agents`, `browser count 'testid=home-screen'` `0`) and `control-openhands browser goto /conversations`, click `open-workspace-button`: `browser value 'testid=workspace-dropdown'` is `qa-f03-folder` (`browser screenshot --feature F03.selection-after-reload --name unconfirmed-pick-restored`). Close with the X, `control-openhands browser reload`, click the pill again: the value is still `qa-f03-folder` and `browser enabled 'testid=workspace-launch-button'` is `true`. Restore the state the next bullets start from with `browser click 'testid=workspace-dropdown'`, `control-openhands browser click 'testid=workspace-dropdown-menu >> role=option[name=\"qa-f03-repo\"]'` (value `qa-f03-repo`, the storage value ends in `/workspace/qa-f03-repo`) and `control-openhands browser click 'testid=workspace-launch-button'`: the dialog closes and `control-openhands browser text 'testid=home-git-control-bar-preview'` is `qa-f03-repo` and `Local Repo` again.",
          "ids": [
            "F03.selection-after-reload"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Workspace mode (`F03.workspace-mode`).",
          "body": "With the preview bar showing, `control-openhands browser click 'testid=workspace-mode-selector'`, `control-openhands browser snapshot 'testid=workspace-mode-selector-menu'` (`menuitemradio \"Local Repo\" [checked]`, `menuitemradio \"New Worktree\"`), `control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'`. `browser storage --values` shows `openhands-last-local-workspace-mode` `new_worktree`; after `browser reload` and reconfirming the workspace, `browser text 'testid=home-git-control-bar-preview'` reads `qa-f03-repo` and `New Worktree`. Restore with `testid=workspace-mode-selector-option-local_repo`.",
          "ids": [
            "F03.workspace-mode"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Manage (`F03.manage-workspaces`).",
          "body": "With the parent and `workspace` saved: `control-openhands browser click 'testid=workspace-dropdown'`, `control-openhands browser click 'testid=manage-workspaces-button'`; `control-openhands browser testids 'testid=manage-workspaces-modal'` shows `manage-workspaces-row-workspace`, `manage-workspaces-parent-row-workspace`, `manage-workspaces-child-qa-f03-folder` and `manage-workspaces-child-qa-f03-repo`. `control-openhands browser click 'testid=manage-workspaces-remove-workspace'`; `control-openhands browser text 'testid=confirmation-modal'` reads `Remove \"workspace\" from your saved workspaces?`. `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'` keeps the row (`browser count 'testid=manage-workspaces-row-workspace'` is `1`); repeat Remove and `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`: the count is `0`. Removing a workspace clears the dialog's selection only when it was the selected one: with `qa-f03-repo` selected, `control-openhands browser click 'testid=manage-workspaces-done'` leaves `browser value 'testid=workspace-dropdown'` at `qa-f03-repo`. After `browser reload` the dropdown lists only the two children, and `control-openhands api GET /api/workspaces` shows `workspaces: []` plus the parent. Cleanup proves Remove parent: `control-openhands browser click 'testid=manage-workspaces-remove-parent-workspace'` reads `Remove \"workspace\" and its 2 child workspaces?`; Confirm leaves `browser text 'testid=manage-workspaces-list'` at `You don't have any workspaces yet.` and `api GET /api/workspaces` empty; because the selected `qa-f03-repo` belonged to that parent, after `browser click 'testid=manage-workspaces-done'` `browser value 'testid=workspace-dropdown'` is empty and `browser enabled 'testid=workspace-launch-button'` is `false`.",
          "ids": [
            "F03.manage-workspaces"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Recommended rail (`F03.recommended-automations-rail`).",
          "body": "On `/` after `browser reload`, `control-openhands browser attr 'testid=recommended-automations-rail-fade-left' data-visible` is `false` and `...-fade-right` is `true`; `control-openhands browser scroll 'testid=recommended-automation-rail-card-github-agents-md-maintainer'` turns the left fade `true` (`browser screenshot --feature F03.recommended-automations-rail --name scrolled-end`). `control-openhands browser click 'testid=recommended-automation-rail-card-custom-automation' --expect-url 'automations/new'` lands on `/automations/new/custom-automation` (`github-pr-reviewer`, `github-issue-to-pr` and `news-digest` do the same).",
          "ids": [
            "F03.recommended-automations-rail"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Responder choice (`F03.recommended-responder-choice`).",
          "body": "From `/conversations` (`control-openhands browser goto /conversations`; the rail click left `/automations/new/...`), `control-openhands browser click 'testid=recommended-automation-rail-card-github-agents-md-maintainer'`; `control-openhands browser snapshot 'testid=responder-deployment-modal'` shows `Choose how your responder should run`, **Continue with local setup** and **Open OpenHands Cloud integrations** (`slack-channel-monitor` too). `control-openhands browser click 'testid=responder-deployment-continue-local'` saves an `OPENHANDS_URL` secret and opens the GitHub MCP install dialog (`browser snapshot 'role=dialog'`: `dialog \"GitHub\"`, Personal access token); `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` closes it on `/conversations`. Delete `OPENHANDS_URL` afterwards in Settings → Secrets (F14): `control-openhands browser goto /settings/secrets`, `control-openhands browser click 'testid=secret-item >> has-text=OPENHANDS_URL >> testid=delete-secret-button'`, then `control-openhands browser click 'role=dialog >> role=button[name=\"Confirm\"]'`.",
          "ids": [
            "F03.recommended-responder-choice"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Cloud option (`F03.responder-cloud-option`).",
          "body": "From `/conversations` (`control-openhands browser goto /conversations`), `control-openhands browser click 'testid=recommended-automation-rail-card-slack-channel-monitor'`, then `control-openhands browser click 'testid=responder-deployment-open-openhands-cloud'`: `browser count 'testid=responder-deployment-modal'` is `0`, `control-openhands browser tabs` lists a second page `https://app.all-hands.dev/settings/integrations` (it can take a second to appear: rerun `tabs` before concluding) (close it with `control-openhands browser close-tab 1`), and `api GET /api/settings/secrets` has no `OPENHANDS_URL`. Reopen the card and `control-openhands browser click 'testid=responder-deployment-modal-close'`: the dialog is gone and nothing else opens.",
          "ids": [
            "F03.responder-cloud-option"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Missing integration (`F03.recommended-missing-integration`).",
          "body": "`control-openhands browser click 'testid=recommended-automation-rail-card-research-brief-writer'` opens `dialog \"Tavily\"` (Command `npx -y tavily-mcp`, Tavily API key); Cancel closes it and no conversation is created.",
          "ids": [
            "F03.recommended-missing-integration"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Already added (`F03.recommended-hides-added`).",
          "body": "`control-openhands browser count 'testid=recommended-automation-rail-card-news-digest'` is `1`. Import `qa-f03-news.automation.json` (same fixture recipe as Preconditions, with `\"name\":\"Daily news digest\"`; it stays disabled), `browser goto /`, `browser wait 'testid=recommended-automation-rail-card-custom-automation'`: the news-digest count is `0`. Delete it on `/automations` (`control-openhands browser goto /automations`, then the Cleanup recipe below with `has-text=Daily news digest`); back on `/` (`control-openhands browser goto /`) the count is `1` again.",
          "ids": [
            "F03.recommended-hides-added"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Activity list (`F03.home-automations-list`, `F03.home-automation-links`).",
          "body": "`control-openhands browser wait '[data-testid^=\"running-automation-row-\"]'`, `control-openhands browser text 'testid=running-automations-list'`: both fixtures with `0 0 1 1 *` and `No activity yet` (`browser screenshot --feature F03.home-automations-list --name list`); no row has a sparkline link yet (the run-bar link is checked after the first run, in the next bullet). `control-openhands browser click '[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Beta >> role=link' --expect-url '/automations/[^/?]+'` opens the detail page; after `control-openhands browser goto /`, `control-openhands browser click 'testid=home-automations-manage' --expect-url '/automations$'` opens the dashboard.",
          "ids": [
            "F03.home-automations-list",
            "F03.home-automation-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Row menu, Run now and Cancel (`F03.home-row-menu`, `F03.cancel-in-flight-run`, `F03.home-automation-links`).",
          "body": "Run `control-openhands browser goto /` (the Manage link left the dashboard), then `control-openhands browser click '[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Pong >> [data-testid^=\"running-automation-menu-\"]'` and `control-openhands browser snapshot '[data-testid^=\"running-automation-menu-panel-\"]'`: Run now, View, Edit, Turn off, separator, Pin to dashboard. `control-openhands browser click '[data-testid^=\"running-automation-run-\"]' --observe 'role=status'` observes `Automation dispatched`. Reopen the menu: `Run now [disabled]` and **Cancel run**. `control-openhands browser click '[data-testid^=\"running-automation-cancel-\"]'` and `control-openhands browser toasts` reads `Automation run cancelled`; `control-openhands api GET /api/automation/v1/<automation-id>/runs` (id from `browser testids --filter running-automation-row`) shows `CANCELLED`, and after `browser reload` the row reads `Just now · Cancelled`. Pong now has a run: `control-openhands browser click '[data-testid^=\"running-automation-activity-\"] >> role=link' --expect-url '/automations/[^/?]+\\?run='` opens it; return with `control-openhands browser goto /`.",
          "ids": [
            "F03.home-row-menu",
            "F03.cancel-in-flight-run",
            "F03.home-automation-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Tooltip (`F03.home-run-tooltip`).",
          "body": "`control-openhands browser tooltip '[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Pong'` returns `QA_F03 Pong`, `Trigger 0 0 1 1 *`, `Status Cancelled`, `Last run Just now`, `Task Cancelled by user`.",
          "ids": [
            "F03.home-run-tooltip"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "View and Edit (`F03.home-row-menu`).",
          "body": "From the Beta row menu, `control-openhands browser click '[data-testid^=\"running-automation-view-\"]' --expect-url '/automations/[^/?]+$'` opens the detail page. Back on `/` (`control-openhands browser goto /`), reopen the menu and click `[data-testid^=\"running-automation-edit-\"]`: the **Edit automation** modal shows `browser value 'label=Name'` `QA_F03 Beta`. `control-openhands browser fill 'label=Prompt' 'Reply with the single word: beta2. Do not run any tools.'`, `control-openhands browser click 'testid=edit-automation-save' --observe 'role=status'` (`Automation updated`); `api GET /api/automation/v1/<automation-id>` shows the new prompt. A timeout above 1800 is refused client-side with `Timeout cannot exceed 1800 seconds`.",
          "ids": [
            "F03.home-row-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Turn off (`F03.turn-off-confirmation`).",
          "body": "From the Beta row menu click `[data-testid^=\"running-automation-turn-off-\"]`; `control-openhands browser snapshot 'role=dialog'` reads `Turn off \"QA_F03 Beta\"? It will stop running on its schedule until you turn it back on.` with Cancel and Turn off. `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps it enabled; repeat and `control-openhands browser click 'role=dialog >> role=button[name=\"Turn off\"]'`, `browser reload`: `control-openhands browser count '[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Beta'` is `0` and the API shows `enabled: false`. Turn it back on from `/automations` (Preconditions) for the pin checks.",
          "ids": [
            "F03.turn-off-confirmation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Pin (`F03.pinned-automations-grid`).",
          "body": "Run `control-openhands browser goto /` (turning Beta back on left `/automations`); from Pong's row menu click `[data-testid^=\"running-automation-pin-\"]`; `control-openhands browser text 'testid=pinned-automations-dashboard'` shows `Pinned automations`, `QA_F03 Pong`, the prompt, `cron` and the last status; `browser storage --values` holds the id under `oh:home-pinned-automations:<backend>:-`. Pin Beta too; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=pinned-automation-card-]')].map(e=>e.innerText.split('\\n')[0]).join(',')\"` is `QA_F03 Pong,QA_F03 Beta`. `control-openhands browser click '[data-testid^=\"pinned-automation-card-\"] >> role=link[name=\"QA_F03 Pong\"]' --expect-url '/automations/[^/?]+$'` opens the detail page.",
          "ids": [
            "F03.pinned-automations-grid"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Pinned menu (`F03.pinned-card-menu`).",
          "body": "Run `control-openhands browser goto /` (the Pin bullet ends on Pong's detail page), then `control-openhands browser click '[data-testid^=\"pinned-automation-card-\"] >> has-text=QA_F03 Beta >> [data-testid^=\"pinned-automation-menu-\"]'` lists Run now, View, Edit, Turn off and Unpin from dashboard. `[data-testid^=\"pinned-automation-run-\"]` toasts `Automation dispatched`; reopen and `[data-testid^=\"pinned-automation-cancel-\"]` toasts `Automation run cancelled` (the run is `CANCELLED`). Reopen and click `[data-testid^=\"unpin-automation-\"]`; after `browser reload` only `QA_F03 Pong` is pinned and Beta stays in the list.",
          "ids": [
            "F03.pinned-card-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Pinned links (`F03.pinned-card-links`).",
          "body": "With Pong pinned and run once, `control-openhands browser click '[data-testid^=\"pinned-automation-card-\"] >> has-text=QA_F03 Pong >> [data-testid^=\"pinned-automation-activity-\"] >> role=link' --expect-url '/automations/[^/?]+\\?run='` opens the run. Back on `/` (`control-openhands browser goto /`), `control-openhands browser focus '[data-testid^=\"pinned-automation-card-\"] >> role=link[name=\"QA_F03 Beta\"]'`, `control-openhands browser press Enter`, `control-openhands browser wait-url '/automations/[^/?]+$'` opens Beta's detail page (pin Beta again first if it was unpinned).",
          "ids": [
            "F03.pinned-card-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-031",
          "label": "Reorder (`F03.pinned-reorder-drag`).",
          "body": "Run `control-openhands browser goto /` (Pinned links ends on Beta's detail page). With Pong and Beta pinned (order `QA_F03 Pong,QA_F03 Beta`, side by side at desktop), read the Beta handle and Pong card boxes with `control-openhands browser bbox '[data-testid^=\"pinned-automation-card-\"] >> has-text=QA_F03 Beta >> [data-testid^=\"pinned-automation-drag-\"]'` and `control-openhands browser bbox '[data-testid^=\"pinned-automation-card-\"] >> has-text=QA_F03 Pong'`, then drag the handle onto the left fifth of Pong's card: `control-openhands browser drag '[data-testid^=\"pinned-automation-card-\"] >> has-text=QA_F03 Beta >> [data-testid^=\"pinned-automation-drag-\"]' --by -522,0 --steps 20` (at 1440×1000 the handle centre is x≈1065 and Pong's card spans x 486–864). The order eval from the Pin bullet reads `QA_F03 Beta,QA_F03 Pong`, `oh:home-pinned-automations:<backend>:-` lists Beta's id first, and the order survives `browser reload`. Drag Pong's handle the same way to restore. The target form `browser drag <handle> <card> [--position before]` does not reorder (the drop lands before the drag-over position is committed).",
          "ids": [
            "F03.pinned-reorder-drag"
          ],
          "children": []
        },
        {
          "anchor": "recipe-032",
          "label": "Backend down (`F03.automations-backend-down`).",
          "body": "`control-openhands service stop automation`, `control-openhands browser reload`, then `browser count` on `testid=running-automations-list`, `testid=pinned-automations-dashboard` and `testid=recommended-automations-rail`: all `0`, no toast, composer usable. `control-openhands restart` and `browser reload` bring them back.",
          "ids": [
            "F03.automations-backend-down"
          ],
          "children": []
        },
        {
          "anchor": "recipe-033",
          "label": "Phone (`F03.phone`).",
          "body": "`control-openhands browser viewport phone`, `control-openhands browser bbox 'testid=pinned-automations-dashboard'` and `... 'testid=running-automations-list'` (358 px wide, `pageHorizontalOverflow` `false`), `browser screenshot --feature F03.phone --name automations` (the banner was checked at phone width in the No-LLM banner bullet). Open Workspace: `control-openhands browser click 'testid=open-workspace-button'`, then `browser bbox 'testid=open-workspace-dialog-body'` is inside the viewport. Folder browser: arrange a long name first with `control-openhands fixture folder --name qa-f03-folder-with-a-name-too-long-for-a-phone` (it lands in `<run>/workspace`), then `control-openhands browser click 'testid=workspace-dropdown'` and `control-openhands browser click 'testid=add-workspaces-button'`; `control-openhands browser eval \"(()=>{const b=document.querySelector('[data-testid=folder-browser-entry-workspace] span span.truncate');return b.getBoundingClientRect().width})()\"` is greater than `0` (about 67). Run `control-openhands browser click 'testid=folder-browser-up'` twice (now `<run>`) and `control-openhands browser click 'testid=folder-browser-entry-workspace'`, then `control-openhands browser eval \"(()=>{const b=document.querySelector('[data-testid=folder-browser-entry-qa-f03-folder-with-a-name-too-long-for-a-phone] span span.truncate');return [b.scrollWidth>b.clientWidth,getComputedStyle(b).textOverflow]})()\"`: `[true,\"ellipsis\"]` (about 109 of 293 px shown). `control-openhands browser screenshot --feature F03.phone --name folder-names` shows each name beside `Folder` under separate `Name` and `Kind` headers, the long one cut to `qa-f03-folder-w…`. Close with `control-openhands browser click 'testid=folder-browser-cancel'` and `control-openhands browser click 'testid=close-open-workspace-dialog'`, then return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F03.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-034",
          "label": "Cleanup (`F03.pinned-automations-grid`).",
          "body": "Delete both fixtures on `/automations` (`control-openhands browser goto /automations`; F21: kebab `role=list >> has-text=Export >> role=button[name=\"Delete\"]`, then `role=heading[name=\"Delete automation\"] >> xpath=.. >> role=button[name=\"Delete\"]`). On `/` (`control-openhands browser goto /`), `browser count 'testid=pinned-automations-dashboard'` is `0` and `browser text 'testid=running-automations-empty-hint'` is `No automations yet. Add one to automate repetitive work.`",
          "ids": [
            "F03.pinned-automations-grid"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F04",
      "title": "Conversation list and folders",
      "file": "F04-conversation-list.md",
      "page": "F04-conversation-list.html",
      "sha256": "7202144cc08df180126807e15e35d77559388d66d0f8c855ee203e043873df92",
      "behaviors": [
        {
          "id": "F04.empty-state",
          "description": "with no conversations (By date) the list shows `No conversations found`."
        },
        {
          "id": "F04.view-menu",
          "description": "the View menu lists the presets By workspace, Recent, Active and Compact (a fresh profile has Recent checked), Filter by tags (`No tags yet` when empty), More options and Delete all (disabled with no conversations); Escape closes it."
        },
        {
          "id": "F04.new-thread-picker",
          "description": "the folder-plus button lists No workspace, every known workspace, + Add Workspace and Manage Workspaces; picking a row starts a conversation there and opens it."
        },
        {
          "id": "F04.card",
          "description": "a row shows a status dot, the title and the relative time, links to `/conversations/<id>`, and the open conversation's row is highlighted."
        },
        {
          "id": "F04.card-live-status",
          "description": "while the agent runs, its row shows the working indicator; once it finishes the row switches to the check mark and the generated title on the next list refresh."
        },
        {
          "id": "F04.list-header",
          "description": "the header divider appears only while the list is scrolled."
        },
        {
          "id": "F04.organize",
          "description": "More options → Organize switches between folders (By workspace) and a flat list (By date); the choice survives a reload."
        },
        {
          "id": "F04.grouped-folders",
          "description": "By workspace shows one folder per known workspace (empty ones included) plus a No workspace folder while any loaded row has no workspace, in any browser."
        },
        {
          "id": "F04.folder-collapse",
          "description": "a folder heading collapses and expands that folder."
        },
        {
          "id": "F04.collapse-all",
          "description": "the Conversations header collapses every folder, then expands them all."
        },
        {
          "id": "F04.folder-new-conversation",
          "description": "a folder's `+` starts a conversation in that workspace and opens it."
        },
        {
          "id": "F04.folder-preview-more",
          "description": "a folder shows five rows; More shows every loaded row, Less returns to five."
        },
        {
          "id": "F04.folder-reorder",
          "description": "dragging a folder heading onto another reorders the folders and the order persists."
        },
        {
          "id": "F04.thread-scope",
          "description": "More options → Threads → Active only hides paused conversations; All threads shows them again."
        },
        {
          "id": "F04.load-more",
          "description": "20 conversations load first; Load more fetches the rest and disappears when nothing is left."
        },
        {
          "id": "F04.sort",
          "description": "Sort by Updated or Created reorders the list."
        },
        {
          "id": "F04.presets",
          "description": "a preset is checked and survives a reload; Active and Compact hide paused conversations; any manual change reads `More options · Custom`."
        },
        {
          "id": "F04.hide-older",
          "description": "Hide old conversations offers four cutoffs, persists the choice and hides conversations older than it."
        },
        {
          "id": "F04.automation-filter",
          "description": "More options → Automations shows all, hides or keeps only automation runs, with its own empty message."
        },
        {
          "id": "F04.tag-filter",
          "description": "choosing a tag facet narrows the list to rows with that tag (pins exempt) and survives a reload."
        },
        {
          "id": "F04.filter-chips",
          "description": "active filters show as chips above the list; a chip click removes one, Clear all removes every chip."
        },
        {
          "id": "F04.metadata-toggles",
          "description": "Repo and branch, Agent / model and Show tags add or remove row chips."
        },
        {
          "id": "F04.hover-preview",
          "description": "hovering a row for a second shows title, directory, model and creation time, unless Details on hover is off."
        },
        {
          "id": "F04.pin",
          "description": "the pin moves a row into a Pinned section above the list, survives a reload and unpins from there."
        },
        {
          "id": "F04.pinned-preview-more",
          "description": "the Pinned section previews five rows; its More shows every pinned row and Less returns to five."
        },
        {
          "id": "F04.card-rename",
          "description": "⋮ → Rename edits the title inline; Enter saves, a toast confirms, and the title persists."
        },
        {
          "id": "F04.edit-tags",
          "description": "⋮ → Edit tags adds and removes `key: value` tags with validation; Save shows `Tags updated` and the chips persist."
        },
        {
          "id": "F04.edit-tags-remove",
          "description": "in Edit tags each row's remove button drops that tag; Save persists the removal on the server."
        },
        {
          "id": "F04.tag-overflow",
          "description": "tags that do not fit on a row collapse into a `+N` chip (aria `Show N more tags`) whose popover lists the hidden tags without opening the conversation; Escape closes it."
        },
        {
          "id": "F04.stop",
          "description": "⋮ → Stop Conversation asks for confirmation and pauses the conversation; the item is gone once paused."
        },
        {
          "id": "F04.download",
          "description": "⋮ → Download conversation data saves `conversation_<id>.zip`."
        },
        {
          "id": "F04.download-error",
          "description": "a failed download shows one toast that names the cause and closes the ⋮ menu."
        },
        {
          "id": "F04.archive",
          "description": "⋮ → Archive Conversation asks for confirmation, hides and unpins the row, and leaves the conversation if it was open."
        },
        {
          "id": "F04.show-archived-unarchive",
          "description": "Show archived brings archived rows back with an `Archived` chip; Unarchive restores a row without confirmation."
        },
        {
          "id": "F04.delete",
          "description": "⋮ → Delete Conversation asks for confirmation, deletes it on the server and leaves it if it was open."
        },
        {
          "id": "F04.delete-all",
          "description": "View → Delete all asks for confirmation with a count and deletes every conversation."
        },
        {
          "id": "F04.collapsed-rail",
          "description": "the collapsed sidebar rail shows no conversation list; expanding brings it back."
        },
        {
          "id": "F04.phone",
          "description": "at 390 px the list lives in the sidebar drawer, fits the viewport, and picking a row closes the drawer."
        },
        {
          "id": "F04.cloud-picker",
          "description": "on a Cloud backend the folder-plus picker shows provider tabs and a repository search; a picked repository starts a conversation."
        },
        {
          "id": "F04.start-task-cards",
          "description": "on a Cloud backend in-progress start tasks show as cards linking to `/conversations/task-<id>`."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Empty list (`F04.empty-state`).",
          "body": "On a run with no conversations, run `control-openhands browser text 'testid=conversation-panel-empty-state'`. The text is `No conversations found`.",
          "ids": [
            "F04.empty-state"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "View menu (`F04.view-menu`).",
          "body": "Run `control-openhands browser click 'testid=conversation-layouts-toggle'` and `control-openhands browser snapshot 'testid=conversation-layouts-menu'`: radios `By workspace`, `Recent` (`[checked]`), `Active`, `Compact`, items `Filter by tags`, `More options` and `Delete all` (`control-openhands browser attr 'testid=layout-preset-recent-activity' aria-checked` is `true`). With no conversations `control-openhands browser enabled 'testid=delete-all-conversations'` is `false`; after `control-openhands browser click 'testid=tag-filters-section'`, `control-openhands browser text 'testid=tag-filters-empty'` is `No tags yet`. `control-openhands browser press Escape` closes the menu (`browser count 'testid=conversation-layouts-menu'` is `0`).",
          "ids": [
            "F04.view-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Start in a workspace (`F04.new-thread-picker`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser click 'testid=conversation-panel-new-thread-picker'` and `control-openhands browser snapshot 'testid=conversation-panel-new-thread-popover'`: buttons `No workspace`, `qa-f04-folder`, `qa-f04-repo`, `+ Add Workspace`, `Manage Workspaces`. Then `control-openhands browser click 'testid=conversation-panel-new-thread-popover >> testid=launch-workspace >> has-text=qa-f04-repo' --expect-url '/conversations/'` returns `/conversations/<id>`, and `control-openhands conversation list` shows that id with `workspace` ending in `/workspace/qa-f04-repo`. From `/` again, `testid=launch-no-workspace` opens a new conversation whose row sits in the No workspace folder. `control-openhands browser click 'testid=manage-workspaces-button'` opens `testid=manage-workspaces-modal` (F03); Escape closes it.",
          "ids": [
            "F04.new-thread-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Row opens its conversation (`F04.card`).",
          "body": "From `/` (`control-openhands browser goto /`; the previous bullet's launch left a conversation page, where `--expect-url '/conversations/'` would pass at once) run `control-openhands browser click 'testid=conversation-card >> has-text=pong' --expect-url '/conversations/'`, then `control-openhands browser attr 'testid=conversation-card >> has-text=pong' data-active` (`true`; other rows `false`). `control-openhands browser eval \"document.querySelector('[data-testid=conversation-card][data-active=true]').closest('a').getAttribute('href')\"` is `/conversations/<id>?backend=default-local`. The finished conversation shows `testid=conversation-status-check`, idle ones `conversation-status-active` (green), paused ones `conversation-status-paused` (grey). The reopened chat has its transcript back: `control-openhands browser wait 'testid=agent-message'`, then `control-openhands browser text 'testid=agent-message >> nth=-1'` is `pong` and `control-openhands browser text 'testid=user-message >> nth=-1'` is the prompt, with `control-openhands browser count 'testid=error-message-banner'` `0` (`browser screenshot --feature F04.card --name reopened`).",
          "ids": [
            "F04.card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Live status (`F04.card-live-status`).",
          "body": "Run `control-openhands conversation start --prompt \"Reply with only the word ping. Do not run any tools.\"` (no `--wait`; one tiny model call), then `control-openhands browser wait 'testid=conversation-panel >> testid=conversation-status-working' --timeout 20000`: the active row (`Conversation <id5>`) shows the working indicator. After `control-openhands conversation wait <id> --timeout 180` reports `finished`, `control-openhands browser wait 'testid=conversation-panel >> [data-testid=conversation-card][data-active=true] >> testid=conversation-status-check' --timeout 60000` succeeds and `control-openhands browser text 'testid=conversation-panel >> [data-testid=conversation-card][data-active=true] >> testid=conversation-card-title'` is the generated title (it varies: `✅ Reply with only ping`, `✅ Reply only ping, no tools`). The row lags the conversation by up to about 40 s (list refresh).",
          "ids": [
            "F04.card-live-status"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Group by workspace (`F04.grouped-folders`).",
          "body": "Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=layout-preset-by-workspace'`, then `control-openhands browser snapshot 'testid=conversation-panel'`. Regions `No workspace`, `qa-f04-repo` and `qa-f04-folder` each list their own conversations; a workspace without conversations still gets a folder. Second view in another browser: `control-openhands browser reset`, `control-openhands onboard --skip`, choose By workspace again; the workspace conversations must still be in their folders (currently they all fall into No workspace, a known failure).",
          "ids": [
            "F04.grouped-folders"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Collapse one folder (`F04.folder-collapse`).",
          "body": "Run `control-openhands browser click '[data-testid^=\"thread-folder-drag-ws-\"][data-testid$=\"qa-f04-repo\"]'`, `control-openhands browser attr '[data-testid^=\"thread-folder-drag-ws-\"][data-testid$=\"qa-f04-repo\"]' aria-expanded` (`false`) and `control-openhands browser count '[data-testid^=\"thread-folder-ws-\"][data-testid$=\"qa-f04-repo\"] >> testid=conversation-card'` (`0`). Click again: `true` and the cards return.",
          "ids": [
            "F04.folder-collapse"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Collapse all (`F04.collapse-all`).",
          "body": "Run `control-openhands browser click 'testid=conversations-header-toggle'`, `control-openhands browser attr 'testid=conversations-header-toggle' aria-expanded` (`false`) and `control-openhands browser count 'testid=conversation-panel-list-scroll >> testid=conversation-card'` (`0`). Click again: `true` and every card is back.",
          "ids": [
            "F04.collapse-all"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "New conversation in a folder (`F04.folder-new-conversation`).",
          "body": "Run `control-openhands browser goto /`, then `control-openhands browser click 'role=button[name=\"Start new conversation in qa-f04-folder\"]' --expect-url '/conversations/'` (returns the new `/conversations/<id>`), then `control-openhands browser count '[data-testid^=\"thread-folder-ws-\"][data-testid$=\"qa-f04-folder\"] >> testid=conversation-card'`; it grows by one. Repeating `browser goto /` plus the click for `qa-f04-repo` 18 times arranges the data for the next two bullets.",
          "ids": [
            "F04.folder-new-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Folder preview (`F04.folder-preview-more`).",
          "body": "With more than five conversations in `qa-f04-repo`, `control-openhands browser count '[data-testid^=\"thread-folder-ws-\"][data-testid$=\"qa-f04-repo\"] >> testid=conversation-card'` is `5` and `control-openhands browser text '[data-testid^=\"thread-folder-view-more-ws-\"][data-testid$=\"qa-f04-repo\"]'` is `More`. Click that button: every loaded row shows and the label is `Less`; click again: `5`.",
          "ids": [
            "F04.folder-preview-more"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Reorder folders (`F04.folder-reorder`).",
          "body": "In By workspace mode read the order with `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=thread-folder-drag-]')].map(e=>e.textContent.trim())\"`, then run `control-openhands browser drag '[data-testid^=\"thread-folder-drag-ws-\"][data-testid$=\"qa-f04-folder\"]' '[data-testid^=\"thread-folder-drag-ws-\"][data-testid$=\"qa-f04-repo\"]' --position before`. The same eval now starts with `qa-f04-folder`, `qa-f04-repo`, also after `browser reload`, and `control-openhands browser eval \"JSON.parse(localStorage.getItem('conversation-panel-preferences')).state.groupFolderOrder\"` lists the `ws:<path>` keys in that order.",
          "ids": [
            "F04.folder-reorder"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Load more (`F04.load-more`).",
          "body": "In By date mode (More options → `testid=organize-chronological`, or the Recent preset) with 22 or more conversations, `control-openhands browser count 'testid=conversation-panel-list-scroll >> testid=conversation-card'` is `20`. Run `control-openhands browser click 'testid=load-more-conversations'` and `control-openhands browser wait 'testid=load-more-conversations' --state detached --timeout 15000`; the count is the total (`23` with 23 conversations). In By workspace mode (`organize-grouped`, then `browser reload`) the same click adds the later rows to their own folders: with the oldest rows in No workspace and none of them loaded, `testid=thread-folder-__none_workspace` appears only after the click and `browser count 'testid=conversation-card'` grows by the number of unloaded No workspace rows (6 → 9 with three such rows, 5 → 7 with two). A folder that already shows five rows keeps the extra ones behind its More.",
          "ids": [
            "F04.load-more"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Organize (`F04.organize`).",
          "body": "Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=advanced-options-row'`, `control-openhands browser snapshot 'testid=advanced-conversation-options-modal'` (sections Organize, Sort by, Threads, Additional, Automations, Metadata), `control-openhands browser click 'testid=organize-chronological'` (`browser attr 'testid=organize-chronological' aria-checked` is `true`), `control-openhands browser click 'testid=advanced-options-close'`, `control-openhands browser reload`, then `control-openhands browser count '[data-testid^=thread-folder-ws-]'`. The count is `0` and the rows form one list; `organize-grouped` brings the folders back.",
          "ids": [
            "F04.organize"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Header divider (`F04.list-header`).",
          "body": "With more rows than fit (By date mode with 20 loaded rows, as Organize leaves it; By workspace mode previews too few rows to scroll), `control-openhands browser eval \"document.querySelector('[data-testid=older-conversations-summary]').parentElement.classList.contains('border-border')\"` is `false`; after `control-openhands browser scroll 'testid=conversation-panel-list-scroll' --by 300` it is `true`, and `false` again after `--by -300`.",
          "ids": [
            "F04.list-header"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Rename (`F04.card-rename`).",
          "body": "Pick the oldest untitled conversation (`b8d99...` from the picker step; the sort check below needs an old one). It is beyond the first 20 rows after a reload, so first run `control-openhands browser click 'testid=load-more-conversations'` and `control-openhands browser wait 'testid=load-more-conversations' --state detached --timeout 15000`. Then run `control-openhands browser scroll 'testid=conversation-card >> has-text=Conversation <id5>'` (the first five characters of its id), `control-openhands browser hover 'testid=conversation-card >> has-text=Conversation <id5>'`, `control-openhands browser click 'testid=conversation-card >> has-text=Conversation <id5> >> testid=ellipsis-button'`, `control-openhands browser click 'testid=context-menu >> testid=edit-button'`, `control-openhands browser fill 'input[data-testid=conversation-card-title]' 'QA_F04 renamed'`, `control-openhands browser press Enter`, then `control-openhands browser toasts` (`Conversation title updated successfully`), `control-openhands browser reload` and `control-openhands browser count 'testid=conversation-card-title >> has-text=QA_F04 renamed'` (`1`).",
          "ids": [
            "F04.card-rename"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Sort (`F04.sort`).",
          "body": "Under Sort by Updated (default) the just-renamed old conversation is first. Sorting applies to the loaded rows only: after a reload the first page is the 20 most recently updated. In More options run `control-openhands browser click 'testid=sort-created'` and `control-openhands browser click 'testid=advanced-options-close'`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=conversation-panel-list-scroll] [data-testid=conversation-card-title]')].map(e=>e.textContent)\"` now ends with `QA_F04 renamed` (with every page loaded, the older `pong` row comes after it), and the View menu row reads `More options · Custom`. Restore with `testid=layout-preset-recent-activity`.",
          "ids": [
            "F04.sort"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Stop (`F04.stop`).",
          "body": "On an idle row open ⋮ and run `control-openhands browser click 'testid=context-menu >> testid=stop-button'`; `control-openhands browser snapshot 'role=dialog'` reads `Confirm Stop Conversation`, `Are you sure you want to stop this conversation?`. `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps it idle (`control-openhands conversation status <id>`). Repeat and click `'role=dialog >> role=button[name=\"Confirm Close\"]'`; `control-openhands conversation wait <id> --until paused --timeout 30` reaches `paused`, `control-openhands browser wait 'testid=conversation-card >> has-text=Conversation <id5> >> testid=conversation-status-paused'` succeeds, and the reopened ⋮ menu has no Stop item. `control-openhands browser press Escape` closes it (`browser count 'testid=context-menu'` is `0`) and puts focus back on the row's ⋮ (`control-openhands browser eval \"document.activeElement.dataset.testid\"` is `ellipsis-button`).",
          "ids": [
            "F04.stop"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Presets (`F04.presets`).",
          "body": "Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=layout-preset-recent-activity'`, `control-openhands browser reload`, reopen the menu, then `control-openhands browser attr 'testid=layout-preset-recent-activity' aria-checked` (`true`) and `control-openhands browser text 'testid=advanced-options-row'` (`More options`). With the paused conversation from Stop (above), `testid=layout-preset-focused` (Active) and `testid=layout-preset-minimal` (Compact) make `control-openhands browser count 'testid=conversation-card >> has-text=Conversation <id5>'` `0`; Recent shows it again.",
          "ids": [
            "F04.presets"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Threads scope (`F04.thread-scope`).",
          "body": "With the paused conversation from Stop and the Recent preset, open More options and run `control-openhands browser click 'testid=scope-relevant'` (`browser attr 'testid=scope-relevant' aria-checked` is `true`) and `control-openhands browser click 'testid=advanced-options-close'`: `control-openhands browser count 'testid=conversation-card >> has-text=Conversation <id5>'` is `0`, also after `browser reload`, and the View menu now checks no preset and reads `More options · Custom` (Recent shows old conversations; the Active preset also hides them). `testid=scope-all` brings the row back (`1`) and Recent is checked again.",
          "ids": [
            "F04.thread-scope"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Hide older (`F04.hide-older`).",
          "body": "In More options, `control-openhands browser attr 'testid=toggle-older-conversations' aria-checked` is `false` (Recent shows conversations of every age) and `control-openhands browser text 'testid=older-conversation-cutoff'` is `Over 1 week`. Run `control-openhands browser click 'testid=older-conversation-cutoff'` (radios Over 1 hour, Over 1 day, Over 1 week, Over 30 days), `control-openhands browser click 'role=menuitemradio[name=\"Over 1 hour\"]'`, then `control-openhands browser click 'testid=toggle-older-conversations >> text=Hide old conversations'` (`aria-checked` turns `true`; a click on the row's center lands on the cutoff dropdown instead), `control-openhands browser click 'testid=advanced-options-close'`, `control-openhands browser reload`, then `control-openhands browser storage --values`: `conversation-panel-preferences` holds `\"showOlderConversations\":false` and `\"olderConversationCutoff\":\"1h\"`, and the View menu reads `More options · Custom`. Restore with `testid=layout-preset-recent-activity` (it turns hiding off but keeps the cutoff) and the cutoff `Over 1 week`. Hiding itself needs a conversation last updated before the cutoff (not reachable on a fresh run).",
          "ids": [
            "F04.hide-older"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Automation filter (`F04.automation-filter`).",
          "body": "In More options run `control-openhands browser click 'testid=automation-filter-only'` and `control-openhands browser click 'testid=advanced-options-close'`; with no automation runs `control-openhands browser text 'testid=conversation-panel-empty-state'` is `No conversations match the automation filter` (Load more stays). `testid=automation-filter-hide` keeps every manual conversation; `testid=automation-filter-all` restores the default. Unpin everything first: a pinned row suppresses the empty state.",
          "ids": [
            "F04.automation-filter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Row metadata (`F04.metadata-toggles`).",
          "body": "In More options click `testid=toggle-repo-branch-metadata`, `testid=toggle-llm-profiles` and `testid=toggle-tags-metadata`, then `testid=advanced-options-close`. `control-openhands browser testids 'testid=conversation-card >> has-text=Conversation <id5>'` lists `conversation-card-workspace-folder` (`qa-f04-repo`) and `conversation-card-agent-chip` (`deepseek-flash`) and no `conversation-card-tag-chip`. Restore with `testid=layout-preset-recent-activity` (presets reset all metadata toggles).",
          "ids": [
            "F04.metadata-toggles"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Hover preview (`F04.hover-preview`).",
          "body": "Run `control-openhands browser mouse-click 900 900`, then `control-openhands browser tooltip 'testid=conversation-card >> has-text=Conversation <id5>'`. The text is the title, `Directory` with the workspace path, `Model` `deepseek/deepseek-flash` and `Created` with a date. After `testid=toggle-hover-metadata` is off (`aria-checked` `false`), `control-openhands browser tooltip ... --timeout 5000` finds no tooltip.",
          "ids": [
            "F04.hover-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Pin (`F04.pin`).",
          "body": "Run `control-openhands browser hover 'testid=conversation-card >> has-text=QA_F04 renamed'`, `control-openhands browser click 'testid=conversation-card >> has-text=QA_F04 renamed >> [data-testid^=conversation-pin-toggle-]'`, then `control-openhands browser text 'testid=conversation-panel-pinned-section'` (`Pinned` and the row), `control-openhands browser count 'testid=conversation-card >> has-text=QA_F04 renamed'` (`1`: not duplicated) and, after `browser reload`, `control-openhands browser attr 'testid=conversation-panel-pinned-section >> [data-testid^=conversation-pin-toggle-]' aria-pressed` (`true`). Unpin with `browser hover` and `browser click` on `testid=conversation-panel-pinned-section >> testid=conversation-card` and its `[data-testid^=conversation-pin-toggle-]`; after a reload `browser count 'testid=conversation-panel-pinned-section'` is `0`.",
          "ids": [
            "F04.pin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Pinned preview (`F04.pinned-preview-more`).",
          "body": "Pin six rows (`browser scroll`, `browser hover`, then click each row's `[data-testid^=conversation-pin-toggle-]`). `control-openhands browser count 'testid=conversation-panel-pinned-section >> testid=conversation-card'` is `5` and `control-openhands browser text 'testid=conversation-panel-pinned-view-more'` is `More`; `control-openhands browser click 'testid=conversation-panel-pinned-view-more'` makes the count `6` and the label `Less`; click again: `5`. Unpin all six with `browser hover` and a pin click on `'testid=conversation-panel-pinned-section >> testid=conversation-card >> nth=0'`, repeated.",
          "ids": [
            "F04.pinned-preview-more"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Edit tags (`F04.edit-tags`).",
          "body": "Open ⋮ on a row (hover, `testid=ellipsis-button`) and run `control-openhands browser click 'testid=context-menu >> testid=edit-tags-button'`. Then: `control-openhands browser fill 'testid=new-tag-key-input' 'Bad Key'` and `control-openhands browser press Enter --selector 'testid=new-tag-key-input'` → `control-openhands browser text 'testid=edit-tags-error'` is `Tag names may only contain lowercase letters and numbers`; key `title` with `testid=add-tag-button` → `This tag name is reserved`; key `qaenv`, `browser fill 'testid=new-tag-value-input' one`, Enter → `browser count 'testid=edit-tag-row-qaenv'` is `1`; key `qaenv` again → `This tag name is already in use`; key `qalong` with a 257-character value → `Tag values must be 256 characters or fewer`; key `qabare` with an empty value left in the inputs, then `control-openhands browser click 'testid=edit-conversation-tags-modal >> testid=confirm-button'`. `control-openhands browser toasts` shows `Tags updated`; after `browser reload` the row shows chips `Qabare` and `Qaenv: one` (`conversation-card-tag-chip`), and `control-openhands api GET '/api/conversations/search?limit=3'` has tags `qaenv: one`, `qabare: \"\"`.",
          "ids": [
            "F04.edit-tags"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Tag overflow (`F04.tag-overflow`).",
          "body": "On the tagged row add three more tags in Edit tags (keys `qaregion`, `qastage`, `qaowner`, values `value-for-<key>`, Enter after each) and save. After `browser reload` the row shows `Qabare` and `Qaenv: one` plus `control-openhands browser text 'testid=conversation-card >> has-text=Conversation <id5> >> testid=conversation-card-tag-overflow'` `+3` (aria-label `Show 3 more tags`). Clicking it keeps the URL, sets `aria-expanded` `true`, and `control-openhands browser text 'testid=conversation-card-tag-overflow-popover'` lists `Qaowner value-for-qaowner`, `Qaregion …`, `Qastage …` (3 `conversation-card-tag-overflow-row`); `control-openhands browser press Escape` closes it (count `0`).",
          "ids": [
            "F04.tag-overflow"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Remove tags (`F04.edit-tags-remove`).",
          "body": "Reopen Edit tags on that row and run `control-openhands browser click 'testid=remove-tag-qaregion'` (likewise `remove-tag-qastage`, `remove-tag-qaowner`); `control-openhands browser count 'testid=edit-tags-rows >> [data-testid^=edit-tag-row-]'` is `2`. Save with `testid=edit-conversation-tags-modal >> testid=confirm-button` (`Tags updated`); after `browser reload` the `+3` chip is gone and `control-openhands api GET '/api/conversations/search?limit=1' --pick items.0.tags` is `clientsource`, `qabare`, `qaenv` only.",
          "ids": [
            "F04.edit-tags-remove"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Tag filter (`F04.tag-filter`).",
          "body": "After Edit tags (above) gave one row `qaenv=one`, run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=tag-filters-section'`, `control-openhands browser click 'testid=tag-facet-row-qaenv=one'` (`aria-checked` `true`; the menu stays open), `control-openhands browser press Escape`, then `control-openhands browser count 'testid=conversation-panel-list-scroll >> testid=conversation-card'`: only the tagged row plus pinned rows. The count is the same after `browser reload`.",
          "ids": [
            "F04.tag-filter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Filter chips (`F04.filter-chips`).",
          "body": "With the facet active, `control-openhands browser text 'testid=conversation-active-tag-filters'` is `qaenv=one` and `Clear all`. `control-openhands browser click 'testid=active-tag-filter-qaenv=one'` removes it (`browser count 'testid=conversation-active-tag-filters'` is `0`). Select `tag-facet-row-qaenv=one` and `tag-facet-row-qabare=` again, then `control-openhands browser click 'testid=clear-tag-filters'`: the strip disappears and the full list returns.",
          "ids": [
            "F04.filter-chips"
          ],
          "children": []
        },
        {
          "anchor": "recipe-031",
          "label": "Download (`F04.download`).",
          "body": "Open ⋮ and run `control-openhands browser click 'testid=context-menu >> testid=download-trajectory-button'`, then `control-openhands browser downloads`. A file `…-conversation_<id>.zip` lands in `<run>/private/downloads`; `control-openhands browser downloads --last 1 --inspect` lists `<id-hex>/base_state.json` and `meta.json` for any row, plus event folders such as `bash_events/` for a conversation that ran (the `pong` row; it is the oldest, so Load more first).",
          "ids": [
            "F04.download"
          ],
          "children": []
        },
        {
          "anchor": "recipe-032",
          "label": "Download failure (`F04.download-error`).",
          "body": "With ⋮ already open on a row, run `control-openhands browser errors --clear` and `control-openhands service stop agent-server`, click `testid=context-menu >> testid=download-trajectory-button`, then `control-openhands browser toasts --history` and `control-openhands browser count 'testid=context-menu'`. There is exactly one toast, `Couldn't download the conversation data: <cause>` (with the server down the cause is the transport text `HTTP request failed (502 Bad Gateway): \"Bad Gateway: connect ECONNREFUSED 127.0.0.1:<port>\"`), and the menu is closed (`0`). `control-openhands browser errors --app-only` has `pageErrors` `0`; its 502 request and console error are the outage itself. Run `control-openhands restart` and `control-openhands doctor` afterwards.",
          "ids": [
            "F04.download-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-033",
          "label": "Archive (`F04.archive`).",
          "body": "Open a conversation from its row, open ⋮ and run `control-openhands browser click 'testid=context-menu >> testid=archive-button'`; `control-openhands browser snapshot 'role=dialog'` reads `Archive conversation` and `Archive \"<title>\"? The conversation stays available with its full history and can be restored from Show archived.` `control-openhands browser click 'role=dialog >> testid=cancel-button'` keeps the row. Repeat and `control-openhands browser click 'role=dialog >> testid=confirm-button' --expect-url '/conversations(\\?|$)'`; the row count is `0`, also after `browser reload`. Archiving a pinned row removes the Pinned section.",
          "ids": [
            "F04.archive"
          ],
          "children": []
        },
        {
          "anchor": "recipe-034",
          "label": "Show archived and Unarchive (`F04.show-archived-unarchive`).",
          "body": "In More options click `testid=toggle-show-archived` (`aria-checked` `true`) and `testid=advanced-options-close`; `control-openhands browser count 'testid=conversation-card >> has-text=Conversation <id5> >> testid=conversation-card-archived-chip'` is `1` and the ⋮ menu offers `Unarchive Conversation`. `control-openhands browser click 'testid=context-menu >> testid=unarchive-button'` opens no dialog (`browser count 'role=dialog'` `0`) and the chip count drops to `0`. Turn Show archived off again.",
          "ids": [
            "F04.show-archived-unarchive"
          ],
          "children": []
        },
        {
          "anchor": "recipe-035",
          "label": "Delete (`F04.delete`).",
          "body": "Open a conversation from its row, open ⋮ and run `control-openhands browser click 'testid=context-menu >> testid=delete-button'`; the dialog reads `Confirm Delete` and `Are you sure you want to delete the \"<title>\" conversation? This action cannot be undone.` `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps the row. Repeat and `control-openhands browser click 'role=dialog >> role=button[name=\"Confirm Delete\"]' --expect-url '/conversations(\\?|$)'`; after `browser reload` the row count is `0` and `control-openhands api GET /api/conversations/<id>` is `404`.",
          "ids": [
            "F04.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-036",
          "label": "Phone (`F04.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /`, `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'`, then `control-openhands browser bbox 'testid=sidebar-mobile-drawer >> testid=conversation-panel'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F04.phone --name drawer`. Hover a row in the drawer and click its `testid=ellipsis-button`; `control-openhands browser bbox 'testid=context-menu'` is inside the viewport. `control-openhands browser mouse-click 380 20` closes the menu (`browser count 'testid=context-menu'` `0`) and the drawer (`control-openhands browser wait 'testid=sidebar-mobile-drawer' --state hidden --timeout 5000`; an immediate `visible` still reads `true` during the slide-out). Reopen the drawer and `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=conversation-card >> has-text=QA_F04 renamed' --expect-url '/conversations/'`; `control-openhands browser wait 'testid=sidebar-mobile-drawer' --state hidden --timeout 5000` succeeds. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F04.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-037",
          "label": "Delete all (`F04.delete-all`).",
          "body": "Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=delete-all-conversations'` and `control-openhands browser snapshot 'role=dialog'`: `Delete all conversations`, `Are you sure you want to delete N conversations?`. Cancel keeps every row. Confirm with `control-openhands browser click 'role=dialog >> role=button[name=\"Confirm Delete\"]'` and `control-openhands browser wait 'role=dialog' --state detached --timeout 30000`; `control-openhands api GET '/api/conversations/search?limit=100'` has no items, Delete all is disabled again, and in By date mode the empty state returns. Expected for every conversation; today N and the deletion cover only the loaded pages (22 conversations, 20 loaded: 2 remain).",
          "ids": [
            "F04.delete-all"
          ],
          "children": []
        },
        {
          "anchor": "recipe-038",
          "label": "Collapsed rail (`F04.collapsed-rail`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-collapse-toggle'`, then `control-openhands browser count 'testid=conversation-panel'` (`0`). Expand with `control-openhands browser click 'aside[data-collapsed]'` (see F02); the count is `1`.",
          "ids": [
            "F04.collapsed-rail"
          ],
          "children": []
        },
        {
          "anchor": "recipe-039",
          "label": "Cloud picker and start tasks (`F04.cloud-picker`, `F04.start-task-cards`).",
          "body": "Blocked without an OpenHands Cloud backend and git provider. On Cloud, the same `conversation-panel-new-thread-picker` opens `cloud-provider-tabs`, `cloud-repo-search-input` (`Search repositories`) and `launch-repository` rows; start tasks render `testid=start-task-card` linking to `/conversations/task-<id>`.",
          "ids": [
            "F04.cloud-picker",
            "F04.start-task-cards"
          ],
          "children": []
        },
        {
          "anchor": "recipe-040",
          "label": "Cleanup.",
          "body": "View → Delete all twice (the second pass removes the rows the first left behind), until `api GET '/api/conversations/search?limit=100'` is empty; re-apply the Recent preset. Fixture folders and the workspace registry vanish with the run.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F05",
      "title": "Composer, slash commands and plan mode",
      "file": "F05-composer.md",
      "page": "F05-composer.html",
      "sha256": "07f13fe130eff6f234cda2e31a0fcf8ae8c712623d2d40a8903dd7f35d317430",
      "behaviors": [
        {
          "id": "F05.send-message",
          "description": "Send is disabled while the field is empty; Shift+Enter inserts a newline; Enter sends, clears the field and the message persists after reload."
        },
        {
          "id": "F05.draft-persistence",
          "description": "unsent text is restored per conversation (after navigation and reload) and on the home composer."
        },
        {
          "id": "F05.composer-resize",
          "description": "the field grows with its content and shrinks when cleared; a top-edge grip lets the user drag the height."
        },
        {
          "id": "F05.plus-menu",
          "description": "`+` opens a tools menu (Macros, Show skills/hooks/agent tools, Add Files and Images; Git tools in a repository); a macro fills the composer without sending."
        },
        {
          "id": "F05.agent-profile-switch",
          "description": "before a conversation starts, `+` > Switch agent profile lists agent profiles and links to Manage agent profiles; it is absent in a started conversation."
        },
        {
          "id": "F05.attach-files",
          "description": "picked files show as chips and images as thumbnails (with an Upload-as-file toggle), each removable; sent files land in the workspace and are named in the message."
        },
        {
          "id": "F05.attach-size-limit",
          "description": "a file over 3 MB, or a selection over 3 MB in total, is refused with an error toast."
        },
        {
          "id": "F05.image-only-send",
          "description": "an attached image alone, with an empty field, enables Send; the sent user message shows the thumbnail and carries the picture as an embedded data URL."
        },
        {
          "id": "F05.paste",
          "description": "pasted clipboard text is inserted as plain text; pasted images and dropped files attach."
        },
        {
          "id": "F05.dictation",
          "description": "the mic button dictates into the composer; without a microphone it toasts a failure and resets."
        },
        {
          "id": "F05.llm-profile-picker",
          "description": "the model pill switches the running conversation's LLM profile; the next reply uses the new model."
        },
        {
          "id": "F05.profile-identity",
          "description": "the pill names the profile the conversation was started with, also after a reload and when another profile uses the same model; changing the default profile in Settings changes the home pill, not an existing conversation's."
        },
        {
          "id": "F05.overflow-menu",
          "description": "at very narrow widths the model pill collapses into a \"More input actions\" menu whose Model submenu fits the viewport and switches the profile."
        },
        {
          "id": "F05.context-window-meter",
          "description": "the ring shows context use; its popover opens the Usage tab and compacts the context."
        },
        {
          "id": "F05.stop-resume",
          "description": "Stop pauses a running agent (status `Stopped`, Play button); Play resumes it."
        },
        {
          "id": "F05.queued-message",
          "description": "a follow-up typed while the agent runs is accepted and answered."
        },
        {
          "id": "F05.slash-menu",
          "description": "`/` opens a filtered command list (built-ins and skills) with arrow, Tab/Enter and Escape handling."
        },
        {
          "id": "F05.slash-model",
          "description": "`/model` lists saved profiles; `/model <name>` switches profile."
        },
        {
          "id": "F05.slash-btw",
          "description": "`/btw <question>` answers a side question in a dismissible card without adding to the main stream; a bare `/btw` toasts that a question is needed."
        },
        {
          "id": "F05.slash-goal",
          "description": "`/goal [--max N] <objective>` runs a judged goal loop with a status banner, Stop and Resume; only the newest goal status offers Resume."
        },
        {
          "id": "F05.slash-plan-code",
          "description": "`/plan` switches to plan mode (a planner sub-conversation), `/code` switches back."
        },
        {
          "id": "F05.slash-plan-code-task",
          "description": "`/code <task>` switches to code mode and sends `<task>` to the code agent at once; `/plan <task>` switches to plan mode and sends `<task>` to the existing planner."
        },
        {
          "id": "F05.plan-preview",
          "description": "the planner's Plan.md card shows the plan with Read more, View (Planner tab) and Build."
        },
        {
          "id": "F05.build-plan-shortcut",
          "description": "Ctrl/Cmd+Enter in plan mode builds the plan with the code agent."
        },
        {
          "id": "F05.phone",
          "description": "the composer fits a 390 px viewport with every control inline."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Type and send (`F05.send-message`).",
          "body": "On `/conversations/<id>` run `control-openhands browser attr 'testid=submit-button' aria-label` (`Send message`), `control-openhands browser enabled 'testid=submit-button'` (`false`), `control-openhands browser type 'testid=chat-input' 'Reply with only:'`, `control-openhands browser enabled 'testid=submit-button'` (`true`), `control-openhands browser press Shift+Enter`, `control-openhands browser type 'testid=chat-input' 'ok2'` and `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"`: `Reply with only:\\nok2`. Run `control-openhands browser press Enter --selector 'testid=chat-input'`; the field is empty and `control-openhands browser count 'testid=stop-button'` is `1` while the agent runs. Then `control-openhands conversation wait <id> --fresh --timeout 180` (a fast model can finish before a separate `--until running` wait starts, which then times out on `finished`), `control-openhands browser reload` and `control-openhands browser count 'testid=user-message >> has-text=ok2'`: `1`; `control-openhands browser text 'testid=agent-message >> nth=-1'` is `ok2`.",
          "ids": [
            "F05.send-message"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Draft survives navigation (`F05.draft-persistence`).",
          "body": "Run `control-openhands browser type 'testid=chat-input' 'QA draft text'` and wait out the 500 ms save: `control-openhands browser eval \"new Promise(r=>setTimeout(()=>r(JSON.parse(localStorage.getItem('conversation-state-<id>')).draftMessage),1500))\"` returns `QA draft text`. Then `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url settings`, `control-openhands browser back` and the `innerText` eval above: `QA draft text`. `control-openhands browser reload` restores it again. Clear with `control-openhands browser fill 'testid=chat-input' ''` and wait a second before navigating.",
          "ids": [
            "F05.draft-persistence"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Home draft (`F05.draft-persistence`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser type 'testid=chat-input' 'QA home draft'`, `control-openhands browser goto /settings/secrets`, `control-openhands browser goto /`, then the `innerText` eval: `QA home draft`; `control-openhands browser storage --session` lists `oh:home-prompt-draft`. Clear with `control-openhands browser fill 'testid=chat-input' ''`.",
          "ids": [
            "F05.draft-persistence"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Auto-resize (`F05.composer-resize`).",
          "body": "On `/conversations/<id>` (`control-openhands browser goto /conversations/<id>`; Home draft left `/`) type `line`, then eight times `control-openhands browser press Shift+Enter` and `control-openhands browser type 'testid=chat-input' 'lineN'` (`line2`…`line9`): `control-openhands browser bbox 'testid=chat-input'` grows by 20 px per line, from `height` `20` to `180`; after `control-openhands browser fill 'testid=chat-input' ''` it is `20`. `control-openhands browser count '#resize-grip'` is `1`. `control-openhands browser drag '#resize-grip' --by 0,-200` makes the empty field `220` high and `control-openhands browser drag '#resize-grip' --by 0,150` brings it to `70`; after `control-openhands browser reload` it is `20` again (the dragged height is not kept).",
          "ids": [
            "F05.composer-resize"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Tools menu and macros (`F05.plus-menu`).",
          "body": "On `/conversations/<id>` run `control-openhands browser click 'testid=chat-plus-button'` and `control-openhands browser testids 'testid=tools-context-menu'`: `macros-button`, `show-skills-button`, `show-hooks-button`, `show-agent-tools-button`, `add-files-and-images-button` (no `git-tools-button` outside a repository). `control-openhands browser hover 'testid=macros-button'` shows `macros-submenu` with `increase-test-coverage-button`, `fix-readme-button`, `auto-merge-prs-button`, `clean-dependencies-button`. `control-openhands browser click 'testid=fix-readme-button'` closes the menu and fills the composer with `Please look at the README and make the following improvements…`; the `testid=user-message` count is unchanged. Clear with `control-openhands browser fill 'testid=chat-input' ''`. Open the menu again and `control-openhands browser press Escape`: `browser count 'testid=tools-context-menu'` is `0` and `control-openhands browser eval \"document.activeElement.dataset.testid\"` is `chat-plus-button`.",
          "ids": [
            "F05.plus-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Show dialogs (`F05.plus-menu`).",
          "body": "For each of `show-skills-button`, `show-hooks-button`, `show-agent-tools-button`: `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser click 'testid=show-hooks-button'` (etc.), `control-openhands browser text 'role=dialog'` (starts `Available Skills`, `Available Hooks`, `Agent Tools & Metadata`), then `control-openhands browser press Escape` and `control-openhands browser count 'role=dialog'`: `0`.",
          "ids": [
            "F05.plus-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Agent profile before start (`F05.agent-profile-switch`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser hover 'testid=switch-agent-profile-button'` and `control-openhands browser text 'testid=agent-profile-submenu'`: `AVAILABLE PROFILES`, `default` (checked; `chat-input-agent-profile-option-default`), `Manage agent profiles`. `control-openhands browser click 'testid=agent-profile-submenu >> text=Manage agent profiles' --expect-url 'settings/agents'` lands on `/settings/agents`. In a started conversation (`control-openhands browser goto /conversations/<id>`) `control-openhands browser click 'testid=chat-plus-button'` then `control-openhands browser count 'testid=switch-agent-profile-button'` is `0`.",
          "ids": [
            "F05.agent-profile-switch"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Attach a file and an image (`F05.attach-files`).",
          "body": "On `/conversations/<id>` run `control-openhands browser upload 'testid=upload-image-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-image.png\" \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\"` and `control-openhands browser screenshot 'testid=interactive-chat-box' --feature F05.attach-files --name attached`: a `qa-note.txt TXT` chip and a thumbnail. `control-openhands browser count 'testid=interactive-chat-box >> alt=qa-image.png'` is `1`. `control-openhands browser click 'role=button[name=\"Upload as file\"]'` turns the image toggle into `Do not upload as file` (`control-openhands browser attr 'role=button[name=\"Do not upload as file\"]' aria-pressed` is `true`); click it again to restore. The remove buttons are named: `control-openhands browser snapshot 'testid=interactive-chat-box'` lists `button \"Remove qa-note.txt\"` and `button \"Remove image\"`. Remove the image with `control-openhands browser click 'testid=interactive-chat-box >> role=button[name=\"Remove image\"]'`; the `alt=qa-image.png` count is `0`. Then `control-openhands browser type 'testid=chat-input' 'Reply with only the content of the attached file.'`, `control-openhands browser press Enter --selector 'testid=chat-input'`, wait as in Type and send, and `control-openhands browser text 'testid=user-message >> nth=-1'`: it ends `NEW FILES ADDED: qa-note.txt`. `control-openhands conversation events <id> --kinds MessageEvent --last 3` shows the reply `QA attachment 7731`, and `qa-note.txt` sits in the conversation `workspace` printed by `control-openhands conversation status <id>`.",
          "ids": [
            "F05.attach-files"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Size limit (`F05.attach-size-limit`).",
          "body": "Arrange `head -c 4194304 /dev/zero > \"$OH_VERIFY_RUN/evidence/_fixtures/qa-big.bin\"` (and `qa-two-a.bin`, `qa-two-b.bin` with `head -c 2097152`). Run `control-openhands browser upload 'testid=upload-image-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-big.bin\"` and `control-openhands browser toasts`: `Error: Files exceeding 3MB are not allowed: qa-big.bin`. Uploading `qa-two-a.bin` and `qa-two-b.bin` together toasts `Error: Total file size would be 4.0MB, exceeding the 3MB limit. Please select fewer or smaller files.` `control-openhands browser count 'testid=interactive-chat-box >> text=qa-big.bin'` is `0`.",
          "ids": [
            "F05.attach-size-limit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Image alone (`F05.image-only-send`).",
          "body": "From the home composer: `control-openhands browser goto /`, `control-openhands browser enabled 'testid=submit-button'` (`false`, empty field), `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser upload-via 'testid=add-files-and-images-button' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-image.png\"`, then `control-openhands browser count 'testid=home-chat-launcher >> alt=qa-image.png'` (`1`) and `control-openhands browser enabled 'testid=submit-button'`: `true` while `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` is still empty (`control-openhands browser screenshot 'testid=home-chat-launcher' --feature F05.image-only-send --name image-alone-send-enabled`). Run `control-openhands browser click 'testid=submit-button' --expect-url '/conversations/'` and read `<image-id>` from `control-openhands browser url`. After `control-openhands browser wait 'testid=user-message'` and `control-openhands browser wait 'testid=chat-message-sending' --state detached --timeout 5000` (the pending bubble carries `testid=user-message` too, so wait for its `Sending...` to go, not only for a bubble; the wait returns `\"state\": \"detached\"` at once when the indicator has already gone, the usual case on a local stack), `control-openhands browser count 'testid=user-message >> testid=image-preview'` is `1`, and `control-openhands browser count 'testid=chat-message-sending'` and `control-openhands browser count 'testid=chat-message-retry'` are `0`. Server side, `control-openhands conversation events <image-id> --kinds MessageEvent --grep 'data:image/png;base64'` matches the one user row (`count` `1`; its `text` is a blank `\" \"`, the row carries `images: 1`, and the excerpt shows the `\"type\":\"image\"` block with `image_urls`), and `control-openhands conversation events <image-id> --kinds MessageEvent` (default `--last`) lists the same row with `images: 1` and no excerpt. `--kinds` filters before `--last` counts, so `--kinds MessageEvent --last 1` is that same row. To check that the model is handed the picture, send the image from Home together with `Which two colours are the bars in this image? Answer with the colour names only. Do not run any tools.` and read `control-openhands conversation events <id> --kinds MessageEvent`. Expect `orange` and `blue` in the agent row and `--kinds ActionEvent` `count` `0` (`fixture image` draws those bars). Known failure (reproduced 2026-10-08, agent-server 1.53.0, the pinned version): on a `deepseek/*` profile the user row still carries `images: 1`, but the agent answers that no image was attached. The model gets text only. The cause is in the SDK, not Canvas: 1.53.0 string-serializes every model id containing `deepseek`, which drops image parts. OpenHands/software-agent-sdk#5460 and #5467 fixed it on 2026-10-08, after the 1.53.0 release. On a stack launched with `--sdk-path <software-agent-sdk main>` the same send answers `Blue and orange.` with no tool call. An image-only send there reaches the model too: its first reasoning describes four blue and orange stripes. On an affected stack, the image-only send leaves the model with an empty task, and deepseek-flash then explores the disk (see Gotchas). Return with `control-openhands browser goto /conversations/<id>`.",
          "ids": [
            "F05.image-only-send"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Paste text (`F05.paste`).",
          "body": "Run `control-openhands browser type 'testid=chat-input' 'QA paste text'`, `control-openhands browser press Control+a`, `control-openhands browser press Control+c`, `control-openhands browser press Delete`, `control-openhands browser press Control+v`, then `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerHTML\"`: `QA paste text`, no markup. On a cleared field `control-openhands browser paste 'testid=chat-input' --file \"$OH_VERIFY_RUN/evidence/_fixtures/qa-image.png\"` attaches the image (`control-openhands browser count 'testid=interactive-chat-box >> alt=qa-image.png'` is `1`, with an `Upload as file` toggle) and `control-openhands browser paste 'testid=chat-input' --text 'QA pasted plain'` inserts plain text (`innerHTML` is `QA pasted plain`). `control-openhands browser drop-files 'testid=chat-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\" --stage over` makes `control-openhands browser text 'testid=interactive-chat-box'` start `Drop your files here`; the same command without `--stage` drops it: a `qa-note.txt` `TXT` chip appears. Remove the chip with `control-openhands browser click 'testid=interactive-chat-box >> role=button[name=\"Remove qa-note.txt\"]'` and the image as in Attach; clear the field.",
          "ids": [
            "F05.paste"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Dictation failure (`F05.dictation`).",
          "body": "Run `control-openhands browser click 'testid=chat-dictation-button' --observe '[role=status]' --observe-ms 3000`: the observed toasts include `Dictation failed. Check microphone access and your voice input settings.` and `control-openhands browser attr 'testid=chat-dictation-button' aria-label` is back to `Start dictation`. Real dictation is blocked without a microphone.",
          "ids": [
            "F05.dictation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Switch LLM profile (`F05.llm-profile-picker`).",
          "body": "Run `control-openhands browser click 'testid=chat-input-llm-profile'` and `control-openhands browser text 'testid=chat-input-llm-profile-popover'`: `AVAILABLE PROFILES`, `deepseek-flash` / `deepseek/deepseek-flash`, `deepseek-pro` / `deepseek/deepseek-v4-pro`, `LLM Profiles`. Run `control-openhands browser click 'testid=chat-input-llm-profile-option-deepseek-pro'`, `control-openhands browser wait-text 'Switched to profile' --timeout 15000`, `control-openhands browser text 'testid=chat-input-llm-profile'` (`deepseek-pro`) and `control-openhands conversation status <id>` (`model` is `deepseek/deepseek-v4-pro`). Send `Reply with only: pro-ok` as in Type and send; the reply is `pro-ok`. After `control-openhands browser reload` the pill still reads `deepseek-pro`. Switch back with `control-openhands browser click 'testid=chat-input-llm-profile'` and `control-openhands browser click 'testid=chat-input-llm-profile-option-deepseek-flash'`.",
          "ids": [
            "F05.llm-profile-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Profile identity (`F05.profile-identity`).",
          "body": "Arrange two profiles on one model right before this bullet: `QA_DUMMY_KEY=qa control-openhands llm set --profile qa-alpha --model openai/gpt-4o --api-key-env QA_DUMMY_KEY --no-validate --no-activate` and `QA_DUMMY_KEY=qa control-openhands llm set --profile qa-zeta --model openai/gpt-4o --api-key-env QA_DUMMY_KEY --no-validate` (`qa-zeta` becomes the default: `control-openhands api GET /api/profiles --pick active_profile` is `qa-zeta`). Run `control-openhands conversation start --prompt \"Reply with only: identity-ok\"` (no `--wait`; with the dummy key the agent errors at once, which does not matter here) and note its `id` as `<zeta-id>`. `control-openhands browser wait 'testid=chat-input-llm-profile'`, then `control-openhands browser text 'testid=chat-input-llm-profile'` is `qa-zeta`, not the alphabetically first profile on that model. `control-openhands browser reload`, the wait and the text again: still `qa-zeta`. With a DeepSeek key, arrange both profiles on `deepseek/deepseek-flash` with `--api-key-env DEEPSEEK_API_KEY` and without `--no-validate`, and start with `--prompt \"Reply with only: identity-ok. Do not run any tools.\" --wait --timeout 180`. Then the reply also survives the reload: `control-openhands browser text 'testid=agent-message >> nth=-1'` is `identity-ok` before and after it (driven 2026-10-08). The `default` agent profile still names `deepseek-flash` here, so the launch runs the pill's profile through agent settings (`F13.llm-pill-precedence`). The name is stamped on the conversation in this browser: `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-agent-server-conversation-metadata'))['<zeta-id>'].active_profile\"` is `qa-zeta`. Change the default in Settings: `control-openhands browser goto /settings/llm`, `control-openhands browser wait 'testid=add-llm-profile'`, `control-openhands browser click '[data-testid=profile-row]:has([title=\"qa-alpha\"]) >> testid=profile-menu-trigger'`, `control-openhands browser click 'testid=profile-actions-menu >> testid=profile-set-active'`, `control-openhands browser wait-text 'Switched to profile \"qa-alpha\"'` (`control-openhands api GET /api/profiles --pick active_profile` is `qa-alpha`). The home pill follows the default: `control-openhands browser goto /`, the wait, and `control-openhands browser text 'testid=chat-input-llm-profile'` is `qa-alpha`; the started conversation keeps its own: `control-openhands browser goto /conversations/<zeta-id>`, the wait, and the text is `qa-zeta` (`control-openhands browser screenshot 'testid=interactive-chat-box' --feature F05.profile-identity --name conversation-keeps-qa-zeta`). Restore: make `deepseek-flash` the default again the same way (its row's `profile-menu-trigger`, then `profile-set-active`), then `control-openhands api DELETE /api/profiles/qa-alpha --write` and `control-openhands api DELETE /api/profiles/qa-zeta --write` (arrange, not proof) so `/model` below still counts two profiles. In a model-free run make `qa-zeta` the default instead and delete only `qa-alpha`. Return with `control-openhands browser goto /conversations/<id>`.",
          "ids": [
            "F05.profile-identity"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Overflow menu (`F05.overflow-menu`).",
          "body": "Run `control-openhands browser viewport narrow` and `control-openhands browser snapshot 'testid=interactive-chat-box'`: the model pill is gone and `button \"More input actions\"` appears (at `phone` and `350x800` the pill stays inline). `control-openhands browser click 'role=button[name=\"More input actions\"]' --observe '[data-testid=chat-input-overflow-menu]' --observe-ms 1000`: `observed` goes from `<absent>` to `Model` (the menu appears in the observed sequence; its delay varies from run to run, so do not assert a time) and has no later `<absent>`. `control-openhands browser count 'testid=chat-input-overflow-menu'` is `1` and `control-openhands browser attr 'role=button[name=\"More input actions\"]' aria-expanded` is `true`. Run `control-openhands browser click 'testid=overflow-model-button'`; `control-openhands browser text 'testid=chat-input-overflow-menu'` reads `Model`, `AVAILABLE PROFILES`, `deepseek-flash` / `deepseek/deepseek-flash`, `deepseek-pro` / `deepseek/deepseek-v4-pro`, `LLM Profiles`. Run `control-openhands browser bbox 'testid=chat-input-llm-profile-option-deepseek-flash'` and `control-openhands browser bbox 'role=link[name=\"LLM Profiles\"]'`. Expected: both `insideViewport` `true`. Known failure (#18063): the submenu opens to the right of the menu, so the profile row starts at x about 270 with width 210 and the `LLM Profiles` link sits at y about 725, below the 700 px viewport (both `insideViewport` `false`); the profile clicks below still work because the first two rows keep a strip of about 50 px on screen (from x about 270 to the 320 px edge), while a click on the `LLM Profiles` link times out (`browser screenshot --feature F05.overflow-menu --name model-submenu-narrow`). `control-openhands browser click 'testid=chat-input-llm-profile-option-deepseek-pro'` and `control-openhands browser wait-text 'Switched to profile' --timeout 15000` close the menu (count `0`), and `control-openhands conversation status <id>` shows `deepseek/deepseek-v4-pro`. Switch back the same way: the trigger, `testid=overflow-model-button`, then `testid=chat-input-llm-profile-option-deepseek-flash` (`conversation status <id>` shows `deepseek/deepseek-flash`). Open the menu once more; a second click on the trigger closes it (`aria-expanded` `false`). Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F05.overflow-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Context window (`F05.context-window-meter`).",
          "body": "Run `control-openhands browser attr 'testid=context-window-meter' aria-label` (`Context window usage: 1% used (99% left)`), `control-openhands browser click 'testid=context-window-meter'` and `control-openhands browser text 'testid=context-window-meter-popover'` (`Context Window`, the percentage, `Compact context`, tokens such as `11.7k / 1.0M`, `Usage`). `control-openhands browser click 'testid=context-window-meter-bar-button'` (the progress bar) closes the popover and opens the Usage tab (`control-openhands browser testids --filter usage` lists `conversation-tab-usage`, `usage-panel`). The popover's `Usage` row does the same: from another tab (`control-openhands browser click 'testid=conversation-tab-files'`), reopen the ring, `control-openhands browser click 'testid=context-window-plan-usage'` and `control-openhands browser wait 'testid=usage-panel'` (the panel mounts a moment later; an immediate `count` is `0`). Reopen the ring, then `control-openhands browser click 'testid=context-window-compact-button' --observe '[role=status]' --observe-ms 25000`: `Context compaction started`, then `Context compacted`; `control-openhands conversation events <id> --kinds CondensationRequest,Condensation --last 5` lists both kinds.",
          "ids": [
            "F05.context-window-meter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Stop and resume (`F05.stop-resume`) with a queued follow-up (`F05.queued-message`).",
          "body": "Send `Run this shell command: sleep 25 && echo slept. Then reply with only: done` (type + Enter as above), `control-openhands conversation wait <id> --until running --timeout 30` and `control-openhands browser wait 'testid=stop-button'`; `control-openhands browser attr 'testid=stop-button' aria-label` is `Stop`. While it runs, `control-openhands browser enabled 'testid=submit-button'` is `false` with an empty field; type `Also reply with: queued-ok` (enabled turns `true`) and press Enter. Run `control-openhands browser click 'testid=stop-button'` and `control-openhands browser wait 'testid=play-button' --timeout 30000`; `control-openhands browser text 'testid=interactive-chat-box'` ends `Stopped`, `control-openhands browser attr 'testid=play-button' aria-label` is `Resume the agent task` and `control-openhands conversation status <id>` is `paused`. Run `control-openhands browser click 'testid=play-button'`, wait for `running` then the terminal state: the last agent message is `done queued-ok` and `testid=user-message >> has-text=queued-ok` counts `1`.",
          "ids": [
            "F05.stop-resume",
            "F05.queued-message"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Slash menu (`F05.slash-menu`).",
          "body": "On a cleared composer run `control-openhands browser type 'testid=chat-input' '/'`, `control-openhands browser count 'testid=slash-command-menu >> role=option'` (37 with the bundled skills) and `control-openhands browser text 'testid=slash-command-menu'`: `Commands`, then `/btw`, `/model`, `/goal`, `/plan`, `/code` with descriptions, then skills (`/agent-creator`, …); no `/new` on the local backend. Type `mo`: the count drops (descriptions match too). `control-openhands browser attr 'testid=slash-command-menu >> role=option >> nth=0' aria-selected` is `true`; after `control-openhands browser press ArrowDown` the selected option is `/plan` (read it with `control-openhands browser text 'testid=slash-command-menu >> [aria-selected=true]'`). `control-openhands browser press Escape` closes the menu and keeps `/mo`. On `/btw`, `control-openhands browser press Tab` leaves `/btw ` in the field; on `/model ` (trailing space) the menu lists `/model deepseek-flash` and `/model deepseek-pro`, and `control-openhands browser press Enter` inserts `/model deepseek-flash ` instead of sending.",
          "ids": [
            "F05.slash-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "`/model` (`F05.slash-model`).",
          "body": "Type `/model deepseek-pro`, `control-openhands browser press Escape` (close the menu), `control-openhands browser press Enter --selector 'testid=chat-input'`: `Switched to profile` appears, the pill reads `deepseek-pro` and `conversation status <id>` shows `deepseek/deepseek-v4-pro`. Bare `/model` (Escape, Enter) adds `Available profiles (2)` to `testid=model-messages`; `control-openhands browser click 'testid=model-messages >> role=button[name=\"Expand\"]'` and `control-openhands browser click 'role=button[name=\"Toggle details for deepseek-pro\"]'` show `model: deepseek/deepseek-v4-pro`, `base_url: —`, `api_key: set`. `/model qa-nope` (Escape, Enter) toasts `Profile 'qa-nope' not found` (`control-openhands browser toasts`) and the pill keeps `deepseek-pro`. Restore with `/model deepseek-flash`.",
          "ids": [
            "F05.slash-model"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "`/btw` (`F05.slash-btw`).",
          "body": "Note `control-openhands conversation events <id> --kinds MessageEvent` count, type `/btw Reply with only: side-ok`, Escape, Enter. `control-openhands browser text 'testid=btw-messages'` is `BTW:` + the question and `testid=btw-spinner` counts `1`; after the answer the card shows `Got it`. `control-openhands browser click 'testid=btw-messages >> role=button[name=\"Expand\"]'` reveals `side-ok`; `control-openhands browser click 'testid=btw-messages >> role=button[name=\"Got it\"]'` removes the card (count `0`). The MessageEvent count is unchanged. Bare `/btw` (Escape, Enter) clears the field and `control-openhands browser toasts` shows `Please provide a question — e.g. /btw <question>`; `control-openhands browser count 'testid=btw-messages'` stays `0`.",
          "ids": [
            "F05.slash-btw"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "`/goal` (`F05.slash-goal`).",
          "body": "Bare `/goal` + Enter: `control-openhands browser toasts` shows `Please provide an objective — e.g. /goal <objective>`. Send `/goal --max 1 Reply with only: goal-ok`: `control-openhands browser text 'testid=goal-status'` reads `Goal: … Round 0/1 running Stop`; `control-openhands browser wait 'testid=goal-done' --timeout 150000`, then `Round 1/1 complete score 100%`. Send `/goal --max 2 Reply with only: goal-two` and at once `control-openhands browser click 'testid=goal-status >> nth=-1 >> testid=goal-stop'`; `control-openhands browser wait 'testid=goal-resume'` and the banner reads `Round 0/2 interrupted Resume`. `control-openhands browser click 'testid=goal-resume'` sets it `running`; poll `control-openhands browser text 'testid=goal-status >> nth=-1'` until a new row ends `Round 1/2 complete score 100%`. The old `Round 0/2 interrupted` row no longer offers Resume: `control-openhands browser count 'testid=goal-resume'` is `0`. After `control-openhands browser reload`, `control-openhands browser count 'testid=goal-status'` is `3` and the `goal-resume` count is still `0`.",
          "ids": [
            "F05.slash-goal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "`/plan` and `/code` (`F05.slash-plan-code`).",
          "body": "Send bare `/plan` (Escape, Enter): `control-openhands browser attr 'testid=chat-input' data-placeholder` turns `Let’s work on a plan`; `control-openhands conversation list` gains a planner conversation (title `null` at first, `<planner-id>`). Bare `/code` turns it back to `What do you want to build?`. `control-openhands browser count 'role=button[name=\"Code\"]'` is `0` on local (the pill is Cloud only). `/plan` sent while the agent runs should be refused with feedback; today it is dropped silently: the field clears, no toast, the placeholder stays `What do you want to build?` (fail row; `use-plan-mode-interceptor.ts` swallows it by design but tells the user nothing).",
          "ids": [
            "F05.slash-plan-code"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Plan card (`F05.plan-preview`).",
          "body": "In plan mode send `Plan creating a file hello.txt containing hi. Keep the plan to three short steps.`, then `control-openhands conversation wait <planner-id> --until running --timeout 60`, `control-openhands conversation wait <planner-id> --timeout 240` and `control-openhands browser wait 'testid=plan-preview-content' --timeout 60000`. `control-openhands browser text 'testid=plan-preview-content'` starts `1. OBJECTIVE` and ends `Read more`; `control-openhands browser enabled 'testid=plan-preview-build-button'` is `true`. Use `--fresh` instead of the `--until running` wait if the planner is fast. `control-openhands browser click 'testid=plan-preview-view-button'` (Read more does the same) opens the Planner tab: `control-openhands browser testids --filter planner` lists `conversation-tab-planner` and `planner-tab-build-button` (`Build ⌘↩`).",
          "ids": [
            "F05.plan-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Build shortcut (`F05.build-plan-shortcut`).",
          "body": "With the plan card shown and the agent idle, `control-openhands browser press Control+Enter`: the placeholder returns to `What do you want to build?`, `control-openhands conversation wait <id> --until running --timeout 60` then `--timeout 240` finishes, `control-openhands browser text 'testid=user-message >> nth=-1'` is `Execute the plan based on the .agents_tmp/PLAN.md file.` (the agent's reply wording varies, e.g. `Executed the plan in .agents_tmp/PLAN.md`), and `hello.txt` exists in the `workspace` from `control-openhands conversation status <id>`.",
          "ids": [
            "F05.build-plan-shortcut"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "`/plan <task>` and `/code <task>` (`F05.slash-plan-code-task`).",
          "body": "With the agent idle, send bare `/plan`, then `/code Reply with only: code-ok` (Escape, Enter): the placeholder turns `What do you want to build?` at once, `control-openhands conversation wait <id> --fresh --timeout 180`, `control-openhands browser text 'testid=user-message >> nth=-1'` is `Reply with only: code-ok` (no `/code` prefix) and the last agent message is `code-ok`. Then send `/plan Plan writing bye.txt containing bye. One step only.`: the placeholder turns `Let’s work on a plan`, `control-openhands conversation wait <planner-id> --fresh --timeout 240`, `control-openhands conversation events <planner-id> --kinds MessageEvent --last 100` ends with the user text `Plan writing bye.txt containing bye. One step only.` and a planner reply, and `control-openhands conversation list` still has only the two conversations (the existing planner is reused).",
          "ids": [
            "F05.slash-plan-code-task"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Build button (`F05.plan-preview`).",
          "body": "With the newer plan card from `/plan Plan writing bye.txt containing bye. One step only.` (above) shown and the agent idle, `control-openhands browser click 'testid=plan-preview-build-button >> nth=-1'` does the same as the shortcut: placeholder `What do you want to build?`, `control-openhands conversation wait <id> --fresh --timeout 240`, the same last user message, and `bye.txt` (content `bye`) in the workspace.",
          "ids": [
            "F05.plan-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Phone (`F05.phone`).",
          "body": "On `/conversations/<id>` run `control-openhands browser viewport phone`, `control-openhands browser bbox 'testid=interactive-chat-box'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F05.phone --name composer` (`+`, model pill, ring, mic and Send on one row). Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F05.phone"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F06",
      "title": "Agent activity: messages, events and lifecycle",
      "file": "F06-agent-activity.md",
      "page": "F06-agent-activity.html",
      "sha256": "5887aeae92c83469945299f8b9838a2ca470363ee969f122907a26f2596fd275",
      "behaviors": [
        {
          "id": "F06.empty-state-suggestions",
          "description": "an empty conversation shows \"Let's start building!\" with four suggestion chips; a chip fills the composer with its prompt."
        },
        {
          "id": "F06.user-and-agent-messages",
          "description": "user and agent bubbles; a long user message is clipped with a gradient and expands on click (\"View More\")."
        },
        {
          "id": "F06.message-copy",
          "description": "hovering a bubble shows Copy to clipboard; it copies the message's markdown source and the label flips to Copied to clipboard for 2 s."
        },
        {
          "id": "F06.code-block-copy",
          "description": "hovering a fenced code block in an agent reply shows its own Copy button, which copies only the code."
        },
        {
          "id": "F06.timestamps",
          "description": "hovering a bubble or tool title shows the event's local date and time."
        },
        {
          "id": "F06.pending-messages",
          "description": "a sent message shows \"Sending...\" until the server echoes it; a failed send shows Failed to send with Retry and Dismiss; Retry delivers it exactly once."
        },
        {
          "id": "F06.clock-skew-send",
          "description": "a message sent while the browser clock runs ahead of the server is still confirmed by the server echo: no lingering \"Sending...\" and no Retry."
        },
        {
          "id": "F06.failed-send-persists",
          "description": "a failed send keeps its Retry row after the backend returns and after leaving and reopening the conversation from the sidebar; sending the same text again succeeds without clearing it, and Retry then delivers that attempt exactly once."
        },
        {
          "id": "F06.markdown-rendering",
          "description": "agent replies render markdown: tables inside a horizontal scroller with edge fades, fenced code, inline code."
        },
        {
          "id": "F06.workspace-path-links",
          "description": "a workspace file path in an agent reply, and the path chip of a file-editor card, open that file in the Files tab."
        },
        {
          "id": "F06.thinking",
          "description": "model reasoning appears as a collapsed \"Thinking\" row that expands."
        },
        {
          "id": "F06.event-groups",
          "description": "consecutive tool calls collapse into \"N actions completed\" (or \"k/N actions completed\" with a spinner while one is pending); expanding lists one titled row per action."
        },
        {
          "id": "F06.tool-visualizers",
          "description": "expanding a tool row shows a rich body: terminal command and output (non-zero exit badge `exit N`), file-editor path chip and content."
        },
        {
          "id": "F06.markdown-file-preview",
          "description": "when the agent creates a Markdown file, its row stays expanded and ungrouped and shows a rendered, height-limited preview card with the file name and View, which opens the file in the Files tab."
        },
        {
          "id": "F06.task-list",
          "description": "task-tracker calls render a \"Tasks\" card with done / todo icons."
        },
        {
          "id": "F06.events-match",
          "description": "the stream shows one bubble per message event and one row per action, with no duplicates (compare with `conversation events`)."
        },
        {
          "id": "F06.load-older-history",
          "description": "only the newest 50 events load first; the previous 50 are fetched when the user scrolls the transcript to its top (a \"Fetching older messages…\" row shows until they arrive) and automatically when the content is too short to scroll, so the first prompt and all actions appear."
        },
        {
          "id": "F06.scroll-to-bottom",
          "description": "scrolling up shows a scroll-to-bottom button; clicking it returns to the bottom and hides it."
        },
        {
          "id": "F06.live-activity",
          "description": "while the agent runs, a chip above the composer names the current action (\"Thinking\", then the action title) and the composer status reads Running."
        },
        {
          "id": "F06.status-indicator",
          "description": "while a new conversation starts, a \"Starting\" pill shows above the composer."
        },
        {
          "id": "F06.stop-resume",
          "description": "Stop in the composer interrupts the agent, including a running tool call (status Stopped, play button, an Agent error row for the interrupted call); Play resumes it to completion."
        },
        {
          "id": "F06.reload-mid-run",
          "description": "reloading while the agent runs reattaches to the live run; it finishes without duplicated events."
        },
        {
          "id": "F06.reconnect",
          "description": "when the Agent Server goes away the composer shows Disconnected; after it returns the page reconnects without a reload."
        },
        {
          "id": "F06.error-banner",
          "description": "losing the server shows \"Unable to connect to server\" with Retry, Copy and Close; Retry reconnects once the server is back."
        },
        {
          "id": "F06.error-events",
          "description": "a conversation error (ConversationErrorEvent) surfaces as a warning banner and the composer status Error."
        },
        {
          "id": "F06.confirmation-mode",
          "description": "with confirmation mode on, a lock chip sits above the composer and each action waits for \"Do you want to continue with this action?\" with Cancel (⇧⌘⌫) and Continue (⌘↩)."
        },
        {
          "id": "F06.confirmation-shortcuts",
          "description": "Cmd+Enter continues and Shift+Cmd+Backspace cancels a pending action."
        },
        {
          "id": "F06.branch-from-here",
          "description": "hovering a message shows Branch from here; on an agent message it forks inclusively; on a user message it forks without that message and pre-fills its text in the new composer, ready to send. The branch is titled \"<title> (branch)\" and keeps working."
        },
        {
          "id": "F06.image-attachments",
          "description": "images sent with a message show as thumbnails in the bubble; expand opens a lightbox that closes with X or Escape."
        },
        {
          "id": "F06.skill-install-banner",
          "description": "after the agent installs a skill into the workspace, a banner offers \"Start new conversation with this skill\" (same workspace) and Close."
        },
        {
          "id": "F06.llm-not-configured-banner",
          "description": "with no usable LLM, a conversation shows \"Your LLM isn't set up yet...\" with Set up LLM; suggestions are hidden and sending is disabled."
        },
        {
          "id": "F06.critic-result",
          "description": "with the critic enabled, agent messages and the finishing action each carry a \"Critic: agent success likelihood\" block (stars labelled `Score: <N.N>%`, one decimal, with the percentage beside them); Expand details lists the issue categories (Potential Issues, Infrastructure, Likely Follow-up)."
        },
        {
          "id": "F06.hook-events",
          "description": "hook executions render as their own rows in the stream."
        },
        {
          "id": "F06.corrective-nudge",
          "description": "when the model answers with neither a message nor a tool call, the SDK's nudge (`Your last response did not include a function call or a message. …`) shows as a muted, italic `role=note` line with an info icon (`corrective-nudge-message`), not as a user bubble (#17864)."
        },
        {
          "id": "F06.phone",
          "description": "at 390 px the transcript fits the viewport; wide tables scroll inside their own container."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "One run that exercises the stream (`F06.markdown-rendering`, `F06.event-groups`, `F06.thinking`, `F06.events-match`).",
          "body": "Run `control-openhands conversation start --prompt \"Run the terminal command 'echo qa-f06-hello', then create a file qa_f06.txt containing the word hi. Finally reply with a markdown table with columns Step and Result (two rows) followed by a python code block that prints 1.\" --wait --timeout 300`, then `control-openhands browser testids 'testid=chat-interface'` and `control-openhands browser snapshot 'testid=agent-message'`. The testids include `user-message`, `event-group` (\"3 actions completed\"), `agent-message`, `markdown-table-scroll` with `markdown-table-scroll-fade-left`/`-right`; the snapshot shows a `table` with column headers Step and Result and a `code` block `print(1)`. Compare with `control-openhands conversation events <id>`: one user and one agent `MessageEvent`, three `ActionEvent`s (`terminal`, `file_editor`, `canvas_ui_control`), matching one `user-message`, one group of three rows and one `agent-message`. The thinking row (`collapsible-thinking`) lives inside the group, so it is listed only after the next bullet expands it.",
          "ids": [
            "F06.markdown-rendering",
            "F06.event-groups",
            "F06.thinking",
            "F06.events-match"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Expand a group and its rows (`F06.event-groups`, `F06.thinking`, `F06.tool-visualizers`).",
          "body": "Run `control-openhands browser click 'testid=event-group-toggle'` (label becomes Collapse actions), `control-openhands browser text 'testid=event-group'` (three titles, e.g. \"Echo test string in terminal\", \"Create qa_f06.txt containing hi\"; titles are model-written), then `control-openhands browser click 'testid=event-group-content >> role=button[name=\"Expand\"][exact] >> nth=0'` twice (each click expands the next collapsed row). The group text now contains `echo qa-f06-hello` and its output `qa-f06-hello`, the file path chip (`testid=file-path-chip`) and the content `hi`. `control-openhands browser click 'testid=collapsible-thinking-toggle >> nth=0'` reveals `collapsible-thinking-content`. Take `control-openhands browser screenshot 'testid=chat-interface' --feature F06.tool-visualizers --name bash-and-file`.",
          "ids": [
            "F06.event-groups",
            "F06.thinking",
            "F06.tool-visualizers"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Open files from the stream (`F06.workspace-path-links`).",
          "body": "On the same page the agent may already have opened the file; run `control-openhands browser click 'testid=file-quick-row-close-qa_f06.txt'` and check `control-openhands browser count 'testid=file-quick-row-item-qa_f06.txt'` is `0`. Run `control-openhands browser click 'testid=agent-message >> testid=markdown-file-path-link'`: the count is `1` and `control-openhands browser text 'testid=file-content-viewer-plain'` is `hi`. Close it again and `control-openhands browser click 'testid=file-path-chip'` (inside the expanded file-editor row): the count is `1` again.",
          "ids": [
            "F06.workspace-path-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Markdown file preview (`F06.markdown-file-preview`).",
          "body": "Run `control-openhands conversation start --prompt \"Use the file_editor tool to create the file qa_notes.md containing a heading '# QA Notes' and one bullet '- first'. Do nothing else and reply DONE.\" --wait --timeout 150`. Without any click, `control-openhands browser text 'testid=markdown-file-preview'` reads `QA Notes first qa_notes.md View` (rendered heading and bullet, then the file name) and `count 'testid=event-group'` is `0`. `control-openhands browser count 'testid=file-quick-row-item-qa_notes.md'` is `0`; `control-openhands browser click 'testid=markdown-file-preview-view'` makes it `1` and `control-openhands browser text 'testid=file-content-viewer-markdown'` reads `QA Notes first`. Then `control-openhands browser goto /conversations/<stream id>` to return to the stream conversation for the next bullets.",
          "ids": [
            "F06.markdown-file-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Timestamps (`F06.timestamps`).",
          "body": "Run `control-openhands browser tooltip 'testid=user-message'` and `control-openhands browser tooltip 'testid=event-group-toggle'`. Both return the local date and time, e.g. `Oct 4, 2026, 11:08 PM`.",
          "ids": [
            "F06.timestamps"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Copy a message and a code block (`F06.message-copy`, `F06.code-block-copy`).",
          "body": "On the same page run `control-openhands browser clipboard --write qa-empty`, `control-openhands browser hover 'testid=agent-message >> nth=0'` and `control-openhands browser click 'testid=agent-message >> nth=0 >> testid=copy-to-clipboard >> nth=0'` (the bubble's button; the code block has a second, hidden `copy-to-clipboard`). `control-openhands browser attr 'testid=agent-message >> nth=0 >> testid=copy-to-clipboard >> nth=0' aria-label` is `Copied to clipboard`, and `Copy to clipboard` again about 2 s later; `control-openhands browser clipboard` starts with `| Step | Result |` and ends with the fenced `print(1)` block. Then `control-openhands browser hover 'testid=agent-message >> pre'`, `control-openhands browser click 'testid=agent-message >> pre >> testid=copy-to-clipboard'` and `control-openhands browser clipboard` is exactly `print(1)`.",
          "ids": [
            "F06.message-copy",
            "F06.code-block-copy"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Non-zero exit badge (`F06.tool-visualizers`).",
          "body": "In an open conversation run `control-openhands browser fill 'testid=chat-input' \"Run exactly this terminal command, verbatim, with nothing appended: ls /qa_missing_dir2 . Then reply OK.\"`, `control-openhands browser click 'testid=submit-button'` and `control-openhands conversation wait <id> --fresh --timeout 150` (without `--fresh` it returns the previous run's `finished` at once). If the reply's actions render as a single row, run `control-openhands browser click 'testid=chat-scroll-container >> role=button[name=\"Expand\"][exact] >> nth=-1'`; if they render as a group (`browser count 'testid=event-group'` grew, e.g. `2 actions completed` after a terminal reset and rerun), run `control-openhands browser click 'testid=event-group-toggle >> nth=-1'` and `control-openhands browser click 'testid=event-group-content >> nth=-1 >> role=button[name=\"Expand\"][exact] >> nth=-1'`. Then `control-openhands browser count 'testid=chat-scroll-container >> text=\"exit 2\"'` is `1`; the badge sits above the `No such file or directory` output (`browser screenshot 'testid=event-group >> nth=-1' --feature F06.tool-visualizers --name exit-code`). Repeating the check in the same conversation needs a new directory name and a count that grows, because an earlier expanded badge stays on the page.",
          "ids": [
            "F06.tool-visualizers"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Task list (`F06.task-list`, `F06.status-indicator`).",
          "body": "From `/` run `control-openhands browser goto /`, `control-openhands browser fill 'testid=chat-input' \"Use your task_tracker tool to plan exactly two tasks titled 'qa step one' and 'qa step two'; mark 'qa step one' done and 'qa step two' todo. Do nothing else and reply DONE.\"` and `control-openhands browser click 'testid=submit-button' --expect-url '/conversations/' --observe '[data-testid=chat-status-indicator]' --observe-ms 8000`. `observed` contains `Starting` for a moment (the pill). Then `control-openhands conversation wait <id> --timeout 150` and `control-openhands browser text 'testid=chat-scroll-container'`: it contains `Tasks`, `qa step one`, `qa step two`; the screenshot shows a check icon and muted text for the done task, an empty circle for the todo one.",
          "ids": [
            "F06.task-list",
            "F06.status-indicator"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Empty state (`F06.empty-state-suggestions`).",
          "body": "Run `control-openhands browser click 'testid=conversation-panel-new-thread-picker'`, `control-openhands browser click 'testid=launch-no-workspace' --expect-url '/conversations/'`, then `control-openhands browser text 'testid=chat-suggestions'`: `Let's start building!` plus `Increase test coverage`, `Auto-merge PRs`, `Fix README`, `Clean dependencies`. Run `control-openhands browser click 'testid=chat-suggestions >> text=Fix README'` and `control-openhands browser text 'testid=chat-input'`: the composer holds the README-improvement prompt.",
          "ids": [
            "F06.empty-state-suggestions"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Pending bubble and long messages (`F06.pending-messages`, `F06.user-and-agent-messages`).",
          "body": "Write a 25-line message to a file (first line `Reply with only the word OK. Ignore the filler lines below.`, then `filler line 1`…`filler line 24`), run `control-openhands browser fill 'testid=chat-input' x --value-file <file>` and `control-openhands browser click 'testid=submit-button' --observe '[data-testid=chat-message-sending]' --observe-ms 3000`. `observed` shows `Sending...` then `<absent>` once the server echoes it. After `control-openhands conversation wait <id> --timeout 120`, `control-openhands browser testids 'testid=chat-scroll-container'` lists `chat-message-truncation-gradient` and `chat-message-expand` (\"View More\"); `control-openhands browser click 'testid=chat-message-expand'` removes both and the whole text shows. `testid=agent-message` reads `OK`.",
          "ids": [
            "F06.pending-messages",
            "F06.user-and-agent-messages"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Browser clock ahead (`F06.clock-skew-send`).",
          "body": "On the same conversation, skew the page's clock before loading it: `control-openhands browser clock --offset-ms 300000`, `control-openhands browser goto /conversations/<id>`, `control-openhands browser wait 'testid=chat-input'`; `control-openhands browser eval \"new Date().toISOString()\"` is about five minutes ahead of `date -u` (read-only shell check). Run `control-openhands browser fill 'testid=chat-input' 'Reply with only: clock-ok'` and `control-openhands browser click 'testid=submit-button' --observe '[data-testid=chat-message-sending],[data-testid=chat-message-error]' --observe-ms 6000`: `observed` shows `Sending...` and then `<absent>` (the server echo confirmed it), never `Failed to send`. `control-openhands browser count 'testid=chat-message-sending'` and `control-openhands browser count 'testid=chat-message-retry'` are `0`, and `control-openhands conversation events <id> --kinds MessageEvent` gains one user row `Reply with only: clock-ok` whose `ts` is the server's time (close to `date -u`), not the browser's. Undo the skew before moving on: `control-openhands browser clock --offset-ms 0` and `control-openhands browser reload` (with a model, `control-openhands conversation wait <id> --fresh --timeout 120` first).",
          "ids": [
            "F06.clock-skew-send"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Server lost: banner, failed send, reconnect (`F06.error-banner`, `F06.pending-messages`, `F06.reconnect`).",
          "body": "With a conversation open run `control-openhands service stop agent-server` and `control-openhands browser wait 'testid=error-message-banner' --timeout 20000`: the banner reads `Unable to connect to server` with `error-message-banner-retry`, `-copy` and `-dismiss`; the composer shows `Disconnected`. Run `control-openhands browser fill 'testid=chat-input' 'Reply with only the word PONG.'` and `control-openhands browser click 'testid=submit-button' --observe '[data-testid=chat-message-sending],[data-testid=chat-message-error]' --observe-ms 8000`: `Sending...` turns into `Failed to send Retry Dismiss`. `control-openhands browser click 'testid=chat-message-retry'` while down fails again; `control-openhands browser click 'testid=chat-message-dismiss'` removes the bubble (`count 'testid=chat-message-error'` is `0`). Send the same message again so one failed bubble remains, `control-openhands browser click 'testid=error-message-banner-dismiss'` (banner count `0`), then `control-openhands restart`. Poll `control-openhands browser text 'testid=interactive-chat-box'` every 10 s: within about a minute after `restart` returns it no longer contains `Disconnected` (reconnected without reload). `control-openhands browser click 'testid=chat-message-retry'`, `control-openhands conversation wait <id> --timeout 120`, then `control-openhands conversation events <id> --kinds MessageEvent`: `Reply with only the word PONG.` appears once and the last `agent-message` is `PONG`.",
          "ids": [
            "F06.error-banner",
            "F06.pending-messages",
            "F06.reconnect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Banner Retry (`F06.error-banner`).",
          "body": "Run `control-openhands service stop agent-server`, wait for `testid=error-message-banner` as above, `control-openhands restart`, then `control-openhands browser click 'testid=error-message-banner-retry'`. `control-openhands browser count 'testid=error-message-banner'` is `0` and the composer is no longer Disconnected. Take `browser screenshot 'testid=chat-interface' --feature F06.error-banner --name disconnected` while the banner is up.",
          "ids": [
            "F06.error-banner"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Failed send survives leaving (`F06.failed-send-persists`).",
          "body": "With `/conversations/<id>` open and healthy, run `control-openhands service stop agent-server`, `control-openhands browser wait 'testid=error-message-banner' --timeout 20000`, `control-openhands browser fill 'testid=chat-input' 'Reply with only: retry-ok'` and `control-openhands browser click 'testid=submit-button' --observe '[data-testid=chat-message-sending],[data-testid=chat-message-error]' --observe-ms 8000`: `Sending...` turns into `Failed to send Retry Dismiss` and `control-openhands browser count 'testid=chat-message-retry'` is `1` (`control-openhands browser screenshot 'testid=chat-interface' --feature F06.failed-send-persists --name failed-before-leaving`). Run `control-openhands restart` and `control-openhands browser wait 'testid=error-message-banner' --state hidden --timeout 90000` (the page reconnects by itself; `testid=error-message-banner-retry` hurries it while the banner is still up): the Retry row is still there (count `1`). Leave through the sidebar and come back: `control-openhands browser click 'role=link[name=\"New Chat\"]'`, `control-openhands browser wait 'testid=home-chat-launcher'`, `control-openhands browser click 'a[href*=\"/conversations/<id>\"] >> nth=0' --expect-url '/conversations/<id>'`, `control-openhands browser wait 'testid=user-message'`; `control-openhands browser count 'testid=chat-message-retry'` is still `1` after the history reloaded (`control-openhands browser screenshot 'testid=chat-interface' --feature F06.failed-send-persists --name retry-after-reopen`). Send the same text again (fill `Reply with only: retry-ok`, click `testid=submit-button` with the same `--observe`): the new bubble goes `Sending...` and settles while `Failed to send Retry Dismiss` stays; `control-openhands browser count 'testid=chat-message-sending'` is `0`, `control-openhands browser count 'testid=chat-message-retry'` is `1`, and the rows of `control-openhands conversation events <id> --kinds MessageEvent --grep 'retry-ok'` whose `source` is `user` number exactly one, `Reply with only: retry-ok` (in a model-free run `count` is `1`; with a model the agent's `retry-ok` reply matches the grep too, so count the `user` rows, not `count`). Then `control-openhands browser click 'testid=chat-message-retry'` and `control-openhands browser wait 'testid=chat-message-retry' --state detached --timeout 15000`: `control-openhands browser count 'testid=chat-message-sending'` is `0` and the same `--grep` now lists exactly two `user` rows `Reply with only: retry-ok` (one per attempt, none duplicated). With a model, `control-openhands conversation wait <id> --fresh --timeout 120` before the next bullet.",
          "ids": [
            "F06.failed-send-persists"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Live chip, stop and resume (`F06.live-activity`, `F06.stop-resume`).",
          "body": "Run `control-openhands conversation start --prompt \"Run exactly one terminal command: sleep 25 && echo qa-slept. Then reply with only its output.\"` (no `--wait`), `control-openhands browser wait 'testid=live-activity-chip' --timeout 30000` and `control-openhands browser text 'testid=live-activity-chip'`: first `Thinking`, then the action title (e.g. `Run sleep 25 then echo qa-slept`); `browser text 'testid=interactive-chat-box'` contains `Running`. Run `control-openhands browser click 'testid=stop-button'`: `control-openhands conversation status <id>` is `paused`, the composer reads `Stopped`, `count 'testid=play-button'` is `1` and the chip is gone. Stop interrupts the running `sleep` too: `control-openhands conversation events <id>` shows an `AgentErrorEvent` (`Tool call interrupted before completion. The conversation was paused.`) followed by an `InterruptEvent`, and `browser text 'testid=chat-scroll-container'` shows an `Agent error` row under the action title. Run `control-openhands browser click 'testid=play-button'`: status `running`, then `control-openhands conversation wait <id> --timeout 120` ends `finished` with a new agent `MessageEvent` (the model reports that the command was interrupted; `qa-slept` never prints).",
          "ids": [
            "F06.live-activity",
            "F06.stop-resume"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Reload mid-run (`F06.reload-mid-run`).",
          "body": "Run `control-openhands conversation start --prompt \"Run exactly one terminal command: sleep 15 && echo qa-reloaded. Then reply with only its output.\"`, wait for `testid=live-activity-chip`, then `control-openhands browser reload`. The composer still reads `Running` and the chip is back. After `control-openhands conversation wait <id> --timeout 120`: `testid=agent-message` is `qa-reloaded`, `count 'testid=user-message'` is `1`, `control-openhands browser eval \"document.querySelectorAll('[data-testid=chat-scroll-container] [data-testid=generic-event-message-title]').length\"` is `1`.",
          "ids": [
            "F06.reload-mid-run"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Older history (`F06.load-older-history`).",
          "body": "Run `control-openhands conversation start --prompt \"Run these 16 terminal commands strictly one per tool call, sequentially (never combine them): echo qa1, echo qa2, echo qa3, echo qa4, echo qa5, echo qa6, echo qa7, echo qa8, echo qa9, echo qa10, echo qa11, echo qa12, echo qa13, echo qa14, echo qa15, echo qa16. Then reply DONE.\" --wait --timeout 400` (note `<history-id>`); `control-openhands conversation events <history-id> --last 500` reports a count above 50. Run `control-openhands browser network --clear`, `control-openhands browser reload`, then `control-openhands browser network --last 300`: two `GET /api/conversations/<history-id>/events/search` requests (newest page, then the older page). `control-openhands browser text 'testid=chat-scroll-container'` starts with the prompt and shows `16 actions completed` and `DONE`. The other trigger is the user scrolling to the top, driven without a model on the dummy profile from Preconditions (`qa-zeta` active; in a keyed run create and activate it with that same `llm set` command for this bullet, then re-run `control-openhands llm preset deepseek` and remove it with `control-openhands api DELETE /api/profiles/qa-zeta --write`, so later families still count two profiles; arrange, not proof). Run `control-openhands conversation start --prompt \"qa older 1\"` (no `--wait`; note `<older-id>`), then `for N in $(seq 2 30); do control-openhands conversation send <older-id> --prompt \"qa older $N\"; done`: every send is accepted while the agent sits in error, about 1.5 s apiece (42 s for the 29 here), and each refused message adds five events (a `MessageEvent`, three `ConversationStateUpdateEvent`s, a `ConversationErrorEvent`), so the conversation ends with about 150 events (152 here). `control-openhands conversation events <older-id> --last 500` reports `\"total\": 152`, `\"pages\": 2`, `\"more\": false` (both server pages read, see Gotchas); its first row is the `SystemPromptEvent` and the first `MessageEvent` is `qa older 1`, while `--last 25` reports `\"pages\": 1`, `\"more\": true` and starts at `qa older 26`. Run `control-openhands browser network --clear`, `control-openhands browser reload`, `control-openhands browser wait 'testid=chat-input'`, then `control-openhands browser network --filter 'events/search' --last 10`: two requests, `?limit=50&sort_order=TIMESTAMP_DESC` and one with `timestamp__lt=<ts of qa older 21>`, because the newest 50 events hold only ten bubbles, shorter than the viewport, so the automatic fetch ran once and then stopped. `control-openhands browser count 'testid=user-message'` is `20`, `control-openhands browser text 'testid=chat-scroll-container'` starts `qa older 11` and holds none of `qa older 1`…`qa older 10`, and `control-openhands browser bbox 'testid=chat-scroll-container'` has `scrollHeight` `1576` against `clientHeight` `814`: taller than the viewport, so the third page waits for the user. Run `control-openhands browser scroll 'testid=chat-scroll-container' --by -100000`: `control-openhands browser network --filter 'events/search' --last 5` gains a request with `timestamp__lt=<ts of qa older 11>`, `control-openhands browser count 'testid=user-message'` is `29` and the text starts `qa older 2` (that page held messages 2–10 plus the tail of the first message's events; not seen in two runs: if the first refusal ever logs six events instead of seven, the arithmetic gives count `30`, text `qa older 1` and a last page of only the `SystemPromptEvent`), and the view stays on `qa older 11`: `control-openhands browser eval \"document.querySelector('[data-testid=chat-scroll-container]').scrollTop\"` is `684`, the height of the prepended rows. Scroll to the top again the same way: a fourth request (`timestamp__lt=` the oldest loaded event), the count is `30`, the text starts `qa older 1` and `scrollTop` is `76` (`control-openhands browser screenshot 'testid=chat-interface' --feature F06.load-older-history --name scroll-top-loaded-qa-older-1`). A third scroll adds no request: the short page ended the paging. `control-openhands browser count 'testid=loading-older-events'` is `0` after each scroll because the `Fetching older messages…` row lasts a few tens of milliseconds on a local stack; to see it, scroll with `control-openhands browser scroll 'testid=chat-scroll-container' --by -100000 --observe 'testid=loading-older-events' --observe-ms 1500` instead (both scrolls were driven this way here, with the same counts), whose `observed` goes `<absent>` at `0` ms, `Fetching older messages…` at about 20 ms and `<absent>` again within about 40 to 120 ms (`116` ms on the first scroll, `39` on the second; `observedBy` `mutation`); the `timestamp__lt=` network rows remain the durable proof. Return to the 16-command conversation for the next bullet: `control-openhands browser goto /conversations/<history-id>` and `control-openhands browser wait 'testid=chat-input'`.",
          "ids": [
            "F06.load-older-history"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Scroll to bottom (`F06.scroll-to-bottom`).",
          "body": "On the 16-command conversation (`<history-id>`; in a model-free run this bullet is blocked with the automatic-fetch half, prerequisite `DEEPSEEK_API_KEY`), `control-openhands browser click 'testid=event-group-toggle'` makes the transcript taller than the viewport while the view stays where it was. Run `control-openhands browser scroll 'testid=chat-scroll-container' --by 2000` first (the button only reacts to scroll events, so a page that is already at the top never shows it): `count 'testid=scroll-to-bottom'` is `0`. Run `control-openhands browser scroll 'testid=chat-scroll-container' --by -2000`; the count is `1`. `control-openhands browser click 'testid=scroll-to-bottom'`, then the count is `0` and `control-openhands browser eval \"(()=>{const e=document.querySelector('[data-testid=chat-scroll-container]');return Math.round(e.scrollHeight-e.scrollTop-e.clientHeight)})()\"` is `0`.",
          "ids": [
            "F06.scroll-to-bottom"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Confirmation mode on (`F06.confirmation-mode`).",
          "body": "Arrange through the settings UI, in this order (the Security Analyzer field appears only once Confirmation Mode is on): `control-openhands browser goto /settings/verification`, `control-openhands browser click 'testid=verification-settings-screen >> text=Confirmation Mode'`, `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser click 'testid=verification-settings-screen >> role=button[name=\"Show suggestions\"]'`, `control-openhands browser click 'role=listbox >> role=option[name=\"None\"]'`, `control-openhands browser value 'testid=verification-settings-screen >> role=combobox[name=\"Security Analyzer\"]'` (`None`), `control-openhands browser click 'testid=verification-settings-screen >> testid=save-button'`; `control-openhands api GET /api/settings` shows `\"confirmation_mode\": true` and `\"security_analyzer\": \"none\"` (None means every action is confirmed; the default LLM analyzer only stops HIGH-risk actions, so `echo` would run unprompted). Run `control-openhands conversation start --prompt \"Run the terminal command 'echo qa-confirm-ok' and reply with its output.\" --wait --timeout 180`: it returns `\"status\": \"waiting_for_confirmation\"`. `control-openhands browser text 'testid=chat-scroll-container'` ends `Do you want to continue with this action? Cancel ⇧⌘⌫ Continue ⌘↩`, `control-openhands browser tooltip 'testid=action-confirm-button'` is `Confirm the requested action`, and a lock icon sits above the composer (screenshot). Run `control-openhands browser click 'testid=action-reject-button'`: status `idle`, the buttons are gone and `conversation events <id> --last 6` ends with `UserRejectObservation`. Send `Run the terminal command 'echo qa-confirm-two' and reply with its output.` through `testid=chat-input` / `testid=submit-button`, `control-openhands browser wait 'testid=action-confirm-button' --timeout 20000`, `control-openhands browser click 'testid=action-confirm-button'`; about 15 s later the status is `finished` and the events contain the observation `qa-confirm-two`. Expected after a Cancel: the rejected row is shown as resolved and the group stops spinning (currently fails, see Gotchas).",
          "ids": [
            "F06.confirmation-mode"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Confirmation shortcuts (`F06.confirmation-shortcuts`).",
          "body": "The previous bullet resolved its action, so arrange a new one in the same conversation: send `Run the terminal command 'echo qa-confirm-three' and reply with its output.` through `testid=chat-input` / `testid=submit-button` and `control-openhands browser wait 'testid=action-confirm-button' --timeout 20000`. While it awaits confirmation, `control-openhands browser press Control+Enter` does nothing (`control-openhands conversation status <id>` stays `waiting_for_confirmation`); see Gotchas. `control-openhands browser press Meta+Enter` continues it: `control-openhands conversation wait <id> --fresh --timeout 120` ends `finished` and `control-openhands conversation events <id> --kinds ObservationEvent` contains `qa-confirm-three`. Restore afterwards: `browser goto /settings/verification`, click `testid=sdk-section-all-toggle`, `Show suggestions`, `role=listbox >> role=option[name=\"LLM\"]`, then `testid=verification-settings-screen >> text=Confirmation Mode`, then `save-button`; `api GET /api/settings` shows `\"confirmation_mode\": false`, `\"security_analyzer\": \"llm\"`.",
          "ids": [
            "F06.confirmation-shortcuts"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Branch from here (`F06.branch-from-here`).",
          "body": "Open a conversation with at least two exchanges (the OK/PONG one above): `control-openhands browser goto /conversations/<id>` with its id (the previous bullet left `/settings/verification`). Agent message: `control-openhands browser hover 'testid=agent-message >> nth=0'`, `control-openhands browser click 'testid=agent-message >> nth=0 >> role=button[name=\"Branch from here\"]' --expect-url '/conversations/(?!<id>)'`; after `control-openhands browser wait 'testid=user-message'` (the history mounts a moment after the URL changes, so an immediate count reads `0`) the new conversation has `count 'testid=user-message'` `1`, the agent message `OK`, and `browser eval \"document.title\"` contains `(branch)`. User message (edit): back on the OK/PONG conversation (`control-openhands browser goto /conversations/<id>`; the branch above has one user message), `control-openhands browser hover 'testid=user-message >> nth=1'` and `control-openhands browser click 'testid=user-message >> nth=1 >> role=button[name=\"Branch from here\"]' --observe '[data-testid=chat-input]' --observe-ms 4000`: `observed` shows the composer change from empty to `Reply with only the word PONG.` and stay. The new conversation (`<branch-id>` from `browser url`) omits that message, `control-openhands browser text 'testid=chat-input'` is `Reply with only the word PONG.`, and nothing was sent: `control-openhands conversation events <branch-id> --kinds MessageEvent` lists only the copied first exchange (count `2`: the long message that starts `Reply with only the word OK.`, then `OK`; no `PONG`). `control-openhands browser enabled 'testid=submit-button'` should be `true` with the prefilled text; today it is `false` until the text is edited or the page is reloaded (see Gotchas). Send it with `control-openhands browser press Enter --selector 'testid=chat-input'` (Enter sends whatever the Send button's state is) and `control-openhands conversation wait <branch-id> --fresh --timeout 120`: expected `finished` with a reply; today it ends `error` (Agent Server bug, see Gotchas).",
          "ids": [
            "F06.branch-from-here"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Images (`F06.image-attachments`).",
          "body": "Run `control-openhands fixture image --name qa-f06-img` (prints the PNG path), `control-openhands browser upload 'testid=upload-image-input' <png path>` (a thumbnail appears in the composer), fill `testid=chat-input` with `Reply with only the word SEEN.` and click `testid=submit-button`. The user bubble contains `image-carousel` / `image-preview`. Run `control-openhands browser hover 'testid=image-preview'`, `control-openhands browser click 'testid=expand-image-button'` (`count 'testid=image-lightbox'` is `1`), `control-openhands browser press Escape` (`0`), click `expand-image-button` again and `control-openhands browser click 'testid=image-lightbox-close'` (`0`).",
          "ids": [
            "F06.image-attachments"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Error events (`F06.error-events`).",
          "body": "Any run that ends in a `ConversationErrorEvent` (today: any message sent in a branched conversation) leaves `conversation wait <id>` at `\"status\": \"error\"`; `control-openhands browser testids 'testid=chat-interface'` lists `error-message-banner` with `warning-message-banner-icon` and the error's text, and the composer status reads `Error`. Read the code with `control-openhands api GET \"/api/conversations/<id>/events/search?limit=3&sort_order=TIMESTAMP_DESC\"`.",
          "ids": [
            "F06.error-events"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Skill installed banner (`F06.skill-install-banner`).",
          "body": "The banner keys on the installer's success line in terminal output. Put this one line in a prompt file: `Run exactly this one terminal command, verbatim, then reply DONE: mkdir -p .agents/skills/qa-skill && printf '# qa-skill\\n' > .agents/skills/qa-skill/SKILL.md && echo \"✅ Successfully installed 'qa-skill' to $PWD/.agents/skills/qa-skill\"`, then run `control-openhands conversation start --prompt \"$(cat <prompt file>)\" --wait --timeout 150`. `control-openhands browser text 'testid=skill-install-restart-banner'` reads `Installed to this workspace: qa-skill. Skills load when a conversation starts, so this conversation can't use them yet.` Run `control-openhands browser click 'testid=skill-install-restart-action' --expect-url '/conversations/(?!<id>)'`; `control-openhands api GET /api/conversations/<new id>` has the same `workspace.working_dir` as the source. Back on the source (`control-openhands browser goto /conversations/<id>`), `control-openhands browser click 'testid=skill-install-restart-dismiss'` hides it (count `0`); after `browser reload` it is back (dismissal is session-only by design).",
          "ids": [
            "F06.skill-install-banner"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "No LLM (`F06.llm-not-configured-banner`).",
          "body": "On the fresh no-LLM run: `control-openhands onboard --skip`, `control-openhands browser click 'testid=conversation-panel-new-thread-picker'`, `control-openhands browser click 'testid=launch-no-workspace' --expect-url '/conversations/'`. `control-openhands browser text 'testid=home-llm-not-configured-banner'` reads `Your LLM isn't set up yet, so conversations won't run. Finish setup to get started. Set up LLM`; `count 'testid=chat-suggestions'` is `0`; the composer cannot take text (`control-openhands browser attr 'testid=chat-input' contenteditable` is `false`, so `browser fill` fails) and `control-openhands browser enabled 'testid=submit-button'` is `false`. `control-openhands browser click 'testid=home-llm-not-configured-action'` then `browser url` ends in `/settings/llm`.",
          "ids": [
            "F06.llm-not-configured-banner"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Critic (`F06.critic-result`, blocked).",
          "body": "Toggle the hidden switch through its label on `/settings/verification`: `control-openhands browser goto /settings/verification`, `control-openhands browser click 'label:has([data-testid=\"sdk-settings-verification.critic_enabled\"])'`, save, `api GET /api/settings` shows `\"critic_enabled\": true`. Without a Critic API key a `Reply with only the word OK.` conversation renders no critic block. With a key and a model, run `control-openhands conversation start --prompt \"Create qa_critic.txt containing hi, then finish.\" --wait --timeout 180`: every scored event carries a block, so `control-openhands browser count 'text=Critic: agent success likelihood'` is at least `1` and a finishing action (`finish` tool row) shows its own block next to the agent's message; the stars carry the score as their label, which an ARIA snapshot does not list: `control-openhands browser count '[aria-label^=\"Score: \"]'` is at least `2` and `control-openhands browser attr '[aria-label^=\"Score: \"] >> nth=0' aria-label` reads `Score: <N.N>%` with one decimal (`Score: 82.0%`), the same number as the `(82.0%)` text beside the stars. `control-openhands browser click 'role=button[name=\"Expand details\"] >> nth=0'` opens the categories `Potential Issues:`, `Infrastructure:` and `Likely Follow-up:` with their named issues in `control-openhands browser text 'testid=chat-scroll-container'`, and the button's label flips to `Collapse details`. Blocked here: DEEPSEEK_API_KEY and a Cloud critic key. Turn it off again the same way.",
          "ids": [
            "F06.critic-result"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Phone (`F06.phone`).",
          "body": "Run `control-openhands browser viewport phone`, open the table conversation (`control-openhands browser goto /conversations/<stream id>`), `control-openhands browser bbox 'testid=chat-interface'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser bbox 'testid=markdown-table-scroll'` (`scrollWidth` larger than `clientWidth`: the table scrolls inside itself). `control-openhands browser screenshot --feature F06.phone --name conversation`, then `control-openhands browser viewport desktop`.",
          "ids": [
            "F06.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "After the family",
          "body": "After each group run `control-openhands browser errors --app-only`. Outages driven with `service stop` log connection-refused console errors by design; `pageErrors` must stay `0`.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F07",
      "title": "Conversation page, header and management menu",
      "file": "F07-conversation-page.md",
      "page": "F07-conversation-page.html",
      "sha256": "1af03f0e1a17de1036e1d700b1c0c182a5dc13e21570aacb2f15ac0168729e02",
      "behaviors": [
        {
          "id": "F07.open-by-url",
          "description": "`/conversations/<id>` shows header, history and composer, and remembers the id as the backend's last conversation."
        },
        {
          "id": "F07.missing-redirect",
          "description": "an unknown id toasts \"This conversation does not exist…\" and redirects to `/conversations`."
        },
        {
          "id": "F07.history-skeleton",
          "description": "opening a conversation shows a message skeleton until its history has loaded."
        },
        {
          "id": "F07.panel-route",
          "description": "at phone width (up to 1024 px) the header panel button opens `/conversations/<id>/panel`, the tabs panel full width with a Back button; wider, that URL redirects to `/conversations/<id>` with the drawer open beside the chat."
        },
        {
          "id": "F07.rename-inline",
          "description": "double-click the title (or menu Rename), Enter or blur saves with a toast; an empty value reverts."
        },
        {
          "id": "F07.status-menu",
          "description": "hovering the status dot (or tapping it on touch) opens a menu with the state and Stop Runtime (active) or Start Runtime (paused)."
        },
        {
          "id": "F07.menu-open",
          "description": "the \"...\" menu opens under the title; Escape closes it and returns focus to \"...\", and so does an outside click (without the focus move)."
        },
        {
          "id": "F07.menu-items-by-state",
          "description": "Show Available Hooks and Stop Conversation appear only while the conversation is active."
        },
        {
          "id": "F07.skills-modal",
          "description": "Show Available Skills lists the loaded skills with expandable rows and Refresh."
        },
        {
          "id": "F07.hooks-modal",
          "description": "Show Available Hooks lists the workspace's `.openhands/hooks.json` hooks by event (read when it first opens for a conversation after a page load, and on Refresh; a reopen within 5 minutes of the last read shows the cached list), or \"No hooks configured for this conversation.\""
        },
        {
          "id": "F07.skills-modal-project",
          "description": "a workspace with `.agents/skills/<name>/SKILL.md` adds a \"Project skills\" section above the public ones; each row expands to its triggers and body. The list is read with the page and on Refresh; opening the dialog within 10 minutes of the last read shows the cached list."
        },
        {
          "id": "F07.agent-tools-modal",
          "description": "Show Agent Tools & Metadata shows the system message and the tool list."
        },
        {
          "id": "F07.export-transcript",
          "description": "Export… downloads a Markdown or HTML transcript with optional tool details and timestamps."
        },
        {
          "id": "F07.download-zip",
          "description": "Download conversation data downloads `conversation_<id>.zip`."
        },
        {
          "id": "F07.display-cost",
          "description": "Display Usage and Cost opens the right panel on the Usage tab."
        },
        {
          "id": "F07.stop-confirm",
          "description": "Stop Conversation asks for confirmation; Confirm Close pauses the conversation and returns to `/conversations`."
        },
        {
          "id": "F07.delete-confirm",
          "description": "Delete Conversation names the title in a confirmation; Cancel keeps it, Confirm Delete removes it and returns to `/conversations`."
        },
        {
          "id": "F07.branch-from-message",
          "description": "Branch from here on a message forks the conversation as `<title> (branch)`; on a user message the message is left out and its text goes back into the composer."
        },
        {
          "id": "F07.right-panel-toggle",
          "description": "the header panel button shows and hides the right tabs panel (closed again after a reload)."
        },
        {
          "id": "F07.overview-toggle",
          "description": "the header (i) button shows and hides the Overview column, which is exclusive with the right panel."
        },
        {
          "id": "F07.git-actions-menu",
          "description": "Git actions offers Commits, Pull, Push, Create PR and Create New Branch; each pre-fills the composer without sending."
        },
        {
          "id": "F07.git-control-bar",
          "description": "under the composer, a folder workspace shows an inert workspace chip and the branch; with a GitHub `origin` the repo and branch chips link to GitHub and Pull, Push and Pull Request appear."
        },
        {
          "id": "F07.git-control-bar-send",
          "description": "the Pull, Push and Pull Request chips send their git prompt to the agent at once."
        },
        {
          "id": "F07.cloud-only",
          "description": "Share publicly, Connect Repo, `task-<uuid>` start URLs, paused-sandbox resume, archived read-only state and the shared-conversation redirect exist only on a Cloud backend."
        },
        {
          "id": "F07.phone",
          "description": "at 390 px the header keeps its controls and the info dialogs fit the viewport."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open by URL (`F07.open-by-url`).",
          "body": "Run `control-openhands browser goto /conversations/<id>`, `control-openhands browser wait 'testid=chat-pane-header'`, `control-openhands browser text 'testid=conversation-name-title'` and `control-openhands browser count 'testid=chat-interface >> testid=agent-message'`. The title is the generated one and the count is `1`. `control-openhands browser eval \"localStorage.getItem('openhands-last-conversation-by-backend')\"` reads `{\"default-local\":\"<id>\"}`.",
          "ids": [
            "F07.open-by-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Unknown id (`F07.missing-redirect`).",
          "body": "Run `control-openhands browser goto /conversations/00000000-0000-0000-0000-000000000000`, `control-openhands browser wait-url '/conversations$'` and `control-openhands browser toasts`. A toast reads `This conversation does not exist, or you do not have permission to access it. If this is your conversation, try switching to the workspace where it was created.` and the last-conversation slot is `{}`. It is the only toast: `control-openhands browser toasts --history` has no entry with `does not understand`.",
          "ids": [
            "F07.missing-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Rename inline (`F07.rename-inline`).",
          "body": "On `/conversations/<id>` (`control-openhands browser goto /conversations/<id>`; the unknown id redirected to `/conversations`) run `control-openhands browser dblclick 'testid=conversation-name-title'`, `control-openhands browser fill 'testid=conversation-name-input' 'QA_F07 renamed'`, `control-openhands browser press Enter`, `control-openhands browser toasts` (`Conversation title updated successfully`), then `control-openhands browser reload` and `control-openhands browser text 'testid=conversation-name-title'`. It reads `QA_F07 renamed`, and so does `control-openhands browser text 'testid=conversation-card >> testid=conversation-card-title'`.",
          "ids": [
            "F07.rename-inline"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Rename from the menu, empty value (`F07.rename-inline`).",
          "body": "Run `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'`, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=rename-button'`, `control-openhands browser fill 'testid=conversation-name-input' ''` and `control-openhands browser press Enter`. The title stays `QA_F07 renamed` and no toast appears.",
          "ids": [
            "F07.rename-inline"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "History skeleton and sidebar entry (`F07.history-skeleton`, `F07.open-by-url`).",
          "body": "From `/` (`control-openhands browser goto /`) run `control-openhands browser click 'testid=conversation-card >> has-text=QA_F07 renamed' --expect-url '/conversations/[0-9a-f-]+' --observe 'testid=chat-messages-skeleton' --observe-ms 3000` (run it after the rename above; before it, match the generated title instead). The `observed` list shows the skeleton present for a few hundred ms, then `<absent>`; the URL is `/conversations/<id>?backend=default-local`.",
          "ids": [
            "F07.history-skeleton",
            "F07.open-by-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Status menu, stop (`F07.status-menu`).",
          "body": "On `/conversations/<id>` run `control-openhands browser hover 'testid=server-status-menu-trigger'` (for a mouse the hover opens the menu and a click on the open menu's dot closes it; a CLI `click` from elsewhere usually opens it only because the click lands before the hover renders, a race that sometimes leaves it closed, so do not click), `control-openhands browser text 'testid=server-status-context-menu'` (`Running` / `Stop Runtime`), then `control-openhands browser click 'testid=server-status-context-menu >> testid=stop-server-button' --expect-url '/conversations$'` and `control-openhands browser toasts`. The toasts read `Stopping conversation...` and `Conversation stopped` and the URL is `/conversations`.",
          "ids": [
            "F07.status-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Menu (`F07.menu-open`).",
          "body": "Stop Runtime left the browser on `/conversations`: run `control-openhands browser goto /conversations/<git-id>` (a finished conversation whose runtime was not stopped), then `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'` and `control-openhands browser testids 'testid=conversation-name-context-menu'`. On a finished or running conversation the items are `rename-button`, `show-skills-button`, `show-hooks-button`, `show-agent-tools-button`, `export-transcript-button` (`Export…`), `download-trajectory-button`, `display-cost-button` (`Display Usage and Cost`), `stop-button`, `delete-button`. `control-openhands browser press Escape` closes it (`control-openhands browser count 'testid=conversation-name-context-menu'` is `0`) and focus is back on the trigger (`control-openhands browser eval \"document.activeElement.dataset.testid\"` is `ellipsis-button`). Open it again: `control-openhands browser mouse-click 900 500` closes it too (count `0`).",
          "ids": [
            "F07.menu-open"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Stop with confirmation (`F07.stop-confirm`).",
          "body": "On `/conversations/<running-id>` run `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'`, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=stop-button'` and `control-openhands browser snapshot 'role=dialog'` (`Confirm Stop Conversation`, buttons `Cancel` and `Confirm Close`). `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` closes it and `control-openhands conversation status <running-id>` stays `running`. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> role=button[name=\"Confirm Close\"]' --expect-url '/conversations$'`; the toasts read `Conversation stopped` and `conversation status <running-id>` is `paused`.",
          "ids": [
            "F07.stop-confirm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Branch from a message (`F07.branch-from-message`).",
          "body": "On `/conversations/<id>` (`control-openhands browser goto /conversations/<id>`; Stop with confirmation ended on `/conversations`) run `control-openhands browser hover 'testid=agent-message'`, then `control-openhands browser click 'testid=agent-message >> role=button[name=\"Branch from here\"]' --expect-url '/conversations/(?!<id>)[0-9a-f-]+'`, `control-openhands browser text 'testid=conversation-name-title'` (`QA_F07 renamed (branch)`) and `control-openhands browser count 'testid=chat-interface >> testid=user-message'` (`1`, the history is copied up to that reply). Back on `/conversations/<id>` (`control-openhands browser goto /conversations/<id>`), `control-openhands browser hover 'testid=user-message'` and `control-openhands browser click 'testid=user-message >> role=button[name=\"Branch from here\"]' --expect-url '/conversations/(?!<id>)[0-9a-f-]+'` (it prints `/conversations/<branch-id>`): the new branch has `0` user messages and `control-openhands browser text 'testid=chat-input'` reads the original prompt `Reply with the single word: pong`, still there a few seconds later and not sent (`control-openhands conversation events <branch-id> --kinds MessageEvent` has count `0`).",
          "ids": [
            "F07.branch-from-message"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Delete with confirmation (`F07.delete-confirm`).",
          "body": "On a disposable conversation (for example the branch from `F07.branch-from-message`) run `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'`, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=delete-button'` and `control-openhands browser snapshot 'role=dialog'`: `Confirm Delete Are you sure you want to delete the \"<title>\" conversation? This action cannot be undone.` with `Cancel` and `Confirm Delete`. `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps the URL. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> role=button[name=\"Confirm Delete\"]' --expect-url '/conversations$'`; `control-openhands api GET '/api/conversations?ids=<id>'` returns `[null]` and `browser goto /conversations/<id>` redirects as in `F07.missing-redirect`.",
          "ids": [
            "F07.delete-confirm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Paused conversation (`F07.menu-items-by-state`, `F07.status-menu`).",
          "body": "Run `control-openhands browser goto /conversations/<running-id>`, open the \"...\" menu and `control-openhands browser testids 'testid=conversation-name-context-menu'`: `show-hooks-button` and `stop-button` are absent. Close it with `control-openhands browser mouse-click 900 500`. Run `control-openhands browser click 'testid=server-status-menu-trigger'` and `control-openhands browser text 'testid=server-status-context-menu'` (`Server Stopped` / `Start Runtime`), then `control-openhands browser click 'testid=server-status-context-menu >> testid=start-server-button'` and `control-openhands conversation wait <running-id> --until running,finished,idle --timeout 60`. The status is `running` again and the menu reads `Running` / `Stop Runtime`.",
          "ids": [
            "F07.menu-items-by-state",
            "F07.status-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Hooks (`F07.hooks-modal`).",
          "body": "On `/conversations/<running-id>` (no workspace attached; Paused conversation left the browser there) open the \"...\" menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-hooks-button'` and `control-openhands browser text 'testid=hooks-modal'`. It reads `Available Hooks`, the workspace explanation and `No hooks configured for this conversation.` (the backend workspace root has no `hooks.json`). Run `control-openhands browser network --clear` and `control-openhands browser click 'testid=hooks-modal >> testid=refresh-hooks'`: the text stays and `control-openhands browser network` shows `POST /api/hooks`. `control-openhands browser click 'testid=close-hooks-modal'` closes it.",
          "ids": [
            "F07.hooks-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Skills (`F07.skills-modal`).",
          "body": "Open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-skills-button'`, `control-openhands browser snapshot 'role=dialog'` (`Public skills 12` and one button per skill), `control-openhands browser click 'testid=skills-modal >> role=button[name=\"github Knowledge\"]'`; the row expands to `Triggers` and `Content`. `control-openhands browser press Escape` closes it (`browser count 'testid=skills-modal'` is `0`).",
          "ids": [
            "F07.skills-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Project skills and configured hooks (`F07.skills-modal-project`, `F07.hooks-modal`).",
          "body": "Arrange (outside the UI) a project skill and a hook in the git fixture: `mkdir -p <run>/workspace/qa-f07-repo/.agents/skills/qa-f07-skill <run>/workspace/qa-f07-repo/.openhands`, write `<run>/workspace/qa-f07-repo/.agents/skills/qa-f07-skill/SKILL.md` with front matter `name: qa-f07-skill`, `description: QA fixture skill for F07.`, `triggers: [qaf07trigger]` and the body `QA_F07 skill body.`, and write `<run>/workspace/qa-f07-repo/.openhands/hooks.json` as `{\"pre_tool_use\":[{\"matcher\":\"terminal\",\"hooks\":[{\"type\":\"command\",\"command\":\"true\",\"timeout\":10}]}]}`. Then `control-openhands conversation start --workspace qa-f07-repo --prompt \"Reply with the single word: hi\" --wait --timeout 240` prints `<hooks-id>`; `control-openhands api GET /api/conversations/<hooks-id>` shows that `hook_config.pre_tool_use` holds the `terminal` matcher. On `/conversations/<hooks-id>` open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-skills-button'` and `control-openhands browser snapshot 'role=dialog'`: `Project skills 1` with `button \"qa-f07-skill AgentSkills\"` above `Public skills 12`. `control-openhands browser click 'testid=skills-modal >> role=button[name=\"qa-f07-skill AgentSkills\"]'` expands it to `Triggers qaf07trigger Content QA_F07 skill body.`; `control-openhands browser click 'testid=skills-modal >> testid=refresh-skills'` re-reads the workspace (`browser network` shows `POST /api/skills`). Close with `control-openhands browser press Escape`. Hooks: open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-hooks-button'` and `control-openhands browser snapshot 'testid=hooks-modal'`: one collapsed event, `button \"Pre Tool Use 1 hook\"`. `control-openhands browser click 'testid=hooks-modal >> role=button[name=\"Pre Tool Use 1 hook\"]'` expands it to `Matcher terminal Commands true Type: command Timeout: 10s` (`control-openhands browser screenshot --feature F07.hooks-modal --name workspace-hooks`). Close it with `control-openhands browser click 'testid=close-hooks-modal'` and write `<run>/workspace/qa-f07-repo/.openhands/hooks.json` as `{\"pre_tool_use\":[{\"matcher\":\"terminal\",\"hooks\":[{\"type\":\"command\",\"command\":\"true\",\"timeout\":10}]}],\"stop\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"echo qa-f07-stop\"}]}]}`. A reopen keeps the list it read: run `control-openhands browser network --clear`, open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-hooks-button'` again, then `control-openhands browser network --filter /api/hooks` (no request) and `control-openhands browser snapshot 'testid=hooks-modal'` (still only `button \"Pre Tool Use 1 hook\"`). Refresh re-reads the file: `control-openhands browser click 'testid=hooks-modal >> testid=refresh-hooks'`, then `control-openhands browser network --filter /api/hooks` shows `POST /api/hooks` and the snapshot adds `button \"Stop 1 hook\"`; `control-openhands browser click 'testid=hooks-modal >> role=button[name=\"Stop 1 hook\"]'` expands it to `Matcher * Commands echo qa-f07-stop Type: command Timeout: 60s` (the SDK defaults). Close with `control-openhands browser click 'testid=close-hooks-modal'`.",
          "ids": [
            "F07.skills-modal-project",
            "F07.hooks-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Agent tools (`F07.agent-tools-modal`).",
          "body": "Open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-agent-tools-button'`; the dialog `testid=system-message-modal` has tabs `System Message` (selected, the system prompt) and `Available Tools`. `control-openhands browser click 'role=dialog >> role=tab[name=\"Available Tools\"]'`, `control-openhands browser count 'testid=system-message-modal >> testid=toggle-button'` (`9`), `control-openhands browser click 'testid=system-message-modal >> testid=toggle-button >> nth=0'` expands `terminal` with its description. Close with `control-openhands browser click 'testid=close-system-message-modal'`.",
          "ids": [
            "F07.agent-tools-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Export transcript (`F07.export-transcript`).",
          "body": "Open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=export-transcript-button'`; `control-openhands browser snapshot 'role=dialog'` shows `Export conversation`, radio `Markdown document (.md)` checked, `Standalone web page (.html)`, and checkboxes `Include tool call details` and `Include timestamps` checked. `control-openhands browser click 'testid=confirm-transcript-export'` then `control-openhands browser downloads --last 1 --inspect`: a `<ms>-conversation-<id>.md` (the harness prefixes a timestamp to the suggested `conversation-<id>.md`) lands in `<run>/private/downloads` starting `# QA_F07 renamed`, `**Model:** deepseek/deepseek-flash`, `## User` with `<sub>` timestamps. Reopen, `control-openhands browser click 'role=dialog >> role=radio[name=\"Standalone web page (.html)\"]'`, `control-openhands browser click 'role=dialog >> role=checkbox[name=\"Include timestamps\"]'`, export: the `.html` has `<title>QA_F07 renamed</title>` and no timestamps. Reopen once more: `control-openhands browser click 'testid=cancel-transcript-export'` closes it without a download (the file count in `<run>/private/downloads` is unchanged); `browser press Escape` and `testid=close-transcript-export-modal` close it too.",
          "ids": [
            "F07.export-transcript"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Download data (`F07.download-zip`).",
          "body": "Open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=download-trajectory-button'`, then `control-openhands browser downloads`. A `<ms>-conversation_<id>.zip` lands in `<run>/private/downloads`; `browser downloads --last 1 --inspect` lists `<id-without-dashes>/base_state.json` and `bash_events/`, and every LLM `api_key` in `base_state.json` is masked as asterisks (`**********`).",
          "ids": [
            "F07.download-zip"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Usage (`F07.display-cost`).",
          "body": "Open the menu, `control-openhands browser click 'testid=conversation-name-context-menu >> testid=display-cost-button'`, `control-openhands browser wait 'testid=usage-panel'` and `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` (`true`). The panel shows Context Window, Token Usage and Total Cost (contents are F27).",
          "ids": [
            "F07.display-cost"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Right panel (`F07.right-panel-toggle`).",
          "body": "Run `control-openhands browser click 'testid=right-panel-toggle'` and `control-openhands browser attr 'testid=right-panel-toggle' aria-label`: it alternates `Show panel` / `Hide panel`, and `control-openhands browser wait 'testid=tabs-pane-header' --state hidden --timeout 5000` (after `Show panel`) or `--state visible` (after `Hide panel`) follows; a plain `browser visible` right after the click still reads the old value while the panel animates. With the panel open, `control-openhands browser reload` and `browser attr 'testid=right-panel-toggle' aria-pressed` reads `false` (closed by design; the selected tab is remembered).",
          "ids": [
            "F07.right-panel-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Overview (`F07.overview-toggle`).",
          "body": "Run `control-openhands browser click 'testid=conversation-overview-toggle'`, `control-openhands browser visible 'testid=conversation-overview-panel'` (`true`) and `browser attr 'testid=conversation-overview-toggle' aria-label` (`Hide overview`). Then `control-openhands browser click 'testid=right-panel-toggle'`: `control-openhands browser wait 'testid=conversation-overview-column' --state hidden --timeout 5000` succeeds (an immediate `browser visible` can still read `true`). With the panel open, `control-openhands browser hover 'testid=conversation-overview-toggle'` and `control-openhands browser wait 'testid=conversation-overview-peek' --timeout 5000` shows the peek. Clicking the overview toggle now closes the panel (`aria-pressed` `false`) and shows the overview (`browser wait 'testid=conversation-overview-column' --state visible`). Click the overview toggle once more to restore the closed state.",
          "ids": [
            "F07.overview-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Git actions (`F07.git-actions-menu`).",
          "body": "Run `control-openhands browser click 'testid=conversation-git-actions-toggle'` and `control-openhands browser testids 'testid=conversation-git-actions-menu'` (`-commit`, `-pull`, `-push`, `-create-pr`, `-create-new-branch`, labels `Commits`, `Pull`, `Push`, `Create PR`, `Create New Branch`). `control-openhands browser click 'testid=conversation-git-actions-menu >> testid=conversation-git-actions-commit'` then `control-openhands browser text 'testid=chat-input'`: `Please review the current changes and create a git commit with a concise, descriptive message.`; `conversation events <id> --kinds MessageEvent` shows no new message. Each item replaces the composer text: `-pull` gives `Please pull the latest code from the repository.`, `-create-new-branch` gives `Please create a new branch with a descriptive name related to the work you plan to do.`, and the Push and Create PR prompts name `GitHub` by default. `control-openhands browser press Escape` closes the open menu. Clear the composer with `control-openhands browser fill 'testid=chat-input' ''`.",
          "ids": [
            "F07.git-actions-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Panel route (`F07.panel-route`).",
          "body": "Run `control-openhands browser viewport phone`, open `/conversations/<id>` (`control-openhands browser goto /conversations/<id>`), `control-openhands browser click 'testid=right-panel-toggle' --expect-url '/panel$'` and `control-openhands browser testids --filter mobile` (`conversation-mobile-panel-top`, `conversation-mobile-panel-back`), then `control-openhands browser click 'testid=conversation-mobile-panel-back' --expect-url '/conversations/[0-9a-f-]+$'`. Direct entry at phone width: `control-openhands browser goto /conversations/<id>/panel` and `control-openhands browser wait 'testid=conversation-mobile-panel-back'` succeed (`control-openhands browser screenshot --feature F07.panel-route --name phone-direct`). The panel page serves every width up to 1024 px: after `control-openhands browser viewport tablet` the URL still ends `/panel` and `control-openhands browser count 'testid=conversation-mobile-panel-back'` is `1`. Wider, there is no panel page: run `control-openhands browser viewport desktop`, then `control-openhands browser wait-url '/conversations/[0-9a-f-]+$'` (the app replaces `/panel` with `/conversations/<id>`), `control-openhands browser count 'testid=conversation-mobile-panel-back'` (`0`) and `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` (`true`: the drawer is open beside the chat). A desktop-width visit lands the same way (#18045): `control-openhands browser goto /conversations/<id>/panel`, `control-openhands browser wait-url '/conversations/[0-9a-f-]+$'`, the same count (`0`) and `aria-pressed` (`true`); `control-openhands browser screenshot --feature F07.panel-route --name desktop-redirect` shows the chat with the drawer open beside it.",
          "ids": [
            "F07.panel-route"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Git control bar (`F07.git-control-bar`).",
          "body": "On `/conversations/<git-id>` (`control-openhands browser goto /conversations/<git-id>`; Panel route ends on `/conversations/<id>`) run `control-openhands browser snapshot 'testid=interactive-chat-box'`: `button \"qa-f07-repo\" [disabled]` and the branch text `main`, no Pull/Push. Add a GitHub origin to the fixture (arrange step, outside the UI): `git -C <run>/workspace/qa-f07-repo remote add origin https://github.com/qa-example/qa-f07-repo.git`. Then `control-openhands browser reload` and the snapshot again: link `qa-example/qa-f07-repo` (`https://github.com/qa-example/qa-f07-repo`), link `main` (`…/tree/main`) and buttons `Pull`, `Push`, `Pull Request`.",
          "ids": [
            "F07.git-control-bar"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Pull sends at once (`F07.git-control-bar-send`).",
          "body": "Run `control-openhands browser click 'testid=interactive-chat-box >> role=button[name=\"Pull\"][exact]'` and `control-openhands browser wait 'testid=user-message >> has-text=Please pull the latest code' --timeout 15000`; `conversation status <git-id>` is `running`. Stop it right away with Stop Conversation as in `F07.stop-confirm` (the fake remote cannot be pulled). Push and Pull Request are blocked: they need a real remote and GitHub credentials.",
          "ids": [
            "F07.git-control-bar-send"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Cloud-only (`F07.cloud-only`).",
          "body": "Blocked without a Cloud backend. On local, confirm the absence: the menu testids (see `F07.menu-open`) have no `share-publicly-button`, and the git control bar shows no `Connect Repo` chip. With Cloud: the menu's **Share publicly** switch (`share-publicly-button`, then `copy-share-link-button`, `open-share-link-button`), the repo chip **Connect Repo** opening the Open repository modal (`close-open-repository-modal`), `/conversations/task-<uuid>` while a conversation starts, and the `Unavailable for Archives` tooltips (see F25).",
          "ids": [
            "F07.cloud-only"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Phone (`F07.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser testids 'testid=chat-pane-header'` (adds `sidebar-mobile-menu-toggle`; all header controls visible), open each info dialog from the menu and `control-openhands browser bbox 'testid=skills-modal'` (likewise `hooks-modal`, `system-message-modal`): `insideViewport` is `true` and `pageHorizontalOverflow` `false`. Then `control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'` and `control-openhands browser bbox 'testid=conversation-name-context-menu'`: inside the viewport too (`x` about 145, `width` about 237), and `control-openhands browser screenshot --feature F07.phone --name header-menu` shows every label whole (`Show Available Skills`, `Download conversation data`, `Delete Conversation`). `control-openhands browser press Escape` closes it. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F07.phone"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F08",
      "title": "Workspace drawer: files and changes",
      "file": "F08-workspace-files-and-changes.md",
      "page": "F08-workspace-files-and-changes.html",
      "sha256": "d7bdc4c7e20d1ccd60ed96c69480bc405b4f4c835e913b1ec0cb1217daa1c0c1",
      "behaviors": [
        {
          "id": "F08.panel-toggle",
          "description": "the chat-header toggle (`Show panel`/`Hide panel`, `aria-pressed`) opens and closes the drawer; the first open shows Files."
        },
        {
          "id": "F08.tab-bar",
          "description": "the tab bar (Files, Commits, Planner, Terminal, Browser, Usage; Task list only when the agent has one) switches the drawer body, and only the active tab shows its label. Clicking the active tab closes the drawer."
        },
        {
          "id": "F08.drawer-state-reload",
          "description": "after a reload the drawer starts closed, and reopening it restores the last selected tab for that conversation."
        },
        {
          "id": "F08.tabs-menu",
          "description": "the ellipsis menu at the end of the tab bar lists every tab. Choosing a row opens that tab and closes the menu, and so does clicking outside."
        },
        {
          "id": "F08.tabs-pin",
          "description": "the pill on each menu row pins or unpins a tab. Unpinning the active tab selects another pinned one, and the choice persists across reloads."
        },
        {
          "id": "F08.drawer-resize",
          "description": "dragging the chat/drawer divider resizes both panes (chat 30–80 %, default 50 %, stored in `desktop-layout-panel-width`)."
        },
        {
          "id": "F08.vscode-link",
          "description": "a `VSCode` button at the right of the tab bar opens the editor in a new window; it is hidden when the runtime has no editor. (#17660 removed it and #18048 restored it.)"
        },
        {
          "id": "F08.archived-disabled",
          "description": "on an archived conversation the toggle is disabled with the tooltip `Unavailable for Archives`."
        },
        {
          "id": "F08.phone-panel-page",
          "description": "at 1024 px or narrower the toggle navigates to `/conversations/<id>/panel`, a full-screen page with a Back chevron, the same tab bar and the tab body."
        },
        {
          "id": "F08.panel-direct-url",
          "description": "opening `/conversations/<id>/panel` directly renders the panel page at 1024 px or narrower, and Back returns to the chat; wider, the URL is replaced with `/conversations/<id>` and the drawer opens beside the chat."
        },
        {
          "id": "F08.files-workspace-path",
          "description": "`Workspace: <working_dir>` with the full path in its tooltip; the copy button turns into `Copied to clipboard` for about 2 s."
        },
        {
          "id": "F08.files-tree",
          "description": "a tree of folders, collapsed at first, and files. Clicking a file selects it and adds it to the open-file strip."
        },
        {
          "id": "F08.files-empty-workspace",
          "description": "a workspace with no files shows `No files in workspace`."
        },
        {
          "id": "F08.files-tree-toggle",
          "description": "the list-tree button hides or shows the tree, and the choice persists per conversation. The tree is 224 px wide by default."
        },
        {
          "id": "F08.files-tree-resize",
          "description": "dragging the divider between the tree and the preview resizes the tree (160–480 px, default 224) and the width persists across reloads (`files-tab-tree-width` in localStorage)."
        },
        {
          "id": "F08.files-open-tabs",
          "description": "the open-file strip, each tab with a close X. Closing the selected file selects a neighbour. Open files and the selection survive a reload and do not leak into other conversations."
        },
        {
          "id": "F08.files-no-selection",
          "description": "with no file selected the pane reads `Select a file to view its contents` and the Rich/Plain toggle is absent."
        },
        {
          "id": "F08.files-rich-plain",
          "description": "Rich highlights code, renders Markdown and shows HTML in an iframe sandboxed without scripts. Plain shows highlighted source. The mode persists across reloads."
        },
        {
          "id": "F08.files-fallbacks",
          "description": "images render as `<img>` in Rich mode. Files with NUL bytes show `Binary file – preview not available`, and Office files show `Preview isn't available for Word files.` (or PowerPoint/Excel)."
        },
        {
          "id": "F08.files-load-error",
          "description": "a selected file that can no longer be read shows the error (`Failed to read <path>: 404`)."
        },
        {
          "id": "F08.files-open-new-window",
          "description": "the external-link icon opens the file from the workspace fileserver in a new browser tab."
        },
        {
          "id": "F08.files-refresh",
          "description": "`Refresh files` re-reads the file list and the selected file's content."
        },
        {
          "id": "F08.files-auto-refresh",
          "description": "files the agent writes appear without a manual refresh."
        },
        {
          "id": "F08.files-open-from-chat",
          "description": "clicking a workspace path in an agent (or user) message opens the drawer on Files with that file selected."
        },
        {
          "id": "F08.files-open-from-tool-chip",
          "description": "the path chip on a file-editor tool card (absolute path) opens the drawer on Files with that file selected."
        },
        {
          "id": "F08.commits-states",
          "description": "with no commits and no changes the Commits tab shows `No commits yet`. While the runtime is inactive it shows `Waiting for runtime to start...`, and while loading `Loading changes...`."
        },
        {
          "id": "F08.uncommitted-changes",
          "description": "the top row reads `-`, `Uncommitted` and a file count; expanding it lists the changed paths."
        },
        {
          "id": "F08.commit-rows",
          "description": "each commit row shows the short SHA, subject, author (only when there are several authors) and relative time. Rows are single-open accordions, and new agent commits appear without a reload."
        },
        {
          "id": "F08.commits-cap",
          "description": "the history loads the latest 50 commits; a longer history ends with `Showing the latest 50 commits`."
        },
        {
          "id": "F08.diff-view-modes",
          "description": "expanding a changed file shows its diff; the view-mode buttons (`Old version`, `Diff`, `New version`) switch between old, diff and new without page errors."
        },
        {
          "id": "F08.diff-markdown-preview",
          "description": "a Markdown file in new (or old) mode renders as a prose preview."
        },
        {
          "id": "F08.diff-deleted-file",
          "description": "a deleted working-tree file reads `This file was deleted. Its previous contents are no longer available for preview.`"
        },
        {
          "id": "F08.changes-from-overview",
          "description": "the overview's Changes row opens the drawer on Commits with Uncommitted expanded and closes the overview."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open the drawer (`F08.panel-toggle`).",
          "body": "Run `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` (`false`) and `control-openhands browser tooltip 'testid=right-panel-toggle'` (`Show panel`). Then run `control-openhands browser click 'testid=right-panel-toggle'`, followed by `control-openhands browser attr 'testid=right-panel-toggle' aria-label` and `control-openhands browser visible 'testid=files-tab'`. The label is `Hide panel`, `aria-pressed` is `true`, and Files is visible with the workspace path and tree (`browser screenshot --feature F08.panel-toggle --name drawer-open-files`).",
          "ids": [
            "F08.panel-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Switch and close by tab (`F08.tab-bar`).",
          "body": "Run `control-openhands browser click 'testid=conversation-tab-commits'` and `control-openhands browser wait 'testid=commit-list' --state visible --timeout 10000` (the first open shows `Loading changes...` for a moment, so an immediate `browser visible` can read `false`). Click `testid=conversation-tab-commits` again. `right-panel-toggle` `aria-pressed` becomes `false` and `control-openhands browser visible 'testid=tabs-pane-header'` is `false`. Reopen with `right-panel-toggle`.",
          "ids": [
            "F08.tab-bar"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Reload (`F08.drawer-state-reload`).",
          "body": "With Commits selected, run `control-openhands browser reload`, then `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` (`false`; the drawer is closed). Click `testid=right-panel-toggle`; `control-openhands browser visible 'testid=commit-list'` is `true`.",
          "ids": [
            "F08.drawer-state-reload"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Overflow menu (`F08.tabs-menu`).",
          "body": "Run `control-openhands browser click 'testid=tabs-pane-header >> testid=ellipsis-button'` and `control-openhands browser testids --filter conversation-tabs-menu`; there is one open row and one pin row per tab. Run `control-openhands browser click 'testid=conversation-tabs-menu-open-usage'`; then `control-openhands browser count 'testid=conversation-tabs-menu-open-usage'` is `0` (menu closed) and the bar's active label is `Usage`. Reopen the menu and run `control-openhands browser mouse-click 560 600` (over the chat); the menu count is `0`.",
          "ids": [
            "F08.tabs-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Pin/unpin (`F08.tabs-pin`).",
          "body": "With Usage active, open the menu and run `control-openhands browser click 'testid=conversation-tabs-menu-pin-usage'`, then `control-openhands browser attr 'testid=conversation-tabs-menu-pin-usage' aria-label` (`Pin tab to bar`; `aria-pressed` is `false`). Close the menu with `browser mouse-click 560 600`, then run `control-openhands browser count 'testid=tabs-pane-header >> testid=conversation-tab-usage'`. The count is `0` and Planner is now active. Run `control-openhands browser reload` and reopen the drawer; the count is still `0`. To restore, open the menu, click `testid=conversation-tabs-menu-pin-usage` (label `Unpin tab from bar`), then `control-openhands browser click 'testid=conversation-tabs-menu-open-files'`. The usage tab count is `1` again.",
          "ids": [
            "F08.tabs-pin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Resize (`F08.drawer-resize`).",
          "body": "The divider has no test id; its grip is the first `.cursor-ew-resize` on the page (the second is the Files tree divider). With the drawer open, `control-openhands browser bbox 'testid=chat-interface'` and `control-openhands browser bbox 'testid=tabs-pane-header'` are about 570 px wide each and `control-openhands browser eval \"localStorage.getItem('desktop-layout-panel-width')\"` is `\"50\"`. Run `control-openhands browser drag '.cursor-ew-resize >> nth=0' --by 200,0`: the chat is 770 px, the drawer about 369 px and the key about `67.5`. `--by 900,0` clamps at `\"80\"`, `--by -1200,0` at `\"30\"` (chat 342 px), and after `browser reload` plus the toggle the chat is still 342 px. Restore with `control-openhands browser drag '.cursor-ew-resize >> nth=0' --by 228,0` (chat 570 px, key `\"50\"`).",
          "ids": [
            "F08.drawer-resize"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "VS Code link (`F08.vscode-link`).",
          "body": "Blocked in a runtime without a running VS Code server: `control-openhands browser count 'testid=drawer-vscode-link'` is `0` (hidden, as designed), and `control-openhands api GET /api/vscode/status` explains why (`enabled: false`, or `running: false` when the agent-server has no editor binary, as in these stacks; see F26 Gotchas). It needs a runtime with `enable_vscode` and a running editor, on an origin that serves `/vscode/`.",
          "ids": [
            "F08.vscode-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Archived (`F08.archived-disabled`).",
          "body": "Blocked locally: archived status comes from a Cloud sandbox (`sandbox_status`). On such a conversation, `browser enabled 'testid=right-panel-toggle'` should be `false` and `browser tooltip 'testid=right-panel-toggle'` should read `Unavailable for Archives`.",
          "ids": [
            "F08.archived-disabled"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Workspace path (`F08.files-workspace-path`).",
          "body": "Run `control-openhands browser attr 'testid=files-tab-workspace-path-value' title` (ends in `/workspace/qa-repo`). Click `testid=files-tab-workspace-path >> testid=copy-to-clipboard`. Immediately afterwards, `control-openhands browser attr 'testid=files-tab-workspace-path >> testid=copy-to-clipboard' aria-label` is `Copied to clipboard` and `browser enabled` on the same button is `false`. `control-openhands browser clipboard` returns the full path. About 3 s later the label is `Copy to clipboard` again.",
          "ids": [
            "F08.files-workspace-path"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Tree (`F08.files-tree`).",
          "body": "Run `control-openhands browser click 'testid=files-tab-refresh'` first (see Gotchas: files the agent made with bash are missing until a refresh). Then `control-openhands browser attr 'testid=file-tree-dir-src' aria-expanded` (`false`), `control-openhands browser click 'testid=file-tree-dir-src'` (now `true`) and `control-openhands browser click 'testid=file-tree-file-src/calc.py'`. `control-openhands browser text 'testid=file-content-viewer-highlighted'` shows the 4 lines `def add`…`return a - b`, and `testid=file-quick-row-item-src/calc.py` exists.",
          "ids": [
            "F08.files-tree"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Tree toggle (`F08.files-tree-toggle`).",
          "body": "Run `control-openhands browser bbox 'testid=files-tab-tree'` (width `224`). Then `control-openhands browser click 'testid=file-quick-row-tree-toggle'`, `control-openhands browser attr 'testid=file-quick-row-tree-toggle' aria-label` (`Show file tree`) and `control-openhands browser count 'testid=files-tab-tree'` (`0`). Reload and reopen the drawer: the count is still `0`. Click the toggle again to restore (count `1`).",
          "ids": [
            "F08.files-tree-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Tree resize (`F08.files-tree-resize`).",
          "body": "Run `control-openhands browser drag 'testid=files-tab-tree-resize-handle >> .cursor-ew-resize' --by 100,0`; `browser bbox 'testid=files-tab-tree'` is `324` wide and `control-openhands browser eval \"localStorage.getItem('files-tab-tree-width')\"` is `\"324\"`. `--by 500,0` clamps at `480`, `--by -600,0` at `160`, and the width survives `browser reload` plus the toggle. Restore with `--by 64,0` (`224`).",
          "ids": [
            "F08.files-tree-resize"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Rich/Plain (`F08.files-rich-plain`).",
          "body": "Click `testid=file-tree-file-qa-notes.md`; `testid=file-content-viewer-markdown` exists. Click `testid=file-tree-file-qa-page.html`, then run `control-openhands browser attr 'testid=file-content-viewer-iframe' sandbox` (`allow-same-origin`) and `control-openhands browser eval \"document.querySelector('[data-testid=file-content-viewer-iframe]').contentDocument.body.innerText\"` (`QA page`). Run `control-openhands browser click 'testid=files-tab-content-mode-toggle-option-plain'`; `control-openhands browser text 'testid=files-tab-content >> testid=file-content-viewer-highlighted'` reads `1<h1>QA page</h1>`. After `browser reload` and reopening, `control-openhands browser attr 'testid=files-tab-content-mode-toggle-option-plain' aria-checked` is `true`. Switch back with `testid=files-tab-content-mode-toggle-option-rich`.",
          "ids": [
            "F08.files-rich-plain"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Open in new window (`F08.files-open-new-window`).",
          "body": "With `qa-page.html` selected, run `control-openhands browser click 'testid=files-tab-open-in-new-window'` and `control-openhands browser tabs`. Page 1's URL is `/api/conversations/<id>/workspace/qa-page.html?v=<n>`. Run `control-openhands browser tab 1`, `control-openhands browser text 'body'` (`QA page`) and `control-openhands browser close-tab 1`.",
          "ids": [
            "F08.files-open-new-window"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Open-file strip (`F08.files-open-tabs`).",
          "body": "With several files opened, run `control-openhands browser attr 'testid=file-quick-row-item-qa-page.html' title` (the path). Select `qa-notes.md` with `control-openhands browser click 'testid=file-quick-row-item-qa-notes.md'`, then close it with `control-openhands browser click 'testid=file-quick-row-close-qa-notes.md'`. `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=file-quick-row-item-]')].filter(e=>e.getAttribute('aria-selected')==='true').map(e=>e.dataset.testid)\"` names the neighbour. Run `browser reload`, reopen the drawer and `control-openhands browser text 'testid=file-quick-row'`: the same files remain. Opening a different conversation (see Commits empty state) shows no quick-row items: `browser text 'testid=file-quick-row'` is empty. `browser testids` omits strip tabs scrolled out of view; count them with `browser eval \"[...document.querySelectorAll('[data-testid^=file-quick-row-item-]')].length\"`.",
          "ids": [
            "F08.files-open-tabs"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "No selection (`F08.files-no-selection`).",
          "body": "Close every open file with its `file-quick-row-close-<path>` button. `control-openhands browser text 'testid=files-tab-content'` reads `Select a file to view its contents`, and `control-openhands browser count 'testid=files-tab-content-mode-toggle'` is `0`.",
          "ids": [
            "F08.files-no-selection"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Second agent turn: commit, binaries, auto-refresh (`F08.files-auto-refresh`, `F08.files-refresh`).",
          "body": "Keep Files open with `qa-notes.md` selected in Rich mode. Write `qa-f08-2.txt` with: ``In the current workspace directory run exactly this one bash command and then reply only \"done\": printf 'BIN\\000\\001' > qa-nul.bin && printf 'PK\\003\\004\\000\\000' > qa-real.docx && python3 -c \"import base64;open('qa-dot.png','wb').write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='))\" && printf '\\n- edited by agent\\n' >> qa-notes.md && git add qa-notes.md && git -c user.name=QA-Agent -c user.email=qa@example.invalid commit -qm 'QA agent commit' && git log --oneline``. Run `control-openhands conversation start --stay --prompt \"$(cat qa-f08-2.txt)\" --wait --timeout 240`, then `control-openhands browser testids 'testid=files-tab-tree'` and `control-openhands browser text 'testid=file-content-viewer-markdown'`. Expected: `qa-nul.bin`, `qa-real.docx` and `qa-dot.png` are listed, and the preview ends with `edited by agent`. Known failure (reproduced 2026-10-06): neither changes until Refresh, because the auto-refresh hook skips the file queries for shell commands on purpose (see Gotchas). Repro candidate, not yet filed; a report would ask to revisit that choice. Click `testid=files-tab-refresh` once: the list gains the three files and the open preview ends with `edited by agent`.",
          "ids": [
            "F08.files-auto-refresh",
            "F08.files-refresh"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Fallbacks (`F08.files-fallbacks`).",
          "body": "After a refresh, click `testid=file-tree-file-qa-nul.bin` (`testid=file-content-viewer-binary-fallback`, text `Binary file – preview not available`). Then click `testid=file-tree-file-qa-real.docx` (`testid=file-content-viewer-unsupported-document`, `Preview isn't available for Word files.`). Then click `testid=file-tree-file-qa-dot.png` and run `control-openhands browser eval \"document.querySelector('[data-testid=file-content-viewer-image] img').naturalWidth\"` (`1`). In Plain mode the PNG shows the binary fallback. `qa-deck.pptx` and `qa-blob.bin` from the first prompt have no NUL byte and render as text (see Gotchas).",
          "ids": [
            "F08.files-fallbacks"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "File-editor edits auto-refresh; deleted file errors (`F08.files-auto-refresh`, `F08.files-load-error`).",
          "body": "Keep Files open with `testid=file-tree-file-qa-blob.bin` selected. Run `control-openhands conversation start --stay --prompt 'Do two steps, then reply only \"done\". Step 1: run the bash command: rm qa-blob.bin. Step 2: use your file editor tool (not bash) to create the file qa-editor.txt in the current workspace directory with the single line: created by file editor' --wait --timeout 240`. Without clicking Refresh, `control-openhands browser count 'testid=file-tree-file-qa-editor.txt'` is `1` and `browser count 'testid=file-tree-file-qa-blob.bin'` is `0` (the editor observation refetches the whole list). `qa-blob.bin` stays in the open-file strip, and the same observation re-reads the open file: `control-openhands browser text 'testid=file-content-viewer-error'` reads `Failed to read qa-blob.bin: 404` and `control-openhands browser network --filter qa-blob` shows the 404 (`browser screenshot --feature F08.files-load-error --name deleted-open-file`).",
          "ids": [
            "F08.files-auto-refresh",
            "F08.files-load-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Open from chat (`F08.files-open-from-chat`).",
          "body": "Run `control-openhands conversation start --stay --prompt 'Without running any tool, reply with exactly this text and nothing else: See `src/calc.py` for the code.' --wait --timeout 180`. Close the drawer, then run `control-openhands browser click 'testid=agent-message >> nth=-1 >> testid=markdown-file-path-link'`. `right-panel-toggle` `aria-pressed` turns `true`, `testid=files-tab` is visible and `file-quick-row-item-src/calc.py` is the selected tab. The same path in the prompt bubble works too: close the drawer and click `'testid=user-message >> testid=markdown-file-path-link'`. Phone: select another file (`control-openhands browser click 'testid=file-quick-row-item-qa-notes.md'`), run `control-openhands browser viewport phone`, then the same agent-message click with `--expect-url '/panel$'`: the URL becomes `/conversations/<id>/panel`, `browser visible 'testid=files-tab'` is `true` and `control-openhands browser attr 'testid=file-quick-row-item-src/calc.py' aria-selected` is `true` (`browser screenshot --feature F08.files-open-from-chat --name phone-after-click`). Return with `control-openhands browser viewport desktop`: the app replaces `/panel` with `/conversations/<id>` (`control-openhands browser wait-url '/conversations/[^/]+$'`) and shows the drawer beside the chat.",
          "ids": [
            "F08.files-open-from-chat"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Tool-card path chip (`F08.files-open-from-tool-chip`).",
          "body": "After the file-editor turn above, close the drawer and expand its tool group and card: `control-openhands browser click 'text=2 actions completed'`, then `control-openhands browser click 'role=region[name=\"Collapse actions\"] >> role=button[name=\"Expand\"] >> nth=-1'` (the `Create qa-editor.txt` card). `control-openhands browser text 'testid=file-path-chip'` is the absolute `<run>/workspace/qa-repo/qa-editor.txt`. Run `control-openhands browser click 'testid=file-path-chip'`: `right-panel-toggle` `aria-pressed` turns `true`, the selected strip tab is `file-quick-row-item-qa-editor.txt` (relative) and `browser text 'testid=files-tab-content'` ends with `created by file editor`.",
          "ids": [
            "F08.files-open-from-tool-chip"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Commits list (`F08.uncommitted-changes`, `F08.commit-rows`).",
          "body": "Click `testid=conversation-tab-commits` and run `control-openhands browser text 'testid=commit-list'`. After the first prompt it reads `-`, `Uncommitted`, `6 files`, then `4bf8272`-style SHA, `Initial fixture commit` and `… ago`; after the second prompt `QA agent commit` appears above it, with author names `QA-Agent` and `QA Fixture` shown because there are now two authors. Run `control-openhands browser click 'testid=uncommitted-changes-row-toggle'` and `control-openhands browser text 'testid=uncommitted-changes-row-content'` (the changed paths; `aria-expanded` is `true`). Run `control-openhands browser click 'testid=commit-row >> has-text=Initial fixture commit >> testid=commit-row-toggle'`; Uncommitted's `aria-expanded` turns `false`, and `control-openhands browser text 'testid=commit-row-content'` lists `README.md`, `src/calc.py`, `src/test_calc.py`. After the third prompt the Uncommitted count is `8 files`.",
          "ids": [
            "F08.uncommitted-changes",
            "F08.commit-rows"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Diff modes (`F08.diff-view-modes`).",
          "body": "In the expanded Uncommitted row, run `control-openhands browser click 'testid=uncommitted-changes-row-content >> testid=file-diff-viewer-outer >> has-text=src/calc.py >> testid=collapse'`. `control-openhands browser text 'testid=uncommitted-changes-row-content >> testid=file-diff-viewer-outer >> has-text=src/calc.py >> testid=editor-container'` shows the added `def sub` lines, and `view-mode-diff` has `aria-pressed` `true`. The row's buttons are named: `control-openhands browser snapshot 'testid=uncommitted-changes-row-content >> testid=file-diff-viewer-outer >> has-text=src/calc.py'` lists `button \"Old version\"`, `button \"Diff\" [pressed]`, `button \"New version\"` and `button \"Collapse\"` (the same `collapse` button reads `Expand` while the row is collapsed), so `… >> role=button[name=\"Old version\"]` works as well as the test id. Click `… >> testid=view-mode-old` (2 lines), then `… >> testid=view-mode-new` (4 lines), then `view-mode-diff`. Run `control-openhands browser errors --app-only` before and after: each collapse and each diff→old switch adds `TextModel got disposed before DiffEditorWidget model got reset` (fail, #17567).",
          "ids": [
            "F08.diff-view-modes"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Markdown preview (`F08.diff-markdown-preview`).",
          "body": "In this order `qa-notes.md` is already committed, so check it in the `QA agent commit` row: `control-openhands browser click 'testid=commit-row >> has-text=QA agent commit >> testid=commit-row-toggle'`, then with `F='testid=commit-row >> has-text=QA agent commit >> testid=file-diff-viewer-outer >> has-text=qa-notes.md'` click `\"$F >> testid=collapse\"` and `\"$F >> testid=view-mode-new\"`. `control-openhands browser count \"$F >> testid=markdown-preview\"` is `1`, rendering `<h1>QA notes</h1>`, **world** in bold and the `edited by agent` list item. (Before the second prompt the same check works under `testid=uncommitted-changes-row-content`.) Re-expand Uncommitted with `uncommitted-changes-row-toggle` for the next bullet.",
          "ids": [
            "F08.diff-markdown-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Deleted file (`F08.diff-deleted-file`).",
          "body": "Expand `src/test_calc.py` in Uncommitted. `control-openhands browser text 'testid=uncommitted-changes-row-content >> testid=file-diff-viewer-outer >> has-text=src/test_calc.py >> testid=file-deleted-message'` reads `This file was deleted. Its previous contents are no longer available for preview.`",
          "ids": [
            "F08.diff-deleted-file"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "From the overview (`F08.changes-from-overview`).",
          "body": "With the drawer closed, run `control-openhands browser click 'testid=conversation-overview-toggle'` and `control-openhands browser click 'testid=conversation-overview-diffs'` (its siblings `conversation-overview-diffs-additions`/`-deletions` show `+N`/`-N`; deleted files are not counted). `right-panel-toggle` `aria-pressed` is `true`, `testid=commit-list` is visible, `uncommitted-changes-row-toggle` has `aria-expanded` `true` and `testid=conversation-overview-panel` is hidden. After `browser reload` and the toggle, Commits is still selected.",
          "ids": [
            "F08.changes-from-overview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Empty states (`F08.commits-states`, `F08.files-empty-workspace`).",
          "body": "Run `control-openhands fixture folder`, which makes `<run>/workspace/qa-folder`, a plain folder (no `.git`) with `notes.md`; by the time the conversation has run it holds an empty `.git` (seen live; not created by the prompt), so it ends up a repo with no commits. Run `control-openhands browser goto /` (the browser is on the qa-repo conversation) and pick it with the workspace picker as in Preconditions (`folder-browser-entry-qa-folder`). Then run `control-openhands conversation start --stay --prompt 'Run exactly this bash command, then reply only \"done\": rm notes.md && git status --short' --wait --timeout 180`. Open the drawer and click `testid=conversation-tab-commits`; `control-openhands browser count 'text=\"No commits yet\"'` is `1` (`browser screenshot --feature F08.commits-states --name no-commits`). On Files, click `testid=files-tab-refresh`; `control-openhands browser text 'testid=files-tab-tree'` reads `No files in workspace`. `Loading changes...` (`testid=commits-tab-status`) shows briefly on the first Commits open after a load: on `/conversations/<id>` with Files selected, `browser reload`, open the drawer, then `control-openhands browser click 'testid=conversation-tab-commits' --observe 'testid=commits-tab-status' --observe-ms 4000` records `Loading changes...` for a few ms. `Waiting for runtime to start...` (agent state init/loading/error) was not reached: `service stop agent-server` and `restart` both left `No commits yet` on screen.",
          "ids": [
            "F08.commits-states",
            "F08.files-empty-workspace"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Commit cap (`F08.commits-cap`).",
          "body": "Arrange more than 50 commits in the fixture (read-only for the UI): `for i in $(seq 1 50); do git -C \"$OH_VERIFY_RUN/workspace/qa-repo\" -c user.name=QA-Bulk -c user.email=qa@example.invalid commit -q --allow-empty -m \"QA bulk $i\"; done`. On the qa-repo conversation (`control-openhands browser goto /conversations/<id>`; Empty states left the qa-folder conversation) run `browser reload`, open the drawer on Commits and wait for `testid=commit-list`. `control-openhands browser text 'testid=commit-list-cap-notice'` reads `Showing the latest 50 commits` and `browser count 'testid=commit-row'` is `50`. Do this last: it changes the commit list the other bullets read.",
          "ids": [
            "F08.commits-cap"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Phone panel page (`F08.phone-panel-page`).",
          "body": "Run `control-openhands browser viewport phone` on `/conversations/<id>`, then `control-openhands browser click 'testid=right-panel-toggle' --expect-url '/panel$'`. `control-openhands browser bbox 'testid=files-tab'` gives `insideViewport` `true` and `pageHorizontalOverflow` `false`, with Commits diffs usable at 390 px (`browser screenshot --feature F08.phone-panel-page --name panel`). `control-openhands browser attr 'testid=conversation-mobile-panel-back' aria-label` is `Back`. Run `control-openhands browser click 'testid=conversation-mobile-panel-back' --expect-url '/conversations/[^/]+$'`; the chat is visible. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F08.phone-panel-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Direct panel URL (`F08.panel-direct-url`).",
          "body": "At desktop run `control-openhands browser goto /conversations/<id>/panel`. The app replaces the URL with `/conversations/<id>` (the `goto` result already shows it; `control-openhands browser url` confirms), `control-openhands browser count 'testid=conversation-mobile-panel-back'` is `0`, `browser visible 'testid=chat-interface'` is `true` and `right-panel-toggle` `aria-pressed` is `true` (the drawer opens beside the chat; `browser screenshot --feature F08.panel-direct-url --name desktop-redirect`). Then run `control-openhands browser viewport phone` and the same `goto`: the URL keeps `/panel`, the `conversation-mobile-panel-back` count is `1` and `chat-interface` is not visible. `control-openhands browser click 'testid=conversation-mobile-panel-back' --expect-url '/conversations/[^/]+$'` returns to the chat. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F08.panel-direct-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-031",
          "label": "Errors.",
          "body": "After each group run `control-openhands browser errors --app-only`. The Monaco disposal error above belongs to this family. 404s on `/api/llm/balance` and `.agents_tmp/` downloads come from the Usage and Planner tabs (F27).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F09",
      "title": "Settings shell and navigation",
      "file": "F09-settings-shell.md",
      "page": "F09-settings-shell.html",
      "sha256": "4c44e9978417d72736c8f8d0f5a5f069200138495e2b450aa4e3653a349ed240",
      "behaviors": [
        {
          "id": "F09.entry-gear",
          "description": "the sidebar gear opens Settings on the Agent page; the gear is marked active on every settings URL; at phone width the drawer's gear opens the hub and closes the drawer."
        },
        {
          "id": "F09.entry-collapsed-rail",
          "description": "with the sidebar collapsed, the rail's Settings icon opens the Agent page."
        },
        {
          "id": "F09.entry-command-menu",
          "description": "the command menu's Settings group (Settings, Agent settings, LLM profiles, Model Router, Condenser, Agent Context, Verification, Application, Secrets settings) opens the matching page."
        },
        {
          "id": "F09.command-menu-coverage",
          "description": "every page in the settings nav, including Model Router and Agent Context, can be found in the command menu and opens from it."
        },
        {
          "id": "F09.entry-deep-links",
          "description": "the Home \"Set up LLM\" banner, the checklist items \"Add LLM API key\" and \"Customize your agent\", the composer LLM picker's \"LLM Profiles\" link and the + menu's \"Manage agent profiles\" link each land on `/settings/llm` or `/settings/agents`."
        },
        {
          "id": "F09.index-redirect",
          "description": "on desktop, `/settings` replaces itself with `/settings/agents` (Back skips `/settings`)."
        },
        {
          "id": "F09.locked-cloud-landing",
          "description": "on a canvas locked to a Cloud host, `/settings` lands on `/settings/app` instead."
        },
        {
          "id": "F09.legacy-agent-redirect",
          "description": "the retired `/settings/agent` URL replaces itself with `/settings/agents`."
        },
        {
          "id": "F09.hidden-llm-redirect",
          "description": "with the `hide_llm_settings` feature flag, the LLM link disappears and `/settings/llm` redirects to `/settings/agents`."
        },
        {
          "id": "F09.desktop-nav",
          "description": "the desktop nav lists the eight pages in order, exactly one link is `aria-current=\"page\"`, and the nav stays pinned while the page content scrolls."
        },
        {
          "id": "F09.page-header",
          "description": "each page shows its nav title as an H2 with a grey subline; Secrets draws its own header instead, and the Agent profile editor hides the shell header until you leave it."
        },
        {
          "id": "F09.landmarks",
          "description": "the settings screen exposes a single `main` landmark, on desktop pages and on the phone hub."
        },
        {
          "id": "F09.phone-hub",
          "description": "below 1024 px (phones and tablets), `/settings` stays put and shows a \"Settings\" heading, the eight links, the version card and the synced note, with no page header and no horizontal overflow."
        },
        {
          "id": "F09.hub-resize-redirect",
          "description": "widening the window to ≥1024 px while the phone hub is open replaces `/settings` with `/settings/agents`; narrowing on a sub-page keeps its URL and adds the Back chevron."
        },
        {
          "id": "F09.breakpoint-sweep",
          "description": "at 767, 768, 820, 1023, 1024 and 1440 px, `/settings` is the hub below 1024 px and redirects to the desktop nav from 1024 px up, and the Application page's `main` is at least 340 px wide with no horizontal overflow."
        },
        {
          "id": "F09.phone-back",
          "description": "at phone width each settings page shows a Back chevron (label \"Settings\") in the top bar that returns to the hub."
        },
        {
          "id": "F09.synced-badge",
          "description": "the nav footer reads \"These settings are synced from Local backend (<origin>)\" for the active backend."
        },
        {
          "id": "F09.cloud-links",
          "description": "on a Cloud backend only, the nav adds \"Integrations\" and \"All Cloud Settings\" external links; on a local backend neither exists."
        },
        {
          "id": "F09.update-card",
          "description": "the \"Agent Canvas / Version x.y.z\" card opens a dialog; its \"Up to date\" / \"Update\" badge appears only once the npm check succeeded."
        },
        {
          "id": "F09.update-modal",
          "description": "the dialog shows the version, a \"Check for updates\" button that re-runs the check, and an inline status; a failed check reads \"Couldn't check for updates. Try again later.\"; Close and Escape dismiss it."
        },
        {
          "id": "F09.update-up-to-date",
          "description": "when the npm check succeeds and the client is current, the card badge reads \"Up to date\", the dialog status reads \"You're running the latest version.\" with a \"What's new\" link to the GitHub releases page (new tab), and \"Check for updates\" briefly shows \"Checking for updates…\"."
        },
        {
          "id": "F09.update-available",
          "description": "when npm has a newer version, the dialog shows \"Version … is available.\", a \"What's new\" link, and npm/Docker command tabs with a copy button."
        },
        {
          "id": "F09.update-modal-phone",
          "description": "the update dialog fits a 390 px (and a 320 px) viewport with its title and Close button on screen, and Close dismisses it."
        },
        {
          "id": "F09.sidebar-version-tile",
          "description": "the main sidebar shows an \"Agent Canvas\" version tile only when an update is available."
        },
        {
          "id": "F09.unknown-subpath",
          "description": "an unknown `/settings/<x>` URL shows the app's \"Page not found\" page inside the app shell (sidebar and a Home link), without the settings shell."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Gear entry and index redirect (`F09.entry-gear`, `F09.index-redirect`).",
          "body": "From `/`, run `control-openhands browser click 'testid=backend-selector-settings-link'` then `control-openhands browser wait-url '/settings/agents$'`. Then `control-openhands browser attr 'testid=sidebar-settings-/settings/agents' aria-current` is `page` and `control-openhands browser attr 'testid=backend-selector-settings-link' data-active` is `true` (it is `false` again on `/`). Run `control-openhands browser back` then `control-openhands browser url`: the URL is `/`, not `/settings`, which proves the redirect replaced history. Direct entry: `control-openhands browser goto /settings` returns `\"url\": \".../settings/agents\"`.",
          "ids": [
            "F09.entry-gear",
            "F09.index-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Collapsed rail (`F09.entry-collapsed-rail`).",
          "body": "From `/` (`control-openhands browser goto /`; the previous bullet ends on `/settings/agents`, where the URL wait would pass at once) run `control-openhands browser click 'testid=sidebar-collapse-toggle'`, `control-openhands browser click 'testid=collapsed-settings-link'`, `control-openhands browser wait-url '/settings/agents$'` and `control-openhands browser screenshot --feature F09.entry-collapsed-rail --name rail`. The screenshot shows the icon rail, the settings nav with Agent highlighted. Restore with `control-openhands browser click 'testid=sidebar-collapse-toggle'` (the label flips back to \"Collapse sidebar\").",
          "ids": [
            "F09.entry-collapsed-rail"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Command menu (`F09.entry-command-menu`).",
          "body": "First rest the pointer off the list with `control-openhands browser hover 'testid=command-menu-trigger'`: a hover can move the menu's selection, and Enter runs the selected option (F02 Gotchas). For each title run `control-openhands browser goto /`, `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' 'LLM profiles'`, `control-openhands browser press Enter`, `control-openhands browser wait-url '/settings/llm$'`. Expected targets: `Settings` → `/settings/agents`, `Agent settings` → `/settings/agents`, `LLM profiles` → `/settings/llm`, `Model Router` → `/settings/meta-llm`, `Condenser settings` → `/settings/condenser`, `Agent Context` → `/settings/agent-context`, `Verification settings` → `/settings/verification`, `Application settings` → `/settings/app`, `Secrets settings` → `/settings/secrets`. `control-openhands browser snapshot 'testid=command-menu >> role=listbox'` after typing `LLM profiles` shows two options under the \"Settings\" group text: `LLM profiles` `[selected]` and `Model Router`, whose subline mentions LLM profiles. Enter runs the selected one.",
          "ids": [
            "F09.entry-command-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Command menu coverage (`F09.command-menu-coverage`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' 'Model Router'` and `control-openhands browser snapshot 'testid=command-menu >> role=listbox'`. It shows one option under the \"Settings\" group text: `Model Router Meta-profiles that route each task to the right LLM profile. Go` `[selected]`. Run `control-openhands browser press Enter` and `control-openhands browser wait-url '/settings/meta-llm$'`; `control-openhands browser count 'testid=command-menu'` is `0`. Repeat from `/` with `Agent Context`: the option reads `Agent Context Let the agent keep notes and recall them in new conversations. Go` and the URL becomes `/settings/agent-context`. With the previous bullet, every page in the settings nav has opened from the menu.",
          "ids": [
            "F09.command-menu-coverage"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Deep links without a model (`F09.entry-deep-links`).",
          "body": "On `/` with no LLM key (`control-openhands browser goto /`): `control-openhands browser click 'testid=home-llm-not-configured-action'` then `control-openhands browser wait-url '/settings/'` → `/settings/llm`. `control-openhands browser goto /`, `control-openhands browser click 'testid=sidebar-onboarding-checklist-item-configure-llm'`, `control-openhands browser wait-url '/settings/'` → `/settings/llm`. `control-openhands browser goto /`, `control-openhands browser click 'testid=sidebar-onboarding-checklist-item-customize-agent'`, `control-openhands browser wait-url '/settings/'` → `/settings/agents`.",
          "ids": [
            "F09.entry-deep-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Deep links from the composer (`F09.entry-deep-links`).",
          "body": "After `control-openhands llm preset deepseek`, run `control-openhands browser goto /`, `control-openhands browser click 'testid=chat-input-llm-profile'`, `control-openhands browser click 'testid=chat-input-llm-profile-popover >> role=link[name=\"LLM Profiles\"]'`, `control-openhands browser wait-url '/settings/llm$'`; `control-openhands browser count 'testid=chat-input-llm-profile-popover'` is `0`. Then `control-openhands browser goto /`, `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser hover 'testid=switch-agent-profile-button'`, `control-openhands browser click 'testid=agent-profile-submenu >> role=link[name=\"Manage agent profiles\"]'`, `control-openhands browser wait-url '/settings/agents$'`; `control-openhands browser count 'testid=tools-context-menu'` is `0`.",
          "ids": [
            "F09.entry-deep-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Legacy URL (`F09.legacy-agent-redirect`).",
          "body": "Run `control-openhands browser goto /`, then `control-openhands browser goto /settings/agent`; the result already reads `.../settings/agents`. `control-openhands browser back` returns to `/`.",
          "ids": [
            "F09.legacy-agent-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Desktop nav (`F09.desktop-nav`).",
          "body": "On any settings page (`control-openhands browser goto /settings/agents`; Legacy URL ends on `/`) run `control-openhands browser snapshot 'testid=settings-navbar-desktop'`: links Agent, LLM, Model Router, Condenser, Agent Context, Verification, Application, Secrets (with `/url`s), then `button \"Agent Canvas Version …\"` and the synced text. Click each one, e.g. `control-openhands browser click 'testid=sidebar-settings-/settings/meta-llm'`, `control-openhands browser wait-url '/settings/meta-llm$'`, `control-openhands browser attr 'testid=sidebar-settings-/settings/meta-llm' aria-current` (`page`), and `control-openhands browser count 'testid=settings-navbar-desktop >> [aria-current=\"page\"]'` (`1`). Pinned: `control-openhands browser goto /settings/app` (the only page taller than the viewport), `control-openhands browser bbox 'testid=settings-navbar-desktop'` (`y` 32), `control-openhands browser hover 'testid=settings-page-subtitle'`, `control-openhands browser scroll --by 1000`, then `control-openhands browser bbox 'testid=settings-page-subtitle'` (negative `y`, content scrolled) and `control-openhands browser bbox 'testid=settings-navbar-desktop'` (still `y` 32); `control-openhands browser screenshot --feature F09.desktop-nav --name scrolled` shows Git Settings on the right and the full nav on the left.",
          "ids": [
            "F09.desktop-nav"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Page header (`F09.page-header`).",
          "body": "After each nav click, `control-openhands browser text 'testid=settings-screen >> header'` reads title + subline: Agent \"Create and manage reusable agent setups.\", LLM \"Model, API key, and options for the agent.\", Model Router \"Meta-profiles that route each task to the right LLM profile.\", Condenser \"Summarize long chats to stay within context limits.\", Agent Context \"Let the agent keep notes and recall them in new conversations.\", Verification \"Confirmation prompts and security checks on actions.\", Application \"Language, theme, notifications, and Git identity.\". On Secrets, `control-openhands browser count 'testid=settings-screen >> header >> h2'` is `0` (the page draws its own \"Secrets\" heading). Editor: on `/settings/agents` (`control-openhands browser goto /settings/agents`) run `control-openhands browser click 'testid=add-agent-profile'`; `control-openhands browser count 'testid=settings-page-subtitle'` is `0` and `control-openhands browser text 'testid=settings-screen >> h2'` is `Add agent profile`. `control-openhands browser click 'testid=back-to-agent-profiles'` brings the subtitle back (count `1`); opening the editor again and clicking `testid=sidebar-settings-/settings/condenser` also shows the Condenser subline.",
          "ids": [
            "F09.page-header"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Landmarks (`F09.landmarks`).",
          "body": "Run `control-openhands browser goto /settings/agents`, `control-openhands browser count 'role=main'` (`1`) and `control-openhands browser eval \"document.querySelector('[data-testid=settings-screen]').tagName\"` (`DIV`: the shell wrapper is not a landmark; the layout's own `<main>` is). `control-openhands browser goto /settings/app` and the same `count` is `1`. At phone width (`control-openhands browser viewport phone`, `control-openhands browser goto /settings`) the hub also counts `1`; return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F09.landmarks"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Phone hub and Back (`F09.phone-hub`, `F09.phone-back`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings` (URL stays `/settings`), `control-openhands browser snapshot 'testid=settings-mobile-hub'` (heading \"Settings\", the eight links, the version button, the synced text), `control-openhands browser count 'testid=settings-page-subtitle'` (`0`), `control-openhands browser bbox 'testid=settings-mobile-hub'` (`pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F09.phone-hub --name hub`. Then `control-openhands browser click 'testid=settings-mobile-hub >> testid=sidebar-settings-/settings/condenser'`, `control-openhands browser wait-url '/settings/condenser$'`, `control-openhands browser attr 'testid=sidebar-mobile-back-button' aria-label` (`Settings`), `control-openhands browser click 'testid=sidebar-mobile-back-button'`, `control-openhands browser wait-url '/settings$'` and `control-openhands browser visible 'testid=settings-mobile-hub'` (`true`). Drawer entry: `control-openhands browser goto /`, `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'`, `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=backend-selector-settings-link'`, `control-openhands browser wait-url '/settings$'`; `control-openhands browser visible 'testid=sidebar-mobile-drawer'` is `false` (the drawer closed). Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F09.phone-hub",
            "F09.phone-back"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Resize from the hub (`F09.hub-resize-redirect`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser viewport phone`, `control-openhands browser goto /settings` and `control-openhands browser visible 'testid=settings-mobile-hub'` (`true`). Then `control-openhands browser viewport desktop` and `control-openhands browser wait-url '/settings/agents$'`: the hub is gone (`count 'testid=settings-mobile-hub'` is `0`) and `control-openhands browser back` lands on `/` (the redirect replaced `/settings`). Reverse: `control-openhands browser goto /settings/condenser`, `control-openhands browser viewport phone`, `control-openhands browser url` (still `/settings/condenser`) and `control-openhands browser visible 'testid=sidebar-mobile-back-button'` (`true`). Tablets get the hub too, and the boundary is 1024 px: run `control-openhands browser viewport tablet` (820 px), `control-openhands browser goto /settings` (URL stays `/settings`) and `control-openhands browser visible 'testid=settings-mobile-hub'` (`true`); then `control-openhands browser viewport 1023x900`, `control-openhands browser goto /settings` and the same `visible` (`true`); then `control-openhands browser viewport 1024x900` and `control-openhands browser wait-url '/settings/agents$'` (`browser visible 'testid=settings-navbar-desktop'` is `true`). Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F09.hub-resize-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Widths around the breakpoint (`F09.breakpoint-sweep`).",
          "body": "For each width `W` in `767`, `768`, `820`, `1023`, `1024`, `1440` run `control-openhands browser viewport Wx1024` and `control-openhands browser goto /settings`. Below 1024 px the URL stays `/settings`, `control-openhands browser visible 'testid=settings-mobile-hub'` is `true` and `control-openhands browser bbox 'testid=settings-mobile-hub'` has `pageHorizontalOverflow` `false` (725, 426, 478 and 681 px wide); from 1024 px up `control-openhands browser wait-url '/settings/agents$'` passes and `control-openhands browser visible 'testid=settings-navbar-desktop'` is `true`. Then `control-openhands browser goto /settings/app` and `control-openhands browser bbox 'role=main'`: `width` is at least 340 (767, 468, 520, 723, 424 and 840 today: full width at 767 px, `W − 300` beside the rail up to 1023 px, `W − 600` beside the rail and the settings nav from 1024 px), `scrollWidth` equals `clientWidth` and `pageHorizontalOverflow` is `false` (`insideViewport` is `false` only because the page is taller than 1024 px). Take `control-openhands browser screenshot --feature F09.breakpoint-sweep --name app-767` and `--name app-1024`. Finish with `control-openhands browser viewport desktop`.",
          "ids": [
            "F09.breakpoint-sweep"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Synced badge (`F09.synced-badge`).",
          "body": "Run `control-openhands browser text 'testid=backend-synced-settings-badge'` and `control-openhands browser eval 'location.origin'`. The text is `These settings are synced from Local backend (<origin>)` with the same origin.",
          "ids": [
            "F09.synced-badge"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Cloud links (`F09.cloud-links`).",
          "body": "On a local backend `control-openhands browser count 'testid=settings-integrations-link'` and `control-openhands browser count 'testid=settings-cloud-link'` are both `0`. Positive case (blocked without a Cloud account): with a Cloud backend active both exist; \"All Cloud Settings\" points at `<cloud host>/settings?org=<org id>` and opens in a new tab unless the canvas is locked to Cloud.",
          "ids": [
            "F09.cloud-links"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Update card and dialog (`F09.update-card`, `F09.update-modal`, `F09.update-up-to-date`).",
          "body": "On a settings page run `control-openhands browser text 'testid=agent-canvas-update-toggle'` (`Agent Canvas`, `Version`, the client version, plus the badge text when present), `control-openhands browser count 'testid=agent-canvas-update-badge'` (`0` while the npm check fails; `1` with \"Up to date\" or \"Update\" when it succeeds), then `control-openhands browser click 'testid=agent-canvas-update-toggle'`, `control-openhands browser wait 'testid=agent-canvas-update-modal'` and `control-openhands browser text 'testid=agent-canvas-update-status'`. With registry access and a current client the status is `You're running the latest version. What's new`, and `control-openhands browser attr 'testid=agent-canvas-update-release-notes' href` is `https://github.com/OpenHands/OpenHands/releases` (`target` `_blank`). Without registry access the status is `Couldn't check for updates. Try again later.` Re-check: run `control-openhands browser network --clear`, then `control-openhands browser click 'testid=agent-canvas-update-check-button' --observe 'testid=agent-canvas-update-modal' --observe-ms 2000` (the observed states include `Checking for updates…` before the final status), then `control-openhands browser network`: exactly one new `GET https://registry.npmjs.org/@openhands/agent-canvas/latest` proves the button re-checked (`browser errors` does not list successful requests). `control-openhands browser enabled 'testid=agent-canvas-update-check-button'` is `true` again. Close with `control-openhands browser click 'testid=close-agent-canvas-update-modal'` (modal count `0`); reopen and `control-openhands browser press Escape` also closes it.",
          "ids": [
            "F09.update-card",
            "F09.update-modal",
            "F09.update-up-to-date"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Update available (`F09.update-available`).",
          "body": "Blocked unless the browser reaches the registry and npm `latest` is newer than the client (a checkout at the latest release shows `F09.update-up-to-date` instead). With both, and a newer npm `latest` than the client, the status shows `Version <x> is available.` with `testid=agent-canvas-update-release-notes` (\"What's new\"), the npm tab `testid=agent-canvas-update-command-npm` reads `npm install -g @openhands/agent-canvas@latest`, the Docker tab reads `docker pull ghcr.io/openhands/agent-canvas:latest`, and `testid=copy-to-clipboard` turns into a check mark for 2 s.",
          "ids": [
            "F09.update-available"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Update dialog at phone width (`F09.update-modal-phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings`, `control-openhands browser click 'testid=settings-mobile-hub >> testid=agent-canvas-update-toggle'`, `control-openhands browser wait 'testid=agent-canvas-update-modal'`, `control-openhands browser bbox 'testid=agent-canvas-update-modal'` and `control-openhands browser bbox 'testid=close-agent-canvas-update-modal'`, then `control-openhands browser screenshot --feature F09.update-modal-phone --name dialog`. Both boxes have `insideViewport` `true` and `pageHorizontalOverflow` `false`: the dialog is 351 px wide at `x` 19.5 and Close is at `x` 329.5; the screenshot shows the whole \"Agent Canvas\" title and the X. `control-openhands browser click 'testid=close-agent-canvas-update-modal'` closes it (`control-openhands browser count 'testid=agent-canvas-update-modal'` is `0`). Run `control-openhands browser viewport narrow` (320×700) and repeat the `goto`, `click`, `wait` and both `bbox` commands: the dialog is 288 px wide at `x` 16 and Close is at `x` 263, both inside the viewport; `control-openhands browser press Escape` closes it. Then `control-openhands browser viewport desktop`.",
          "ids": [
            "F09.update-modal-phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Sidebar version tile (`F09.sidebar-version-tile`).",
          "body": "On `/` run `control-openhands browser count 'testid=agent-canvas-version-tile'`. It is `0` unless the npm check found a newer version, also when the check succeeded with \"Up to date\" (positive case blocked without registry access or while the client is the latest release).",
          "ids": [
            "F09.sidebar-version-tile"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Unknown sub-page (`F09.unknown-subpath`).",
          "body": "Run `control-openhands browser goto /settings/does-not-exist` and `control-openhands browser snapshot 'testid=not-found-screen'`: `heading \"Page not found\"`, the paragraph `This address does not match any page. Check the URL, or go back to the home page.` and `link \"Home\"`. The settings shell is absent (`control-openhands browser count 'testid=settings-screen'` and `control-openhands browser count 'testid=settings-navbar-desktop'` are `0`), while the app sidebar stays (`control-openhands browser count 'aside[data-collapsed]'` is `1`). `control-openhands browser screenshot --feature F09.unknown-subpath --name not-found` shows the message beside the sidebar. Leave with `control-openhands browser click 'testid=not-found-home-link' --expect-url '/$'`.",
          "ids": [
            "F09.unknown-subpath"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "After the family.",
          "body": "Run `control-openhands browser errors --clear` before the family and `control-openhands browser errors --app-only` after it. Expected: `pageErrors` `0` and `appErrors` `0`; the unknown sub-page adds no errors. The Agent profile editor logs `[i18n] Missing translation for key \"SCHEMA$TOOL_CONCURRENCY_LIMIT$…\"` console warnings, counted under `warnings`, not as errors (the Agent page's family). A registry request failure, when the sandbox blocks npm, is external.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F10",
      "title": "LLM profiles",
      "file": "F10-llm-profiles.md",
      "page": "F10-llm-profiles.html",
      "sha256": "6e69e1a15243c700bdb27c3eb597d2203315482dd5d8378395f46d946e2b9ea0",
      "behaviors": [
        {
          "id": "F10.list",
          "description": "the \"Available Profiles\" list shows each row's name, model, a `Default` badge on the active profile and a `...` menu, sorted by name; reachable from the sidebar, the direct URL and the command menu."
        },
        {
          "id": "F10.list-empty",
          "description": "with no profiles the list reads `No profiles saved yet. Add a profile to save your LLM configurations.` and both Add buttons stay available."
        },
        {
          "id": "F10.list-states",
          "description": "a spinner shows while profiles load; a load failure shows `Failed to load profiles`."
        },
        {
          "id": "F10.list-grouped",
          "description": "once any profile links to a provider connection, rows group under the connection's name, with unlinked rows last under `Not linked`; with no linked rows the list is flat again."
        },
        {
          "id": "F10.broken-link",
          "description": "a profile whose linked connection no longer exists shows a warning `Broken link` badge whose tooltip says to edit the profile to re-link it; its editor offers the orphaned id in the Provider connection selector so it can be cleared."
        },
        {
          "id": "F10.actions-menu",
          "description": "the row menu offers Edit, Rename, Duplicate, Set as default (disabled on the Default row) and Delete; it focuses Edit on open (mouse or Enter), arrows cycle over the enabled items (skipping a disabled Set as default), and Escape, Tab or an outside click close it."
        },
        {
          "id": "F10.set-default",
          "description": "Set as default toasts `Switched to profile \"<name>\"`, moves the badge, persists and becomes the agent's active LLM."
        },
        {
          "id": "F10.duplicate",
          "description": "Duplicate saves `<name>-copy`, then `<name>-copy-1`, with the same model and stored key; the copy is not Default."
        },
        {
          "id": "F10.rename",
          "description": "the Rename modal is prefilled and focused; invalid names disable Rename; an unchanged name or Cancel just closes; Enter submits; success toasts and persists, and the Default badge follows a renamed default."
        },
        {
          "id": "F10.rename-conflict",
          "description": "renaming onto an existing name is refused with the server's message (`Profile '<name>' already exists`) and the modal stays open."
        },
        {
          "id": "F10.delete",
          "description": "Delete asks `Are you sure you want to delete the profile \"<name>\"? This action cannot be undone.` with Cancel focused; Cancel keeps the row, Delete removes it with a toast."
        },
        {
          "id": "F10.delete-default",
          "description": "deleting the Default profile promotes another remaining profile to Default."
        },
        {
          "id": "F10.create",
          "description": "Add LLM Profile → provider, model and API key → Save shows `Validating...` then `Saving...`, toasts `Profile \"<name>\" created` and returns to the list; the first profile on an empty list becomes Default."
        },
        {
          "id": "F10.create-prefill",
          "description": "a new editor prefills the backend default model with a blank key, base URL and connection; the other LLM options it shows come from the agent's current LLM settings, i.e. the last applied Default profile (the editor opens on the tab they need), and Save stores what the form shows."
        },
        {
          "id": "F10.api-key-help",
          "description": "under the API key field (Basic and Advanced), `Don't know your API key? Click here for instructions` opens the OpenHands docs on getting an API key in a new tab."
        },
        {
          "id": "F10.name-autofill",
          "description": "the Profile Name is auto-filled from the chosen model and follows later model changes until the user types a name; clearing the field resumes it."
        },
        {
          "id": "F10.name-validation",
          "description": "a name that breaks the rule (letter or digit first; letters, digits, `.`, `_`, `-`; max 64) disables Save and turns the rule text red; a name that already exists also disables Save."
        },
        {
          "id": "F10.create-validation",
          "description": "a Save whose validation call fails toasts the provider error and keeps the editor open with nothing saved."
        },
        {
          "id": "F10.custom-model",
          "description": "the Advanced tab takes a free-text model and Base URL; an empty model is refused with `Model is required`."
        },
        {
          "id": "F10.model-picker",
          "description": "the Basic provider and model comboboxes group options as Verified Models and Other Models; changing provider clears the model; the OpenHands provider shows account and key help."
        },
        {
          "id": "F10.provider-list-complete",
          "description": "the Basic provider combobox offers every provider the backend reports or has verified models for (over a hundred, `xai` included); picking a late-sorting one fills the field and loads its models."
        },
        {
          "id": "F10.cloud-provider-pagination",
          "description": "on a Cloud organization backend the provider list is fetched from `providers/search` page by page, so providers past the first page (`xai`, `openrouter`) are offered too (blocked here)."
        },
        {
          "id": "F10.edit",
          "description": "Edit loads the profile (`Editing profile \"<name>\" - save to apply changes`) with the stored key masked; Save stays disabled until something changes; a changed name renames before saving; the stored key is kept."
        },
        {
          "id": "F10.basic-save-keeps-base-url",
          "description": "re-saving a profile from the Basic view without changing its model keeps the Base URL that view hides."
        },
        {
          "id": "F10.edit-default-reapply",
          "description": "saving the Default profile re-applies it to the agent's LLM settings."
        },
        {
          "id": "F10.editor-discard",
          "description": "Back and Cancel return to the list without saving and without an unsaved-changes prompt."
        },
        {
          "id": "F10.schema-validation",
          "description": "bad values in schema fields (Temperature over 2, Top P over 1, invalid JSON) are refused with a toast naming the field."
        },
        {
          "id": "F10.connection-link",
          "description": "with connections present the editor shows a Provider connection selector; linking hides API key and Base URL, skips validation on Save and regroups the row; None restores the key field."
        },
        {
          "id": "F10.add-models",
          "description": "\"Add from provider connections\" (or a connection row's **Bulk add**) creates one linked or keyless profile per checked model; models that already have a profile of that name are hidden."
        },
        {
          "id": "F10.subscription",
          "description": "Authentication → ChatGPT subscription swaps the model fields for a subscription model list and a device-login card (code, sign-in link, poll, cancel); switching back restores the API-key model."
        },
        {
          "id": "F10.schema-unavailable",
          "description": "a backend without the settings-schema endpoints shows `SDK settings schema unavailable.` with an upgrade or session-key hint."
        },
        {
          "id": "F10.cloud-readonly",
          "description": "Cloud org members without edit rights see the list read-only (no Add buttons, no row menus, no Provider connections section)."
        },
        {
          "id": "F10.phone",
          "description": "list, row menu and editor fit a 390 px viewport without horizontal overflow."
        },
        {
          "id": "F10.default-used",
          "description": "a conversation started after creating a profile in the UI runs on the Default profile's model."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Empty list (`F10.list-empty`).",
          "body": "On a fresh run, navigate from the sidebar: `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'`, `control-openhands browser click 'testid=sidebar-settings-/settings/llm' --expect-url '/settings/llm'`, then `control-openhands browser text 'testid=profiles-empty'`. The text is `No profiles saved yet. Add a profile to save your LLM configurations.` and `control-openhands browser testids main` lists `add-llm-profile`, `add-models-from-provider` and `profiles-empty`.",
          "ids": [
            "F10.list-empty"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Open the editor (`F10.create`).",
          "body": "Run `control-openhands browser click 'testid=add-llm-profile'`. `control-openhands browser text 'testid=profile-editor-title'` is `Add LLM Profile`, `control-openhands browser text 'testid=profile-editor-description'` is `Configure your LLM settings below, then click Save to create this profile.`, and the form opens on the Basic tab with provider `OpenAI` and model `gpt-5.6-sol` (`browser value 'testid=llm-provider-input'`, `browser value 'testid=llm-model-input'`).",
          "ids": [
            "F10.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "API key help link (`F10.api-key-help`).",
          "body": "In that editor run `control-openhands browser click 'testid=sdk-section-basic-toggle'` (a new editor opens on All once the agent's LLM settings carry a non-default option; see `F10.create-prefill`), then `control-openhands browser text 'testid=llm-api-key-help-anchor'` is `Don't know your API key? Click here for instructions`; its link has `target` `_blank` (`browser eval \"document.querySelector('[data-testid=llm-api-key-help-anchor] a, a[data-testid=llm-api-key-help-anchor]').target\"`). Run `control-openhands browser click 'testid=llm-api-key-help-anchor >> role=link'`, then `control-openhands browser tabs` (rerun once if it still lists one page): page 1 is `https://docs.openhands.dev/.../local-setup#getting-an-api-key`. Return with `control-openhands browser close-tab 1` and `control-openhands browser tab 0`. The Advanced tab shows the same link as `testid=llm-api-key-help-anchor-advanced`.",
          "ids": [
            "F10.api-key-help"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Provider and model lists (`F10.model-picker`).",
          "body": "Run `control-openhands browser click 'testid=llm-provider-input'` and `control-openhands browser snapshot 'role=listbox'`: groups `Verified Models` (OpenHands, Anthropic, OpenAI, Mistral AI, Gemini, deepseek, Moonshot, minimax, glm, nvidia, qwen) and `Other Models`. Pick DeepSeek with `control-openhands browser fill 'testid=llm-provider-input' deepseek` and `control-openhands browser click 'testid=provider-item-deepseek'`; `browser value 'testid=llm-model-input'` is now empty. `control-openhands browser click 'testid=llm-model-input'` and `browser snapshot 'role=listbox'` list `deepseek-chat`, `deepseek-v4-pro`, … as Verified and `deepseek-coder`, `deepseek-flash`, … under Other. For the OpenHands provider (`browser fill 'testid=llm-provider-input' openhands`, `browser click 'testid=provider-item-openhands'`), `control-openhands browser text 'testid=openhands-account-help'` is `Need an OpenHands Account? Click here`; after `control-openhands browser click 'role=option[name=\"claude-sonnet-5\"][exact]'` in the model list, `control-openhands browser text 'testid=openhands-api-key-help'` starts `You can find your OpenHands Provider LLM Key in the OpenHands LLM Key section of OpenHands Cloud`. Screenshot: `browser screenshot --feature F10.model-picker --name openhands-provider`.",
          "ids": [
            "F10.model-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Every provider is offered (`F10.provider-list-complete`).",
          "body": "Run `control-openhands browser click 'testid=cancel-profile-btn'` (the Provider and model lists bullet leaves its editor open; skip this when the list is showing), `control-openhands browser click 'testid=add-llm-profile'`, `control-openhands browser click 'testid=sdk-section-basic-toggle'` and `control-openhands browser click 'testid=llm-provider-input'`. `control-openhands browser count 'role=listbox >> role=option'` is the size of the union of the two backend lists the page merges: the names in `control-openhands api GET /api/llm/providers --pick providers` (under `value`; `control-openhands api GET /api/llm/providers --pick providers | jq '.value | length'` is `160` today) and the provider keys of `control-openhands api GET /api/llm/models/verified --max-bytes 200000` (its `body` is `{\"models\": {<provider>: [<model>, …]}}`; `control-openhands api GET /api/llm/models/verified --max-bytes 200000 | jq '.body.models | keys | length'` is `12` today, four of which, `glm`, `nvidia`, `openhands` and `qwen`, are missing from the first list). The union is `jq -s '([.[0].value[]] + (.[1].body.models | keys)) | unique | length' <(control-openhands api GET /api/llm/providers --pick providers) <(control-openhands api GET /api/llm/models/verified --max-bytes 200000)` (bash process substitution): `164` today, and the listbox count is the same `164`, with every backend name present under its display label (`openhands` as `OpenHands`, `mistral` as `Mistral AI`, `azure_ai` as `Azure AI Studio`). `control-openhands browser count 'role=option[name=\"xai\"][exact]'` is `1` (it sorts last but three, before `xiaomi_mimo`, `xinference` and `zai`). Pick it with `control-openhands browser click 'role=option[name=\"xai\"][exact]'`: `control-openhands browser value 'testid=llm-provider-input'` is `xai`, `control-openhands browser value 'testid=llm-model-input'` is empty, and after `control-openhands browser click 'testid=llm-model-input'` the listbox offers `grok-4.20`, `grok-4.20-0309`, … under Other Models (57 today; `control-openhands browser screenshot --feature F10.provider-list-complete --name xai-picked`). Close with `control-openhands browser press Escape` (the open listbox would swallow the next click) and `control-openhands browser click 'testid=cancel-profile-btn'`; the list is back.",
          "ids": [
            "F10.provider-list-complete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Cloud provider pagination (`F10.cloud-provider-pagination`).",
          "body": "Blocked: needs a Cloud organization backend where the user can manage profiles (local backends serve the whole list in one `GET /api/llm/providers`). There, the Basic provider list comes from `providers/search` one page at a time (`control-openhands browser network --filter providers/search` lists one request per page, the later ones with a `page_id`), and `xai` and `openrouter`, which sort past the first page, are offered and pick like any other provider. Record `control-openhands evidence add --feature F10.cloud-provider-pagination --result blocked ...` naming the Cloud account.",
          "ids": [
            "F10.cloud-provider-pagination"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Create the first profile (`F10.create`, `F10.name-autofill`).",
          "body": "In a fresh editor pick deepseek as above, then `control-openhands browser click 'testid=llm-model-input'` and `control-openhands browser click 'testid=model-item-deepseek-flash'`. `control-openhands browser value 'testid=profile-name-input'` now follows the model: `deepseek-flash` (right after the provider pick, while the model is still empty, it still reads `gpt-5.6-sol`). Run `control-openhands browser fill 'testid=profile-name-input' QA_flash`, `control-openhands browser fill 'testid=llm-api-key-input' --value-file <key file>` (or `--value-env DEEPSEEK_API_KEY`), `control-openhands browser click 'testid=save-profile-btn' --observe 'testid=save-profile-btn' --observe-ms 4000` and `control-openhands browser wait-text 'Profile \"QA_flash\" created' --timeout 60000`. The observed button states are `Save`, `Validating...`, `Saving...`, then the editor closes. After `control-openhands browser reload`, `control-openhands browser text '[data-testid=profile-row]:has([title=\"QA_flash\"])'` reads `QA_flash`, `deepseek/deepseek-flash`, `Default`, and `control-openhands api GET /api/profiles` has `active_profile` `QA_flash`.",
          "ids": [
            "F10.create",
            "F10.name-autofill"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "A typed name sticks; a cleared one follows again (`F10.name-autofill`).",
          "body": "In a fresh editor (`control-openhands browser click 'testid=add-llm-profile'`) run `control-openhands browser fill 'testid=profile-name-input' QA_typed`, then pick deepseek as above, `control-openhands browser click 'testid=llm-model-input'` and `control-openhands browser click 'role=option[name=\"deepseek-v4-pro\"][exact]'`; `browser value 'testid=profile-name-input'` stays `QA_typed`. Run `control-openhands browser fill 'testid=profile-name-input' ''`, then `browser click 'testid=llm-model-input'` and `browser click 'testid=model-item-deepseek-flash'`: the name is `deepseek-flash`; pick `role=option[name=\"deepseek-v4-pro\"][exact]` again and it is `deepseek-v4-pro`.",
          "ids": [
            "F10.name-autofill"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Name rules (`F10.name-validation`).",
          "body": "In the editor run `control-openhands browser fill 'testid=profile-name-input' 'QA bad'`; `control-openhands browser enabled 'testid=save-profile-btn'` is `false` and `control-openhands browser attr 'testid=profile-name-input' aria-invalid` is `true` (the same for `-QA` and a 65-character name), and the rule text under the field turns red. An existing name (`QA_flash`) also gives `enabled` `false`, but `aria-invalid` stays `false` and nothing says why (see Gotchas). `QA_flash2` gives `true`.",
          "ids": [
            "F10.name-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Validation failure (`F10.create-validation`).",
          "body": "In a fresh editor choose deepseek / `model-item-deepseek-flash`, fill `profile-name-input` with `QA_nokey`, leave the API key empty and run `control-openhands browser click 'testid=save-profile-btn' --observe 'testid=save-profile-btn' --observe-ms 3000` (states `Save`, `Validating...`, `Save`). `control-openhands browser wait-text 'Authentication Fails'` finds the toast `litellm.AuthenticationError: AuthenticationError: DeepseekException - Authentication Fails (...)`; `browser text 'testid=profile-editor-title'` is still `Add LLM Profile` and `control-openhands api GET /api/profiles` has no `QA_nokey`.",
          "ids": [
            "F10.create-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Custom model (`F10.custom-model`).",
          "body": "In that editor run `control-openhands browser click 'testid=sdk-section-advanced-toggle'`. `browser value 'testid=llm-custom-model-input'` carries the Basic choice (`deepseek/deepseek-flash`; placeholder `openai/gpt-5.6-sol`), and `browser attr 'testid=base-url-input' placeholder` is `https://api.openai.com`. Run `control-openhands browser fill 'testid=llm-custom-model-input' ''`, `control-openhands browser fill 'testid=profile-name-input' QA_adv`, `control-openhands browser click 'testid=save-profile-btn'` and `control-openhands browser wait-text 'Model is required'`. Then `control-openhands browser fill 'testid=llm-custom-model-input' deepseek/deepseek-flash`, `control-openhands browser fill 'testid=base-url-input' https://api.deepseek.com`, fill the key as in Create, Save, and `control-openhands browser wait-text 'Profile \"QA_adv\" created' --timeout 60000`. `api GET /api/profiles` shows `QA_adv` with `base_url` `https://api.deepseek.com`.",
          "ids": [
            "F10.custom-model"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Second profile from a verified model.",
          "body": "Repeat Create choosing `control-openhands browser click 'role=option[name=\"deepseek-v4-pro\"][exact]'` (verified models have no `model-item-*` test ID) and the name `QA_pro`. The toast reads `Profile \"QA_pro\" created`; the row `QA_pro deepseek/deepseek-v4-pro` has no badge.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Row menu (`F10.actions-menu`).",
          "body": "Run `control-openhands browser click '[data-testid=profile-row]:has([title=\"QA_flash\"]) >> testid=profile-menu-trigger'` and `control-openhands browser snapshot 'testid=profile-actions-menu'`: menuitems Edit, Rename, Duplicate, `Set as default [disabled]`, Delete on the Default row (all enabled on other rows). `control-openhands browser eval \"document.activeElement.dataset.testid\"` is `profile-edit`: focus moves into the menu on open. Run `control-openhands browser press ArrowDown` and that eval four times: `profile-rename`, `profile-duplicate`, `profile-delete` (the disabled Set as default is skipped), `profile-edit` (wraps); `control-openhands browser press ArrowUp` twice gives `profile-delete`, `profile-duplicate`. `control-openhands browser press Tab` closes the menu (`browser count 'testid=profile-actions-menu'` is `0`) and moves focus on to the next row's trigger: the row-aware eval `control-openhands browser eval \"(()=>{const a=document.activeElement;const r=a.closest('[data-testid=profile-row]');return a.dataset.testid+'@'+(r?r.querySelector('[title]').title:'-')})()\"` is `profile-menu-trigger@QA_pro`. Reopen the `QA_flash` menu and `control-openhands browser press Escape`: count `0`, and the row-aware eval is `profile-menu-trigger@QA_flash` (focus is back on its own trigger). Reopen it and `control-openhands browser click 'role=heading[name=\"Available Profiles\"]'` closes it too. Keyboard open: `control-openhands browser focus '[data-testid=profile-row]:has([title=\"QA_pro\"]) >> testid=profile-menu-trigger'` and `control-openhands browser press Enter` put focus on `profile-edit`; on this non-default row `control-openhands browser snapshot 'testid=profile-actions-menu'` lists the same five menuitems with none `[disabled]`, and `control-openhands browser screenshot --feature F10.actions-menu --name menu-nondefault` shows them with the focus ring on Edit. Five `ArrowDown`s walk `profile-rename`, `profile-duplicate`, `profile-set-active`, `profile-delete`, `profile-edit`. `control-openhands browser press Escape` closes it, and the row-aware eval is `profile-menu-trigger@QA_pro`.",
          "ids": [
            "F10.actions-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Set as default (`F10.set-default`).",
          "body": "Run `control-openhands browser click '[data-testid=profile-row]:has([title=\"QA_pro\"]) >> testid=profile-menu-trigger'`, `control-openhands browser click 'testid=profile-actions-menu >> testid=profile-set-active'` and `control-openhands browser wait-text 'Switched to profile \"QA_pro\"'`. After `control-openhands browser reload`, `control-openhands browser count 'testid=profile-active-badge'` is `1` and `control-openhands browser count '[data-testid=profile-row]:has([title=\"QA_pro\"]) >> testid=profile-active-badge'` is `1`; `control-openhands api GET /api/profiles` has `active_profile` `QA_pro` and `control-openhands llm show` reports `active.model` `deepseek/deepseek-v4-pro`. A profile's Base URL follows it into the agent settings: `QA_adv` (Custom model above) carries one: Set as active on its row makes `control-openhands llm show` report `active.base_url` `https://api.deepseek.com`, also under `agent_settings.llm.base_url` in `control-openhands api GET /api/settings`. With a key, `control-openhands conversation start --prompt \"Reply with only: adv-ok. Do not run any tools.\" --wait --timeout 240` then shows the pill `QA_adv` and the reply `adv-ok` (driven 2026-10-08; `QA_pro` likewise replied `pro-ok` on `deepseek/deepseek-v4-pro`). Make `QA_pro` the Default again before Duplicate.",
          "ids": [
            "F10.set-default"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Duplicate (`F10.duplicate`).",
          "body": "From the `QA_pro` row menu click `testid=profile-actions-menu >> testid=profile-duplicate` and `control-openhands browser wait-text 'Profile duplicated as \"QA_pro-copy\"'`; repeat on `QA_pro` for `Profile duplicated as \"QA_pro-copy-1\"`, and on `QA_pro-copy` for `Profile duplicated as \"QA_pro-copy-copy\"`. After a reload `control-openhands browser text 'testid=profile-row >> has-text=QA_pro-copy-1'` reads `QA_pro-copy-1 deepseek/deepseek-v4-pro`, the badge stays on `QA_pro`, and `api GET /api/profiles` has `api_key_set: true` on every copy.",
          "ids": [
            "F10.duplicate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Rename (`F10.rename`).",
          "body": "Open the `QA_pro-copy-copy` row menu and click `testid=profile-actions-menu >> testid=profile-rename`. `control-openhands browser value 'testid=rename-profile-input'` is `QA_pro-copy-copy` and `browser eval \"document.activeElement.dataset.testid\"` is `rename-profile-input`. `control-openhands browser fill 'testid=rename-profile-input' '-bad'` makes `control-openhands browser enabled 'role=dialog[name=\"Rename Profile\"] >> role=button[name=\"Rename\"]'` `false` and `control-openhands browser attr 'testid=rename-profile-rule' class` contains `text-red-400`. Filling the old name and clicking Rename closes the modal with no toast (`browser count 'testid=rename-profile-modal'` is `0`); `control-openhands browser click 'role=dialog[name=\"Rename Profile\"] >> role=button[name=\"Cancel\"]'` also closes it. To rename, reopen, fill `QA_renamed`, `control-openhands browser press Enter`, `control-openhands browser wait-text 'Profile renamed to \"QA_renamed\"'`, reload, and the count of `'[data-testid=profile-row]:has([title=\"QA_renamed\"])'` is `1` while the old name's is `0`. Renaming the Default row (`QA_pro` → `QA_pro2`, submit with `testid=rename-profile-submit`) keeps its badge (`browser text '[data-testid=profile-row]:has([data-testid=profile-active-badge])'` reads `QA_pro2 … Default`) and `active_profile` follows.",
          "ids": [
            "F10.rename"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Rename onto an existing name (`F10.rename-conflict`).",
          "body": "The Rename step above already renamed `QA_pro-copy-copy` and `QA_pro`, so use the remaining fixtures: open the `QA_pro-copy` row menu, click `testid=profile-actions-menu >> testid=profile-rename`, fill `testid=rename-profile-input` with `QA_pro2` and click `testid=rename-profile-submit`. The modal stays open (`count 'testid=rename-profile-modal'` is `1`), `control-openhands browser toasts` has the server's message `Profile 'QA_pro2' already exists` (no `HTTP request failed` prefix, no JSON), and `browser screenshot --feature F10.rename-conflict --name toast` shows that toast above the open modal. Close it with `control-openhands browser click 'role=dialog[name=\"Rename Profile\"] >> role=button[name=\"Cancel\"]'`.",
          "ids": [
            "F10.rename-conflict"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Edit (`F10.edit`).",
          "body": "Open the `QA_renamed` row menu and click `testid=profile-actions-menu >> testid=profile-edit`. `browser text 'testid=profile-editor-title'` is `Edit LLM Profile`, `browser text 'testid=profile-editor-description'` is `Editing profile \"QA_renamed\" - save to apply changes`, `browser attr 'testid=llm-api-key-input' placeholder` is `<hidden>`, `browser count 'testid=set-indicator'` is `1` and `browser enabled 'testid=save-profile-btn'` is `false` (screenshot `--feature F10.edit --name loaded`). Run `control-openhands browser click 'testid=llm-model-input'`, `control-openhands browser click 'testid=model-item-deepseek-flash'` (Save turns `true`), `control-openhands browser fill 'testid=profile-name-input' QA_edited`, Save, and `control-openhands browser wait-text 'Profile \"QA_edited\" updated' --timeout 60000`. After a reload `browser text '[data-testid=profile-row]:has([title=\"QA_edited\"])'` reads `QA_edited deepseek/deepseek-flash` and `QA_renamed` is gone. The save passed validation without retyping the key, which proves the stored key was kept.",
          "ids": [
            "F10.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Basic re-save keeps a hidden Base URL (`F10.basic-save-keeps-base-url`).",
          "body": "`QA_adv` carries `base_url` `https://api.deepseek.com` (Custom model above). Open its row menu, click `testid=profile-actions-menu >> testid=profile-edit`, then `control-openhands browser click 'testid=sdk-section-advanced-toggle'`: `control-openhands browser value 'testid=base-url-input'` is `https://api.deepseek.com`. Run `control-openhands browser click 'testid=sdk-section-basic-toggle'`: `control-openhands browser count 'testid=base-url-input'` is `0` (the view hides it), `control-openhands browser value 'testid=llm-model-input'` is `deepseek-flash` and `control-openhands browser enabled 'testid=save-profile-btn'` is `false` (`control-openhands browser screenshot --feature F10.basic-save-keeps-base-url --name basic-view`). Without touching the model, run `control-openhands browser fill 'testid=llm-api-key-input' --value-env DEEPSEEK_API_KEY` (Save turns `true`), `control-openhands browser click 'testid=save-profile-btn' --observe 'testid=save-profile-btn' --observe-ms 4000` (`Save`, `Validating...`, `Saving...`) and `control-openhands browser wait-text 'Profile \"QA_adv\" updated' --timeout 60000`. Then `control-openhands api GET /api/profiles/QA_adv --pick config.base_url` is still `https://api.deepseek.com` and `control-openhands api GET /api/profiles/QA_adv --pick config.model` is `deepseek/deepseek-flash`; after `control-openhands browser reload` the same GETs agree. Without a key, arrange `QA_DUMMY_KEY=qa control-openhands llm set --profile QA_base --model deepseek/deepseek-chat --api-key-env QA_DUMMY_KEY --base-url https://api.deepseek.com --no-validate --no-activate` (it prints `activated` `false` and `control-openhands api GET /api/profiles --pick active_profile` is unchanged: the Default stays where it is and the editors below inherit it, as long as some profile is Default; on a run where `QA_base` is the only profile the page makes it Default at the next load, see Gotchas) and `control-openhands browser reload`, then drive the same steps on `QA_base`'s row with its own readbacks: its editor opens on Advanced (the Base URL is a non-default value) with `control-openhands browser value 'testid=base-url-input'` `https://api.deepseek.com`, Basic hides it (`count` `0`), `control-openhands browser value 'testid=llm-model-input'` is `deepseek-chat` and `control-openhands browser enabled 'testid=save-profile-btn'` is `false`; fill the key with `QA_DUMMY_KEY=qa-dummy-key control-openhands browser fill 'testid=llm-api-key-input' --value-env QA_DUMMY_KEY` (Save turns `true`) and Save: the observed states are `Save`, `Validating...` (`[disabled] [busy]`), `Save` within a second, `control-openhands browser toasts` has the provider's message `litellm.BadRequestError: DeepseekException - {\"error\":{\"message\":\"Authentication Fails, Your api key: ****-key is invalid …`, `browser text 'testid=profile-editor-title'` is still `Edit LLM Profile`, `control-openhands api GET /api/profiles/QA_base --pick config.base_url` is still `https://api.deepseek.com` and `--pick config.model` is `deepseek/deepseek-chat`; record `blocked` with `DEEPSEEK_API_KEY` as the prerequisite, leave with `control-openhands browser click 'testid=cancel-profile-btn'` and remove the arranged profile with `control-openhands api DELETE /api/profiles/QA_base --write` (arrange, not proof).",
          "ids": [
            "F10.basic-save-keeps-base-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Leave without saving (`F10.editor-discard`).",
          "body": "Edit `QA_adv`, `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser fill 'testid=sdk-settings-llm.top_p' 0.5` (Save turns enabled), then `control-openhands browser click 'testid=back-to-profiles'`. The list returns at once (`browser count 'testid=add-llm-profile'` is `1`) with no prompt (`control-openhands browser events --kinds dialog` is empty), and reopening Edit → All shows `top_p` empty. `testid=cancel-profile-btn` behaves the same.",
          "ids": [
            "F10.editor-discard"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Schema field validation and re-apply (`F10.schema-validation`, `F10.edit-default-reapply`).",
          "body": "Edit the Default profile and run `control-openhands browser click 'testid=sdk-section-all-toggle'`. `control-openhands browser fill 'testid=sdk-settings-llm.temperature' 5`, Save, then `control-openhands browser wait-text 'must be at most 2'` (toast `Temperature must be at most 2`). Clear it, fill `testid=sdk-settings-llm.top_p` with `3`: `Top P must be at most 1`. Clear it, fill `testid=sdk-settings-llm.extra_headers` with `{bad`: `Invalid JSON for Extra Headers`. Clear that, fill temperature `0.3`, Save, and `wait-text 'Profile \"<name>\" updated'`. `control-openhands api GET /api/settings` then has `agent_settings.llm.temperature` `0.3`, and reopening Edit → All shows `0.3`.",
          "ids": [
            "F10.schema-validation",
            "F10.edit-default-reapply"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "New profile prefill (`F10.create-prefill`).",
          "body": "With that Default (temperature `0.3`) run `control-openhands browser click 'testid=add-llm-profile'`. The editor opens on All (`browser eval \"[...document.querySelectorAll('[role=tab]')].map(t=>t.innerText+':'+t.getAttribute('aria-selected')).join(',')\"` is `Basic:false,Advanced:false,All:true`), `browser value 'testid=llm-custom-model-input'` is the backend default `openai/gpt-5.6-sol`, and `browser value 'testid=sdk-settings-llm.temperature'` is `0.3`, inherited from the Default. Run `control-openhands browser fill 'testid=llm-custom-model-input' deepseek/deepseek-flash`, `browser fill 'testid=profile-name-input' QA_inherit`, fill the key as in Create, Save and `wait-text 'Profile \"QA_inherit\" created' --timeout 60000`. Expected: the saved profile has the temperature the form showed. Actual today: `control-openhands api GET /api/profiles/QA_inherit` has `config.temperature` `null`, and after `browser reload` Edit `QA_inherit` → All shows the temperature empty (`fail`; the create path saves dirty fields only, so the shown inherited values are dropped). Screenshot `--feature F10.create-prefill --name edit-after-create`.",
          "ids": [
            "F10.create-prefill"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Link to a provider connection (`F10.connection-link`, `F10.list-grouped`).",
          "body": "Arrange a connection through the F11 form: `control-openhands browser click 'testid=add-provider-connection'`, `control-openhands browser fill 'testid=provider-connection-name-input' QA_ds_conn`, `control-openhands browser click 'testid=provider-connection-provider-input'`, `control-openhands browser fill 'testid=provider-connection-provider-input' deepseek`, `control-openhands browser click 'testid=provider-item-deepseek'`, `control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>`, `control-openhands browser click 'testid=provider-connection-submit'` and `control-openhands browser wait-text 'Connection \"QA_ds_conn\" created'`. Edit `QA_adv`, click `testid=sdk-section-basic-toggle`, run `control-openhands browser click 'testid=llm-provider-connection-input'` (options `None`, `QA_ds_conn`) and `control-openhands browser click 'role=option[name=\"QA_ds_conn\"]'`; `browser count 'testid=llm-api-key-input'` is `0`, and on the Advanced tab `browser count 'testid=base-url-input'` is `0`. Picking `role=option[name=\"None\"]` brings the key field back (`1`). Re-select `QA_ds_conn`, then `control-openhands browser click 'testid=save-profile-btn' --observe 'testid=save-profile-btn' --observe-ms 3000`: states `Save`, `Saving...` (no `Validating...`), and `wait-text 'Profile \"QA_adv\" updated'`. After a reload `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=profile-group-header]')].map(e=>e.innerText).join(' | ')\"` is `QA_DS_CONN | NOT LINKED` (CSS upper-case) and `control-openhands browser count 'div:has(> [data-testid=profile-group-header]:text-is(\"QA_ds_conn\")) [data-testid=profile-row]:has([title=\"QA_adv\"])'` is `1` (screenshot `--feature F10.list-grouped --name grouped`).",
          "ids": [
            "F10.connection-link",
            "F10.list-grouped"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Add models from a provider (`F10.add-models`).",
          "body": "Run `control-openhands browser click 'testid=add-models-from-provider'`; `browser enabled 'testid=add-models-submit'` is `false`. Keyless: `control-openhands browser select 'testid=add-models-provider' moonshot` and `control-openhands browser wait 'testid=add-models-select-all'`; `browser text 'testid=add-models-keyless-note'` is `No connection — profiles will need a key added later`, rows start unchecked (`Add 0 profiles`), and `control-openhands browser check 'testid=add-models-select-all'` makes `browser text 'testid=add-models-submit'` read `Add 12 profiles`; uncheck it again. Linked: `control-openhands browser select 'testid=add-models-provider' deepseek`; the connection field (`testid=add-models-connection-field`, options `No connection (keyless)` and `QA_ds_conn`) is pre-bound to `QA_ds_conn`. Run `control-openhands browser check 'testid=add-models-check-deepseek/deepseek-chat'`, `control-openhands browser check 'testid=add-models-check-deepseek/deepseek-v3'`, `control-openhands browser click 'testid=add-models-submit'` and `control-openhands browser wait-text 'Added 2'` (toast `Added 2 profiles`). The modal closes; after a reload `deepseek-chat` and `deepseek-v3` sit under the `QA_DS_CONN` header and `api GET /api/profiles` shows both with a `provider_connection_id`. Preselect entry: `control-openhands browser click 'testid=provider-connection-row >> has-text=QA_ds_conn >> testid=provider-connection-menu-trigger'` (menu: Bulk add, Edit, Delete), `control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-add-models'`; provider `deepseek` and the connection are preset, every remaining row is checked (`Add 10 profiles`), and `browser count 'testid=add-models-row-deepseek/deepseek-chat'` is `0` (already added). Close with `control-openhands browser click 'role=dialog[name=\"Add models as profiles\"] >> role=button[name=\"Cancel\"]'`.",
          "ids": [
            "F10.add-models"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Broken link (`F10.broken-link`).",
          "body": "The local server refuses to delete a referenced connection (row menu → `testid=provider-connection-delete` → `testid=delete-provider-connection-confirm` toasts `Provider connection cannot be deleted while it is referenced by LLM profile(s): ...`), so arrange the state directly: `control-openhands api POST /api/profiles/QA_broken --write --data '{\"llm\":{\"model\":\"deepseek/deepseek-chat\",\"provider_connection_id\":\"00000000000000000000000000000000\"},\"include_secrets\":false}'`. Then `control-openhands browser reload` and `control-openhands browser text '[data-testid=profile-row]:has([title=\"QA_broken\"])'` reads `QA_broken deepseek/deepseek-chat Broken link`; `control-openhands browser attr '[data-testid=profile-row]:has([title=\"QA_broken\"]) >> testid=profile-broken-connection-badge' title` is `The linked provider connection was deleted. Edit this profile to re-link it.` (screenshot `--feature F10.broken-link --name badge`). Edit it: `browser value 'testid=llm-provider-connection-input'` is the orphaned id and the listbox offers `None`, `QA_ds_conn` and that id. The row's group header shows the raw id, not a name (see Gotchas).",
          "ids": [
            "F10.broken-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "ChatGPT subscription, pre-auth (`F10.subscription`).",
          "body": "In a fresh editor run `control-openhands browser click 'testid=sdk-section-basic-toggle'` first (after the schema step the Default profile has a temperature, so a new editor opens on All; see `F10.create-prefill`), then `control-openhands browser click 'testid=llm-auth-type-input'` (options `API key`, `ChatGPT subscription`) and `control-openhands browser click 'role=option[name=\"ChatGPT subscription\"]'`. `browser text 'testid=subscription-status'` is `ChatGPT subscription not connected`, `browser value 'testid=llm-subscription-model-input'` is `gpt-5.5` (list: gpt-5.5, gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-6-astra), and `llm-provider-input` and `llm-api-key-input` are gone (count `0`). `control-openhands browser click 'testid=subscription-connect'` and `control-openhands browser wait 'testid=subscription-user-code'`: the card shows the code, `Open sign-in page`, `I've finished signing in` (`subscription-poll`) and Cancel, and `control-openhands browser tabs` lists a second page on `auth.openai.com` (it opens a second or two after the code appears; rerun `browser tabs` if it lists one page). The card polls on its own, so `Sign-in is not complete yet. ...` can already show before you click poll. Close it with `control-openhands browser close-tab 1` and `control-openhands browser tab 0`. `control-openhands browser click 'testid=subscription-poll'` leaves `Sign-in is not complete yet. Finish the browser flow and try again.` on the card, and `control-openhands browser click 'testid=subscription-cancel'` removes the code (`count 'testid=subscription-user-code'` `0`). Switching back with `role=option[name=\"API key\"]` restores provider `OpenAI` and model `gpt-5.6-sol`. Completing sign-in is blocked (needs a ChatGPT Plus/Pro account).",
          "ids": [
            "F10.subscription"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Delete (`F10.delete`, `F10.delete-default`).",
          "body": "Run `control-openhands browser click '[data-testid=profile-row]:has([title=\"QA_pro-copy\"]) >> testid=profile-menu-trigger'` and `control-openhands browser click 'testid=profile-actions-menu >> testid=profile-delete'`. `control-openhands browser snapshot 'role=dialog'` shows `Delete Profile` with `Are you sure you want to delete the profile \"QA_pro-copy\"? This action cannot be undone.`, and `browser eval \"document.activeElement.innerText\"` is `Cancel`. `control-openhands browser click 'role=dialog[name=\"Delete Profile\"] >> role=button[name=\"Cancel\"]'` keeps the row (count `1`). Repeat, then `control-openhands browser click 'testid=delete-profile-confirm'`, `control-openhands browser wait-text 'Profile \"QA_pro-copy\" deleted'`, reload, and the count is `0`. Deleting the Default row the same way leaves exactly one `profile-active-badge` on another profile (`browser text '[data-testid=profile-row]:has([data-testid=profile-active-badge])'`), and `api GET /api/profiles` names it in `active_profile`.",
          "ids": [
            "F10.delete",
            "F10.delete-default"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Command menu and direct URL (`F10.list`).",
          "body": "Run `control-openhands browser goto /` (the bullets above end on `/settings/llm`, where the URL wait would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' LLM` and `control-openhands browser snapshot 'testid=command-menu'`: two options, `LLM profiles` (\"Manage models, providers, and API keys.\") and `Model Router` (F12; its keywords include \"llm\"). Hovering an option selects it, so which one Enter runs depends on where the mouse rests: click the option instead, `control-openhands browser click 'testid=command-menu >> role=option[name=\"LLM profiles\"]' --expect-url '/settings/llm(\\?|$)'`. `browser count 'testid=add-llm-profile'` is `1` on arrival. `control-openhands browser goto /settings/llm` shows the same list (`browser count 'testid=profile-row'`, one `profile-active-badge`; screenshot `--feature F10.list --name list`).",
          "ids": [
            "F10.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Phone layout (`F10.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/llm`, `control-openhands browser bbox 'testid=add-llm-profile'`, `control-openhands browser bbox 'testid=profile-row >> nth=0'` and `control-openhands browser screenshot --feature F10.phone --name list`: `insideViewport` `true`, `pageHorizontalOverflow` `false`. Open a row menu: `browser bbox 'testid=profile-actions-menu'` is inside the viewport. Open Edit: `browser bbox 'testid=profile-name-input'` is 348 px wide inside the 390 px viewport and `pageHorizontalOverflow` is `false` (`llm-settings-screen` may report `insideViewport` `false` when the edited profile's form is taller than the screen; only the overflow flag matters). Return with `control-openhands browser click 'testid=cancel-profile-btn'` and `control-openhands browser viewport desktop`.",
          "ids": [
            "F10.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Default profile drives a conversation (`F10.default-used`).",
          "body": "Make a profile with a distinct model Default through the row menu (for example `QA_pro-copy-1`, `deepseek/deepseek-v4-pro`, then `wait-text 'Switched to profile \"QA_pro-copy-1\"'`), and run `control-openhands conversation start --prompt \"Reply with exactly the word: pong\" --wait --timeout 180`. The JSON has `\"status\": \"finished\"` and `\"model\": \"deepseek/deepseek-v4-pro\"`. The browser is now on the conversation page: `control-openhands browser goto /settings/llm` before driving this family again.",
          "ids": [
            "F10.default-used"
          ],
          "children": []
        },
        {
          "anchor": "recipe-031",
          "label": "Persistence across a restart (`F10.list`).",
          "body": "`control-openhands restart`, `control-openhands doctor`, `control-openhands browser goto /settings/llm`: the same rows and the same Default badge are back.",
          "ids": [
            "F10.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-032",
          "label": "Load error (`F10.list-states`, blocked).",
          "body": "`control-openhands service stop agent-server` and `control-openhands browser reload` do not reach `testid=profiles-load-error`: the whole app is replaced by the disconnected-backend screen (`testid=agent-server-onboarding-screen`, toasts `An error occurred` (twice, one after the other) and `Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.` (#18051); the Manage backends row detail still reads `HTTP request failed (502 Bad Gateway): \"Bad Gateway: connect ECONNREFUSED 127.0.0.1:<agent-server port>\"`). The list error needs `/api/profiles` alone to fail. Bring the stack back with `control-openhands restart`.",
          "ids": [
            "F10.list-states"
          ],
          "children": []
        },
        {
          "anchor": "recipe-033",
          "label": "Cleanup.",
          "body": "Run `control-openhands browser goto /settings/llm` (the Load error bullet left the disconnected-backend screen), then delete every `QA_*`, `deepseek-chat` and `deepseek-v3` profile through the row menu, linked ones first; `control-openhands browser count 'testid=profile-group-header'` drops to `0` once no linked row is left (`F10.list-grouped`). Then delete `QA_ds_conn` (`testid=provider-connection-row >> has-text=QA_ds_conn >> testid=provider-connection-menu-trigger`, `testid=provider-connection-actions-menu >> testid=provider-connection-delete`, `testid=delete-provider-connection-confirm`; toast `Connection \"QA_ds_conn\" deleted`). With no profiles left, `testid=profiles-empty` returns and `api GET /api/profiles` is `{\"profiles\":[],\"active_profile\":null}`.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F11",
      "title": "Provider connections",
      "file": "F11-provider-connections.md",
      "page": "F11-provider-connections.html",
      "sha256": "f46fa3d41263df471359a2f6ab938e77b8c9a188f4b0d38ebba100f3facd9614",
      "behaviors": [
        {
          "id": "F11.list-empty",
          "description": "with no connections the section shows the heading `Provider connections`, the subline `Share one API key across multiple models.`, **Add connection** and `No provider connections yet. Add one to share an API key across models.`"
        },
        {
          "id": "F11.list",
          "description": "each row shows the name, the provider, `<n> model(s)` (profiles linked to it), a green key-set icon and a `...` menu labelled `Provider connection menu`; the section is reachable from the settings nav, the command menu and the URL."
        },
        {
          "id": "F11.create-validation",
          "description": "in **Add provider connection** focus starts on Name; Save stays disabled until Name, a picked Provider and API Key are non-blank (whitespace does not count); a provider typed but not picked is cleared; Enter does not submit."
        },
        {
          "id": "F11.provider-picker",
          "description": "the Provider combobox lists `Verified Models` (OpenHands, Anthropic, OpenAI, …, deepseek, …) and `Other Models` groups and filters as you type."
        },
        {
          "id": "F11.create",
          "description": "Save creates the connection (toast `Connection \"<name>\" created`); the row persists after a reload and a backend restart; the key is stored but never shown."
        },
        {
          "id": "F11.create-cancel",
          "description": "Cancel, Escape and a backdrop click close the modal without creating anything; reopening starts empty."
        },
        {
          "id": "F11.create-error",
          "description": "a server rejection (for example a name over 128 characters) keeps the modal open and toasts a readable message."
        },
        {
          "id": "F11.row-menu",
          "description": "the `...` menu offers **Bulk add**, **Edit**, **Delete**; Escape, a click outside or a second trigger click close it; ArrowUp/ArrowDown move and wrap; Tab closes it."
        },
        {
          "id": "F11.row-menu-focus",
          "description": "opening the menu moves focus to its first item (**Bulk add**)."
        },
        {
          "id": "F11.edit",
          "description": "**Edit** opens `Edit provider connection` prefilled (key blank with placeholder `<hidden>` and hint `Leave blank to keep the current key.`); rename, provider and base URL changes persist (toast `Connection \"<name>\" updated`), a cleared base URL is stored as none, a blank name disables Save, and a rename shows in the linked profiles' group header."
        },
        {
          "id": "F11.add-models",
          "description": "**Bulk add** opens `Add models as profiles` with the row's provider and connection preselected; the profiles it adds are linked and the row's count goes up."
        },
        {
          "id": "F11.agent-uses-connection",
          "description": "a conversation on a profile linked to the connection runs with the connection's key; an Edit with the key left blank keeps it working."
        },
        {
          "id": "F11.rotate",
          "description": "typing a new key rotates it for new conversations: an invalid key makes the next conversation fail with the provider's authentication error and no reply; rotating back restores replies."
        },
        {
          "id": "F11.delete",
          "description": "**Delete** asks `Are you sure you want to delete the connection \"<name>\"?` (focus on Cancel); Cancel and Escape keep it; Delete removes it (toast `Connection \"<name>\" deleted`)."
        },
        {
          "id": "F11.delete-referenced",
          "description": "deleting a connection that profiles or the active settings reference is refused with the server's message naming them; the dialog stays open and the row stays."
        },
        {
          "id": "F11.delete-stale-reference",
          "description": "once its last linked profile is deleted (row shows `0 model(s)`) the connection can be deleted, or the UI says how to clear the remaining reference."
        },
        {
          "id": "F11.phone",
          "description": "at 390 px the rows, the row menu and the modals fit without horizontal overflow."
        },
        {
          "id": "F11.load-error",
          "description": "if listing connections fails (for example an unreadable connections store, `GET /api/llm/provider-connections` 400), the section shows `Failed to load provider connections.` (`provider-connections-load-error`) in red while the profiles list and **Add connection** still render."
        },
        {
          "id": "F11.long-name",
          "description": "a name at the 128-character limit is accepted; the row truncates the name and provider with an ellipsis, shows the full name as a hover tooltip (`title`) and keeps the `...` trigger in view at desktop and phone width."
        },
        {
          "id": "F11.edit-unlisted-provider",
          "description": "a connection whose provider is not in the provider catalog (created through the API) opens in Edit with that provider preselected and listed under `Other Models`, and saving keeps it."
        },
        {
          "id": "F11.cloud",
          "description": "on Cloud the section appears only with an organization bound and for members allowed to manage profiles; local users always see it."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Empty state (`F11.list-empty`).",
          "body": "Run `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'`, `control-openhands browser click 'testid=sidebar-settings-/settings/llm' --expect-url '/settings/llm'`, then `control-openhands browser text 'testid=provider-connections-empty'` and `control-openhands browser snapshot main`. The text is `No provider connections yet. Add one to share an API key across models.`; the snapshot shows heading `Provider connections`, paragraph `Share one API key across multiple models.` and button `Add connection` (screenshot `--feature F11.list-empty --name empty`).",
          "ids": [
            "F11.list-empty"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Other entry points (`F11.list`).",
          "body": "Run `control-openhands browser goto /` (Empty state ends on `/settings/llm`, where the URL wait would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' LLM` (`browser snapshot 'testid=command-menu'` shows the selected option `LLM profiles Manage models, providers, and API keys. Go`), `control-openhands browser press Enter`, `control-openhands browser wait-url '/settings/llm'`, then `control-openhands browser count 'testid=add-provider-connection'` (`1`). `control-openhands browser goto /settings/llm` shows the same section.",
          "ids": [
            "F11.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Validation and provider picker (`F11.create-validation`, `F11.provider-picker`).",
          "body": "Run `control-openhands browser click 'testid=add-provider-connection'`; `control-openhands browser snapshot 'role=dialog'` shows `Add provider connection` with Name, Provider, API Key, `Base URL Optional` and `Save` [disabled], and `control-openhands browser eval \"document.activeElement.getAttribute('data-testid')\"` is `provider-connection-name-input`. Fill `control-openhands browser fill 'testid=provider-connection-name-input' QA_conn`; `control-openhands browser enabled 'testid=provider-connection-submit'` stays `false`. Run `control-openhands browser click 'testid=provider-connection-provider-input'` and `control-openhands browser snapshot 'role=listbox'`: groups `Verified Models` (OpenHands, Anthropic, OpenAI, Mistral AI, Gemini, deepseek, Moonshot, minimax, glm, nvidia, qwen, OpenRouter; OpenRouter is listed since 2026-10-08 or earlier) and `Other Models`. The field shows the provider's label, not its id: picking `testid=provider-item-openai` makes `browser value 'testid=provider-connection-provider-input'` `OpenAI`. `control-openhands browser fill 'testid=provider-connection-provider-input' deep` narrows it to `deepseek` (verified) and `deepgram`, `DeepInfra`; then `control-openhands browser click 'testid=provider-item-deepseek'` and `control-openhands browser value 'testid=provider-connection-provider-input'` is `deepseek`. Save is still disabled; `control-openhands browser fill 'testid=provider-connection-api-key-input' '  '` keeps it disabled, and a name of only spaces does too. A provider typed but not picked is cleared: `control-openhands browser fill 'testid=provider-connection-provider-input' deepse`, `control-openhands browser click 'testid=provider-connection-name-input'`, then `browser value 'testid=provider-connection-provider-input'` is empty (pick deepseek again afterwards). Enter does not submit: with every field valid (`enabled` `true`), `control-openhands browser press Enter` in the Name field leaves `browser count 'testid=provider-connection-modal'` at `1` and creates nothing (`api GET /api/llm/provider-connections`).",
          "ids": [
            "F11.create-validation",
            "F11.provider-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Create (`F11.create`).",
          "body": "In the same modal run `control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>` (`enabled` turns `true`, `browser attr 'testid=provider-connection-api-key-input' type` is `password`), `control-openhands browser click 'testid=provider-connection-submit'` and `control-openhands browser wait-text 'Connection \"QA_conn\" created'`. `control-openhands browser count 'testid=provider-connection-modal'` is `0`. After `control-openhands browser reload`, `control-openhands browser text 'testid=provider-connection-row >> has-text=QA_conn'` is `QA_conn\\ndeepseek\\n0 model(s)` and `control-openhands api GET /api/llm/provider-connections` lists it with `\"api_key_set\": true` and `\"base_url\": null` (the key itself is never returned).",
          "ids": [
            "F11.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Row (`F11.list`).",
          "body": "Run `control-openhands browser count 'testid=provider-connection-row >> has-text=QA_conn >> testid=set-indicator'` (`1`), `control-openhands browser attr 'testid=provider-connection-row >> has-text=QA_conn >> testid=provider-connection-menu-trigger' aria-label` (`Provider connection menu`) and `control-openhands browser screenshot 'testid=provider-connection-row' --feature F11.list --name row` (name, muted provider, `0 model(s)`, green check, vertical dots).",
          "ids": [
            "F11.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Cancel (`F11.create-cancel`).",
          "body": "Run `control-openhands browser click 'testid=add-provider-connection'`, `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'`; `browser count 'testid=provider-connection-modal'` is `0`. Reopen, `control-openhands browser fill 'testid=provider-connection-name-input' QA_escape`, `control-openhands browser press Escape` (count `0`); reopen: `control-openhands browser value 'testid=provider-connection-name-input'` is empty; `control-openhands browser mouse-click 50 500` closes it too. `api GET /api/llm/provider-connections` lists no `QA_escape`.",
          "ids": [
            "F11.create-cancel"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Server error (`F11.create-error`).",
          "body": "Run `control-openhands browser click 'testid=add-provider-connection'`, `control-openhands browser fill 'testid=provider-connection-name-input' \"QA_$(printf 'x%.0s' $(seq 1 127))\"` (130 characters), pick deepseek as above, `control-openhands browser fill 'testid=provider-connection-api-key-input' dummy-key-3`, `control-openhands browser click 'testid=provider-connection-submit'`, then `control-openhands browser toasts` and `control-openhands browser count 'testid=provider-connection-modal'`. Expected: the modal stays open (`1`) and the toast reads like `String should have at most 128 characters`. Today the toast is the raw response, `HTTP request failed (422 Unprocessable Entity): {\"detail\":[{\"type\":\"string_too_long\",...` (fail, screenshot `--feature F11.create-error --name toast`). Close with `role=dialog >> role=button[name=\"Cancel\"]`.",
          "ids": [
            "F11.create-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Second connection.",
          "body": "Create `QA_spare` the same way with `dummy-key-2` as key and `control-openhands browser fill 'testid=provider-connection-base-url-input' 'not a url'` (accepted: base URLs are not validated). Toast `Connection \"QA_spare\" created`.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Row menu (`F11.row-menu`, `F11.row-menu-focus`).",
          "body": "Run `control-openhands browser click 'testid=provider-connection-row >> has-text=QA_spare >> testid=provider-connection-menu-trigger'` and `control-openhands browser snapshot 'testid=provider-connection-actions-menu'`: menuitems `Bulk add`, `Edit`, `Delete` (screenshot `--feature F11.row-menu --name open`). `control-openhands browser eval \"document.activeElement.getAttribute('data-testid')\"` should be `provider-connection-add-models`; today it stays `provider-connection-menu-trigger` (fail) and ArrowDown does nothing until `control-openhands browser press Tab` moves focus to Bulk add. From there `browser press ArrowDown` focuses `provider-connection-edit`, two `ArrowUp` wrap to `provider-connection-delete`, and `browser press Tab` closes the menu (`browser count 'testid=provider-connection-actions-menu'` `0`). Reopen and `browser press Escape` (`0`); reopen and click the trigger again (`0`); reopen and `control-openhands browser click 'text=Share one API key across multiple models.'` (`0`).",
          "ids": [
            "F11.row-menu",
            "F11.row-menu-focus"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Edit (`F11.edit`).",
          "body": "Run the trigger click above, then `control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-edit'` and `control-openhands browser snapshot 'role=dialog'`: `Edit provider connection`, Name `QA_spare`, Provider `deepseek`, `API Key Leave blank to keep the current key.` with placeholder `<hidden>`, Base URL `not a url`; `browser value 'testid=provider-connection-api-key-input'` is empty and Save is enabled. Run `control-openhands browser fill 'testid=provider-connection-name-input' QA_edited`, `control-openhands browser fill 'testid=provider-connection-base-url-input' 'https://api.deepseek.com'`, `control-openhands browser click 'testid=provider-connection-submit'` and `control-openhands browser wait-text 'Connection \"QA_edited\" updated'`. After `browser reload` the row reads `QA_edited\\ndeepseek\\n0 model(s)`, the `QA_spare` count is `0`, and `api GET /api/llm/provider-connections` shows `base_url` `https://api.deepseek.com` and `api_key_set` `true`. Edit again: fill the name with `' '` (`browser enabled 'testid=provider-connection-submit'` `false`), restore `QA_edited`, `control-openhands browser fill 'testid=provider-connection-base-url-input' ''`, Save, `browser wait 'testid=provider-connection-modal' --state detached`; the API shows `base_url` `null`.",
          "ids": [
            "F11.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Bulk add (`F11.add-models`).",
          "body": "Run `control-openhands browser click 'testid=provider-connection-row >> has-text=QA_conn >> testid=provider-connection-menu-trigger'` and `control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-add-models'`. The dialog `Add models as profiles` has provider `deepseek` selected, `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=add-models-connection] option')].map(o=>o.textContent+'='+o.selected).join(' | ')\"` is `No connection (keyless)=false | QA_conn=true | QA_edited=false`, and `browser text 'testid=add-models-submit'` is `Add 12 profiles` (every model checked). Run `control-openhands browser uncheck 'testid=add-models-select-all'` (`Add 0 profiles`), `control-openhands browser check 'testid=add-models-check-deepseek/deepseek-flash'` (`Add 1 profiles`), `control-openhands browser click 'testid=add-models-submit'` and `control-openhands browser wait-text 'Added 1'`. After `browser reload` the `QA_conn` row reads `QA_conn\\ndeepseek\\n1 model(s)` (`QA_edited` stays `0 model(s)`), and `api GET /api/profiles` shows `deepseek-flash` with `provider_connection_id` and `\"active_profile\": \"deepseek-flash\"`.",
          "ids": [
            "F11.add-models"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "The agent uses the connection's key (`F11.agent-uses-connection`).",
          "body": "Open Edit on `QA_conn` (as above), leave the key blank, click `testid=provider-connection-submit` and `browser wait-text 'Connection \"QA_conn\" updated'`. Run `control-openhands conversation start --prompt \"Reply with exactly the word: pong\" --wait --timeout 180` (prints `<id>`, `\"model\": \"deepseek/deepseek-flash\"`, status `finished`), then `control-openhands conversation events <id> --kinds ConversationErrorEvent,MessageEvent`: the agent message is `pong` and there is no `ConversationErrorEvent`.",
          "ids": [
            "F11.agent-uses-connection"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Rotate the key (`F11.rotate`).",
          "body": "Run `control-openhands browser goto /settings/llm`, open Edit on `QA_conn`, `control-openhands browser fill 'testid=provider-connection-api-key-input' sk-qa-invalid-0000`, Save and `browser wait-text 'Connection \"QA_conn\" updated'`. Run `control-openhands conversation start --prompt \"Reply with exactly the word: ping\" --wait --timeout 180` and `control-openhands conversation events <id> --kinds ConversationErrorEvent,MessageEvent`. Expected: a `ConversationErrorEvent` and no agent reply. Today the conversation shows the banner `litellm.BadRequestError: DeepseekException - ... Authentication Fails, Your api key: ****0000 is invalid` (so the new key was used), yet the agent still answers `ping` in the same turn (fail, screenshot `--feature F11.rotate --name bad-key`). Rotate back: `browser goto /settings/llm`, Edit `QA_conn`, `control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>`, Save; a new `conversation start --prompt \"Reply with exactly the word: pong\" --wait --timeout 180` has `pong` and no `ConversationErrorEvent`.",
          "ids": [
            "F11.rotate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Rename a linked connection (`F11.edit`).",
          "body": "Run `control-openhands browser goto /settings/llm`, Edit `QA_conn`, fill the name with `QA_shared`, Save and `browser wait-text 'Connection \"QA_shared\" updated'`. After `browser reload`, `control-openhands browser text 'testid=profile-group-header'` is `QA_SHARED` (CSS upper-case).",
          "ids": [
            "F11.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Delete is refused while referenced (`F11.delete-referenced`).",
          "body": "Run `control-openhands browser click 'testid=provider-connection-row >> has-text=QA_shared >> testid=provider-connection-menu-trigger'`, `control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-delete'`, `control-openhands browser click 'testid=delete-provider-connection-confirm'`, then `control-openhands browser toasts`: `Provider connection cannot be deleted while it is referenced by LLM profile(s): deepseek-flash and referenced by the active agent settings. Update those references before deleting it.` The dialog stays open (`browser count 'role=dialog'` > `0`); close it with `role=dialog >> role=button[name=\"Cancel\"]`; after `browser reload` the row is still there.",
          "ids": [
            "F11.delete-referenced"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Delete (`F11.delete`).",
          "body": "Run `control-openhands browser click 'testid=provider-connection-row >> has-text=QA_edited >> testid=provider-connection-menu-trigger'`, `control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-delete'` and `control-openhands browser snapshot 'role=dialog'`: `Delete provider connection`, `Are you sure you want to delete the connection \"QA_edited\"?`, buttons `Cancel` and `Delete`; `browser eval \"document.activeElement.textContent\"` is `Cancel` (screenshot `--feature F11.delete --name confirm`). `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps the row; reopen and `browser press Escape` keeps it too. Reopen, `control-openhands browser click 'testid=delete-provider-connection-confirm'`, `control-openhands browser wait-text 'Connection \"QA_edited\" deleted'`, `browser reload`: `browser count 'testid=provider-connection-row >> has-text=QA_edited'` is `0`.",
          "ids": [
            "F11.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Phone layout (`F11.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser scroll 'testid=add-provider-connection'`, `control-openhands browser bbox 'testid=provider-connection-row >> nth=0'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `browser screenshot --feature F11.phone --name list`. Open the `QA_shared` row menu: `browser bbox 'testid=provider-connection-actions-menu'` is inside the viewport. Click `testid=provider-connection-actions-menu >> testid=provider-connection-edit`: `browser bbox 'testid=provider-connection-modal'` and `browser bbox 'testid=provider-connection-submit'` are inside the viewport (screenshot `--name edit-modal`). Cancel, then `control-openhands browser viewport desktop`.",
          "ids": [
            "F11.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Long name and provider change (`F11.long-name`, `F11.edit`).",
          "body": "Create a connection named `\"QA_long$(printf 'x%.0s' $(seq 1 121))\"` (128 characters) with deepseek and `dummy-key-5` as above: toast `Connection \"QA_long…\" created`, modal closed. `control-openhands browser tooltip 'testid=provider-connection-row >> has-text=QA_long >> span[title]'` returns the full 128-character name (`source` `title attribute`), `browser eval` on that span shows `scrollWidth > clientWidth` with `text-overflow: ellipsis`, and `browser bbox 'testid=provider-connection-row >> has-text=QA_long'` is `insideViewport` `true`, `pageHorizontalOverflow` `false` (screenshot `--feature F11.long-name --name row`). At `browser viewport phone` after `browser scroll 'testid=add-provider-connection'`, `browser bbox 'testid=provider-connection-row >> has-text=QA_long >> testid=provider-connection-menu-trigger'` is inside the viewport with no horizontal overflow (screenshot `--name phone`); back to `browser viewport desktop`. Open Edit on `QA_long`: `browser count 'testid=provider-connection-modal >> testid=set-indicator'` is `1` (green key-set check beside API Key). `control-openhands browser choose 'testid=provider-connection-provider-input' Anthropic`, Save, `browser wait 'testid=provider-connection-modal' --state detached`, `browser reload`: the row reads `QA_long…\\nanthropic\\n0 model(s)` and `api GET /api/llm/provider-connections` shows `\"provider\": \"anthropic\"`.",
          "ids": [
            "F11.long-name",
            "F11.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Persistence across a backend restart (`F11.create`).",
          "body": "Run `control-openhands restart` and `control-openhands browser reload`; `browser eval \"[...document.querySelectorAll('[data-testid=provider-connection-row]')].map(r=>r.innerText.replace(/\\n/g,'|')).join(' ; ')\"` lists the same rows and counts as before (for example `QA_shared|deepseek|1 model(s) ; QA_long…|anthropic|0 model(s)`).",
          "ids": [
            "F11.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Unlisted provider (`F11.edit-unlisted-provider`).",
          "body": "Arrange (not proof) `control-openhands api POST /api/llm/provider-connections --data '{\"display_name\":\"QA_custom\",\"provider\":\"qa_unlisted\",\"api_key\":\"dummy-key-6\",\"base_url\":null}' --write` (201), then `browser reload`: the row reads `QA_custom\\nqa_unlisted\\n0 model(s)`. Open Edit on `QA_custom`: `browser value 'testid=provider-connection-provider-input'` is `qa_unlisted`; `browser click` that input, `browser fill ... qa_` and `browser snapshot 'role=listbox'` shows only group `Other Models` with option `qa_unlisted` [selected]. Click `'role=option[name=\"qa_unlisted\"]'`, fill the name with `QA_custom2`, Save: toast `Connection \"QA_custom2\" updated` and the API still shows `\"provider\": \"qa_unlisted\"`. Delete `QA_custom2` and `QA_long` through their row menus and `testid=delete-provider-connection-confirm` (wait for the confirm button `--state detached` between them); `api GET /api/llm/provider-connections` lists only `QA_shared`.",
          "ids": [
            "F11.edit-unlisted-provider"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Stale reference and cleanup (`F11.delete-stale-reference`).",
          "body": "Delete the linked profile (F10): `control-openhands browser click '[data-testid=profile-row]:has([title=\"deepseek-flash\"]) >> testid=profile-menu-trigger'`, `control-openhands browser click 'testid=profile-actions-menu >> testid=profile-delete'`, `control-openhands browser click 'testid=delete-profile-confirm'` (toast `Profile \"deepseek-flash\" deleted`). After `browser reload` the `QA_shared` row reads `0 model(s)`. Delete `QA_shared` through its row menu and `testid=delete-provider-connection-confirm`. Expected: it is deleted. Today the toast is `Provider connection cannot be deleted while it is referenced by the active agent settings. Update those references before deleting it.` and nothing on the page clears that reference (fail, screenshot `--feature F11.delete-stale-reference --name refused`). To finish cleanup, close the dialog, arrange an unlinked active profile with `control-openhands llm preset deepseek --api-key-file <key file>` (this is the arrange step, not proof), `browser reload`, delete `QA_shared` through the row menu (toast `Connection \"QA_shared\" deleted`), `browser reload`: `testid=provider-connections-empty` is back and `api GET /api/llm/provider-connections` is `[]`.",
          "ids": [
            "F11.delete-stale-reference"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Load error (`F11.load-error`).",
          "body": "With the baseline restored (connections `[]`), run the shell arrange step (it writes only this run's state) `F=$OH_VERIFY_RUN/private/provider-connections/provider_connections.json; cp $F $F.bak; printf '{not json' > $F`. `control-openhands api GET /api/llm/provider-connections` is now `400` (`Provider connections file is unreadable: ...`) while `api GET /api/profiles` is `200`. Run `control-openhands browser reload`, `control-openhands browser text 'testid=provider-connections-load-error'` (`Failed to load provider connections.`), `browser count 'testid=add-provider-connection'` (`1`) and `browser count 'testid=profile-row'` (> `0`; screenshot `--feature F11.load-error --name error`). Restore with `cp $F.bak $F && rm $F.bak`, `browser reload`: `testid=provider-connections-empty` is back and `provider-connections-load-error` count is `0`.",
          "ids": [
            "F11.load-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Errors sweep.",
          "body": "`control-openhands browser errors --app-only` shows `pageErrors` `0`; the HTTP errors are the deliberate `POST 422` (long name), two `DELETE 409` (referenced, then the stale reference) and the `GET 400` from the load-error arrange, all on `/api/llm/provider-connections`. Run `browser errors --clear` afterwards.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F12",
      "title": "Model router (meta-LLM profiles)",
      "file": "F12-model-router.md",
      "page": "F12-model-router.html",
      "sha256": "e0afc9ae10bd621bb3ea349d64fab85797cb480b5418813b8c7380e2d2f99e19",
      "behaviors": [
        {
          "id": "F12.page",
          "description": "Settings → **Model Router** opens `/settings/meta-llm` with the subtitle `Meta-profiles that route each task to the right LLM profile.`; the command menu offers a **Model Router** command that opens it, like the other settings pages."
        },
        {
          "id": "F12.empty-states",
          "description": "with no LLM profiles the page says `Create LLM profiles first — meta-profiles route between your saved LLM profiles.`; with no routers it says `No meta-profiles yet. Add one to route tasks across your LLM profiles.` and **Run on first message** is off and disabled."
        },
        {
          "id": "F12.load-error",
          "description": "when only the meta-profile request fails, a red generic error shows under the header."
        },
        {
          "id": "F12.unsupported",
          "description": "an agent server without `/api/meta-profiles` (404) shows `This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.`"
        },
        {
          "id": "F12.cloud",
          "description": "a Cloud backend without an organization shows `Meta-profiles are only available for local backends.`; an organization-bound Cloud backend shows the full page."
        },
        {
          "id": "F12.template-chooser",
          "description": "**Add Model Router** opens `Choose Model Router template` (Add OpenHands Router Pro, Add OpenHands Router Flash, Custom, Cancel); Cancel, Escape and a backdrop click close it; Pro and Flash prefill the name, classifier `minimax-m3`, their own long routing prompt and the same 11-model table."
        },
        {
          "id": "F12.template-custom",
          "description": "**Custom** opens a blank editor; the placeholders show an example prompt and a two-line example model table."
        },
        {
          "id": "F12.editor-validation",
          "description": "Save stays disabled until the name is valid, a classifier is set and the prompt contains `{{ instance_text }}` (spaces inside the braces optional)."
        },
        {
          "id": "F12.duplicate-name",
          "description": "a new router cannot take an existing router's name (`A meta-profile with this name already exists...`, Save disabled)."
        },
        {
          "id": "F12.add-connection",
          "description": "**Add connection** in the create editor opens `Add provider connection`; saving it selects the new connection in the picker."
        },
        {
          "id": "F12.add-connection-cancel",
          "description": "**Cancel** in `Add provider connection` closes it without creating a connection and keeps the editor's values and picker choice."
        },
        {
          "id": "F12.editor-cancel",
          "description": "while the create or edit editor is open it replaces the list, **Add Model Router** and the switch; **Cancel** returns to the list without saving anything."
        },
        {
          "id": "F12.router-profiles",
          "description": "with a connection picked under **Create missing router LLM profiles using**, Save creates an LLM profile `<provider>/<name>` linked to that connection for every model-table name (and the classifier) that does not exist yet; **Don't create profiles** creates none."
        },
        {
          "id": "F12.create-first",
          "description": "saving the first router lists it, makes it Active and turns **Run on first message** on."
        },
        {
          "id": "F12.create-more",
          "description": "saving another router lists it and leaves the Active badge where it was."
        },
        {
          "id": "F12.list",
          "description": "each row shows the name, `<classifier> · Direct prompt`, an **Active** badge on the active router and a `...` menu; routers and the active choice survive a stack restart."
        },
        {
          "id": "F12.row-menu",
          "description": "the row menu offers Edit, Set active (disabled on the active row) and Delete, and closes on Escape."
        },
        {
          "id": "F12.activate",
          "description": "**Set active** toasts `Meta-profile \"<name>\" activated` and moves the badge."
        },
        {
          "id": "F12.edit",
          "description": "**Edit** opens `Edit meta-profile` with the name locked and no connection picker; changes persist."
        },
        {
          "id": "F12.delete",
          "description": "**Delete** asks `Are you sure you want to delete \"<name>\"? This cannot be undone.`; Cancel keeps the row, Delete removes it."
        },
        {
          "id": "F12.delete-active",
          "description": "deleting the active router leaves no router active; the switch shows off and disabled, and new conversations get neither the tool nor the first-message instruction."
        },
        {
          "id": "F12.run-first-message-toggle",
          "description": "the switch saves immediately (no Save button, no toast) and survives a reload."
        },
        {
          "id": "F12.route-first-message",
          "description": "with a router active and the switch on, a new conversation routes its first message (`route_task_to_model` action, `Switched to profile <name>` in the chat); with the switch off the tool is still attached but the instruction is absent."
        },
        {
          "id": "F12.light-theme",
          "description": "in a light colour theme the list heading and router names are readable."
        },
        {
          "id": "F12.phone",
          "description": "the list, template modal, editor and row menu fit a 390 px viewport without horizontal overflow."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open from navigation (`F12.page`).",
          "body": "From `/` run `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'`, `control-openhands browser click 'testid=sidebar-settings-/settings/meta-llm' --expect-url '/settings/meta-llm(\\?|$)'`, `control-openhands browser text 'testid=settings-page-subtitle'` and `control-openhands browser attr 'testid=sidebar-settings-/settings/meta-llm' aria-current`. The subtitle is `Meta-profiles that route each task to the right LLM profile.` and `aria-current` is `page`.",
          "ids": [
            "F12.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command menu (`F12.page`).",
          "body": "Run `control-openhands browser goto /` (the previous bullet ends on `/settings/meta-llm`, where the URL wait would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' 'Model Router'` and `control-openhands browser snapshot 'testid=command-menu'`: the only option is `Model Router Meta-profiles that route each task to the right LLM profile. Go` [selected]. `control-openhands browser fill 'testid=command-menu >> role=combobox' router` (and then `meta`) keeps that single option (`control-openhands browser count 'testid=command-menu >> role=option'` is `1`). Run `control-openhands browser press Enter` and `control-openhands browser wait-url '/settings/meta-llm(\\?|$)'`; `browser text 'testid=settings-page-subtitle'` is the subtitle above.",
          "ids": [
            "F12.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Empty states (`F12.empty-states`).",
          "body": "On the fresh run run `control-openhands browser goto /settings/meta-llm`, `control-openhands browser text 'testid=meta-profile-no-llm-profiles'`, `control-openhands browser text 'testid=meta-profile-empty'` and `control-openhands browser eval \"(()=>{const i=document.querySelector('[data-testid=meta-profile-run-at-conversation-start-switch]');return {checked:i.checked,disabled:i.disabled}})()\"`. The texts are the two messages in Sub-features and the switch is `{checked:false, disabled:true}`. `control-openhands browser screenshot --feature F12.empty-states --name no-llm-profiles` shows both messages above a greyed switch.",
          "ids": [
            "F12.empty-states"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Template chooser (`F12.template-chooser`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'` and `control-openhands browser snapshot 'role=dialog'`: dialog `Choose Model Router template` with buttons `Add OpenHands Router Pro`, `Add OpenHands Router Flash`, `Custom`, `Cancel`. `control-openhands browser click 'testid=meta-profile-template-cancel'` then `control-openhands browser count 'testid=meta-profile-template-modal'` is `0`; reopen and `control-openhands browser press Escape` gives `0` too. Reopen, `control-openhands browser click 'testid=meta-profile-template-router-pro'`, then `control-openhands browser value 'testid=meta-profile-name-input'` (`openhands-router-pro`), `control-openhands browser value 'testid=meta-profile-classifier-input'` (`minimax-m3`), `control-openhands browser eval \"document.querySelector('[data-testid=meta-profile-model-table]').value.split('\\n').length\"` (`11`), `control-openhands browser eval \"document.querySelector('[data-testid=meta-profile-prompt-template]').value.includes('{{ instance_text }}')\"` (`true`), `control-openhands browser value 'testid=meta-profile-router-connection'` (`Don't create profiles` while no connection exists) and `control-openhands browser enabled 'testid=meta-profile-save'` (`true`). Leave with `control-openhands browser click 'testid=meta-profile-cancel'`. The Flash template (`testid=meta-profile-template-router-flash`) gives `openhands-router-flash`, the same classifier and identical table, and a different prompt (about 5,900 characters against Pro's 5,000; compare with `browser eval \"document.querySelector('[data-testid=meta-profile-prompt-template]').value.length\"`). A backdrop click also closes the chooser: reopen it and `control-openhands browser mouse-click 20 980`, then `count` is `0`.",
          "ids": [
            "F12.template-chooser"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Custom template (`F12.template-custom`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'`, `control-openhands browser click 'testid=meta-profile-template-custom'`, then `control-openhands browser value` on `testid=meta-profile-name-input`, `testid=meta-profile-classifier-input`, `testid=meta-profile-prompt-template` and `testid=meta-profile-model-table` (all `\"\"`), `control-openhands browser enabled 'testid=meta-profile-save'` (`false`) and `control-openhands browser eval \"document.querySelector('[data-testid=meta-profile-model-table]').placeholder.split('\\n')\"`: two example rows, `[\"- GPT-5.4: strong for coding and reasoning\", \"- MiniMax-M3: efficient fallback\"]`, joined by a real line break (#18035), and `control-openhands browser screenshot --feature F12.template-custom --name custom-empty` shows them on two lines under the prompt placeholder, which breaks lines the same way.",
          "ids": [
            "F12.template-custom"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Validation (`F12.editor-validation`).",
          "body": "In that Custom editor run `control-openhands browser fill 'testid=meta-profile-name-input' 'QA router!'`, `control-openhands browser fill 'testid=meta-profile-classifier-input' qa-classifier`, `control-openhands browser fill 'testid=meta-profile-prompt-template' 'Pick a model for: {{ instance_text }}'` and `control-openhands browser enabled 'testid=meta-profile-save'` (`false`: bad name). `control-openhands browser fill 'testid=meta-profile-name-input' QA_router` turns it `true`; `control-openhands browser fill 'testid=meta-profile-prompt-template' 'Pick a model for: {{ task }}'` turns it `false`; `'Pick a model for: {{instance_text}}'` turns it `true` again; `control-openhands browser fill 'testid=meta-profile-classifier-input' ''` turns it `false`. Keep the editor open.",
          "ids": [
            "F12.editor-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Add a connection (`F12.add-connection`).",
          "body": "Run `control-openhands browser click 'testid=meta-profile-add-provider-connection'`, `control-openhands browser fill 'testid=provider-connection-name-input' QA_deepseek`, `control-openhands browser fill 'testid=provider-connection-provider-input' deepseek`, `control-openhands browser click 'role=option[name=\"deepseek\"]'`, `control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>`, `control-openhands browser click 'testid=provider-connection-submit'` and `control-openhands browser wait 'testid=provider-connection-modal' --state detached`. `control-openhands browser toasts` has `Connection \"QA_deepseek\" created` and `control-openhands browser value 'testid=meta-profile-router-connection'` is `QA_deepseek (deepseek)`.",
          "ids": [
            "F12.add-connection"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Create the first router (`F12.create-first`, `F12.router-profiles`).",
          "body": "Write the prompt and table to files: `printf 'You route tasks. Reply only with JSON {\"model\": \"<name>\", \"reason\": \"<short>\"} using one name from this table:\\n{{ model_table }}\\n\\nTask:\\n{{ instance_text }}' > <prompt file>` and `printf -- '- deepseek-flash: cheap, for simple tasks\\n- deepseek-pro: stronger, for hard tasks\\n- deepseek-chat: general chat' > <table file>`. Run `control-openhands browser click 'testid=meta-profile-classifier-input'`, `control-openhands browser click 'role=option[name=\"deepseek-flash\"]'`, `control-openhands browser fill 'testid=meta-profile-name-input' QA_router`, `control-openhands browser fill 'testid=meta-profile-prompt-template' --value-file <prompt file>`, `control-openhands browser fill 'testid=meta-profile-model-table' --value-file <table file>`, `control-openhands browser click 'testid=meta-profile-save'` and `control-openhands browser wait-text 'Meta-profile \"QA_router\" saved'`. After `control-openhands browser reload`: `control-openhands browser text 'testid=meta-profile-row-QA_router'` is `QA_router\\ndeepseek-flash · Direct prompt\\nActive`, the switch `eval` above returns `{checked:true, disabled:false}`, `control-openhands api GET /api/meta-profiles` has `active_meta_profile` `QA_router`, `control-openhands api GET /api/settings` has `\"run_router_at_conversation_start\": true`, and `control-openhands api GET /api/profiles` has a new `deepseek-chat` (`deepseek/deepseek-chat`, `provider_connection_id` set) while `deepseek-flash`/`deepseek-pro` are unchanged. `control-openhands browser screenshot --feature F12.create-first --name list-after-create` shows the row with the gold Active badge and the switch on.",
          "ids": [
            "F12.create-first",
            "F12.router-profiles"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Duplicate name (`F12.duplicate-name`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'`, `control-openhands browser click 'testid=meta-profile-template-custom'`, `control-openhands browser fill 'testid=meta-profile-name-input' QA_router`, `control-openhands browser text 'testid=meta-profile-name-taken'` (`A meta-profile with this name already exists. Choose a different name, or edit the existing one.`), pick classifier `deepseek-pro` as above, fill the prompt with `'Route: {{ instance_text }}'`, and `control-openhands browser enabled 'testid=meta-profile-save'` (`false`). `control-openhands browser fill 'testid=meta-profile-name-input' QA_router_2` makes `control-openhands browser count 'testid=meta-profile-name-taken'` `0` and Save `true`. Click `testid=meta-profile-cancel`; `control-openhands browser count 'testid=meta-profile-row-QA_router_2'` is `0`.",
          "ids": [
            "F12.duplicate-name"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "\"Don't create profiles\" in a template (`F12.router-profiles`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'`, `control-openhands browser click 'testid=meta-profile-template-router-pro'`, `control-openhands browser value 'testid=meta-profile-router-connection'` (`QA_deepseek (deepseek)`, preselected), `control-openhands browser click 'testid=meta-profile-router-connection'` and `control-openhands browser click \"role=option[name=\\\"Don't create profiles\\\"]\" --observe 'testid=meta-profile-router-connection' --observe-ms 1500`. The picker stays on `Don't create profiles`: the observed states are `QA_deepseek (deepseek)` at 0 ms and `Don't create profiles` about 300 ms later, with nothing after it, and `control-openhands browser value 'testid=meta-profile-router-connection'` is `Don't create profiles`. Cancel the editor (`control-openhands browser click 'testid=meta-profile-cancel'`); `F12.create-more` saves a template with this choice. The Custom template starts on `Don't create profiles` even with a connection present; there, pick `role=option[name=\"QA_deepseek (deepseek)\"]` first and then `Don't create profiles`: the second pick sticks. Cancel the editor.",
          "ids": [
            "F12.router-profiles"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Create another router without new profiles (`F12.create-more`, `F12.router-profiles`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'`, `control-openhands browser click 'testid=meta-profile-template-router-flash'`, `control-openhands browser value 'testid=meta-profile-router-connection'` (`QA_deepseek (deepseek)`, preselected), `control-openhands browser fill 'testid=meta-profile-name-input' QA_router_flash`, `control-openhands browser click 'testid=meta-profile-router-connection'`, `control-openhands browser click \"role=option[name=\\\"Don't create profiles\\\"]\"` and `control-openhands browser value 'testid=meta-profile-router-connection'` (`Don't create profiles`). Then `control-openhands browser click 'testid=meta-profile-save'` and `control-openhands browser wait-text 'Meta-profile \"QA_router_flash\" saved'`. After `control-openhands browser reload`, `control-openhands browser text 'testid=meta-profile-list'` is `QA_router\\ndeepseek-flash · Direct prompt\\nActive\\nQA_router_flash\\nminimax-m3 · Direct prompt` and `control-openhands browser count 'testid=meta-profile-active-badge'` is `1`. `control-openhands api GET /api/profiles` still lists only `deepseek-chat`, `deepseek-flash` and `deepseek-pro`: none of the template's 11 models became a profile, so `QA_router_flash` names a classifier (`minimax-m3`) that does not exist, which saving allows (see Gotchas).",
          "ids": [
            "F12.create-more",
            "F12.router-profiles"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Row menu and activate (`F12.row-menu`, `F12.activate`).",
          "body": "Run `control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router'` and `control-openhands browser snapshot 'testid=meta-profile-actions-menu'` (`menuitem \"Edit\"`, `menuitem \"Set active\" [disabled]`, `menuitem \"Delete\"`); `control-openhands browser press Escape` closes it (`count` `0`). Then `control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_flash'`, `control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-set-active'` and `control-openhands browser wait-text 'Meta-profile \"QA_router_flash\" activated'`. After `control-openhands browser reload`, `control-openhands browser count 'testid=meta-profile-row-QA_router_flash >> testid=meta-profile-active-badge'` is `1`, the total badge count is `1`, and `api GET /api/meta-profiles` agrees.",
          "ids": [
            "F12.row-menu",
            "F12.activate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Edit (`F12.edit`).",
          "body": "Run `control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router'`, `control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-edit'`, `control-openhands browser text 'testid=meta-profile-editor >> role=heading'` (`Edit meta-profile`), `control-openhands browser enabled 'testid=meta-profile-name-input'` (`false`), `control-openhands browser count 'testid=meta-profile-router-connection'` (`0`). Pick classifier `deepseek-pro`, fill `testid=meta-profile-model-table` with the first two table lines only, click `testid=meta-profile-save` and `control-openhands browser wait-text 'Meta-profile \"QA_router\" saved'`. After a reload the row reads `QA_router\\ndeepseek-pro · Direct prompt`, the badge stays on `QA_router_flash`, and `control-openhands api GET /api/meta-profiles/QA_router` shows the new classifier and table.",
          "ids": [
            "F12.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Cancel the connection dialog (`F12.add-connection-cancel`).",
          "body": "Run `control-openhands browser click 'testid=add-meta-profile'`, `control-openhands browser click 'testid=meta-profile-template-custom'`, `control-openhands browser fill 'testid=meta-profile-name-input' QA_router_cancel`, `control-openhands browser click 'testid=meta-profile-add-provider-connection'`, `control-openhands browser fill 'testid=provider-connection-name-input' QA_unused`, `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` (the Cancel button has no test id) and `control-openhands browser wait 'testid=provider-connection-modal' --state detached`. `browser value 'testid=meta-profile-name-input'` is still `QA_router_cancel`, the picker still reads `Don't create profiles`, and `control-openhands api GET /api/llm/provider-connections` has no `QA_unused` (`display_name` is the field).",
          "ids": [
            "F12.add-connection-cancel"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Leave the editor (`F12.editor-cancel`).",
          "body": "In that editor `control-openhands browser count` on `testid=meta-profile-list`, `testid=add-meta-profile` and `testid=meta-profile-run-at-conversation-start` is `0` each. Pick classifier `deepseek-flash`, fill the prompt `'Pick: {{ instance_text }}'`, click `testid=meta-profile-cancel`: `count 'testid=meta-profile-list'` is `1`, `count 'testid=meta-profile-row-QA_router_cancel'` is `0` and `api GET /api/meta-profiles` has no `QA_router_cancel`. In edit mode (row menu → Edit on `QA_router`), `control-openhands browser fill 'testid=meta-profile-prompt-template' 'Changed: {{ instance_text }}'` then Cancel leaves `control-openhands api GET /api/meta-profiles/QA_router` with its saved `prompt_template`.",
          "ids": [
            "F12.editor-cancel"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Run on first message (`F12.run-first-message-toggle`).",
          "body": "Run `control-openhands browser click 'testid=meta-profile-run-at-conversation-start >> text=Run on first message'`, then `control-openhands browser reload` and the switch `eval`: `checked` flips and stays flipped; `api GET /api/settings` shows `run_router_at_conversation_start` with the same value; `control-openhands browser toasts` is empty. Click again to restore `true`.",
          "ids": [
            "F12.run-first-message-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Routing in a conversation (`F12.route-first-message`).",
          "body": "Make `QA_router` active (row menu → Set active, `wait-text 'Meta-profile \"QA_router\" activated'`) with the switch on. Run `control-openhands conversation start --prompt \"Run echo routed in the terminal and tell me its output.\" --wait --timeout 300`, then `control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent` (`<id>` from the start output). The first action is `route_task_to_model`, its observation starts `Classified task as 'model: deepseek-flash' and switched to LLM profile 'deepseek-flash'`, then the terminal runs `echo routed`. On the conversation page `control-openhands browser wait-text 'Switched to profile'` succeeds and `control-openhands browser screenshot --feature F12.route-first-message --name conversation` shows the `Switched to profile deepseek-flash` line above the answer. `control-openhands api GET \"/api/conversations/<id>/events/search?limit=1\" --max-bytes 200000` (the system prompt) contains `ROUTE_AT_CONVERSATION_START` and `\"route_task_to_model\"`. Switch **Run on first message** off (`control-openhands browser goto /settings/meta-llm` first: the browser is on the conversation), start `--prompt \"Reply with only the word pong.\"`, and the same GET contains `\"route_task_to_model\"` but no `ROUTE_AT_CONVERSATION_START`; there is no route action.",
          "ids": [
            "F12.route-first-message"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Delete (`F12.delete`).",
          "body": "Run `control-openhands browser goto /settings/meta-llm`, `control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_flash'`, `control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-delete'` and `control-openhands browser snapshot 'role=dialog'` (`Delete meta-profile`, `Are you sure you want to delete \"QA_router_flash\"? This cannot be undone.`, Cancel, Delete). `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps the row (`count` `1`). Repeat the menu click, then `control-openhands browser click 'testid=delete-meta-profile-confirm'`, `control-openhands browser wait-text 'Meta-profile \"QA_router_flash\" deleted'`, `control-openhands browser reload`; `control-openhands browser count 'testid=meta-profile-row-QA_router_flash'` is `0`.",
          "ids": [
            "F12.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Delete the active router (`F12.delete-active`).",
          "body": "With `QA_router` active and the switch on, delete it the same way (`wait-text 'Meta-profile \"QA_router\" deleted'`). After a reload `testid=meta-profile-empty` is back, the switch `eval` is `{checked:false, disabled:true}` and `api GET /api/meta-profiles` has `active_meta_profile` `null`, while `api GET /api/settings` still has `\"run_router_at_conversation_start\": true`. A new `conversation start --prompt \"Reply with only the word pong.\" --wait` has neither `\"route_task_to_model\"` nor `ROUTE_AT_CONVERSATION_START` in its events/search output.",
          "ids": [
            "F12.delete-active"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Light theme (`F12.light-theme`).",
          "body": "No router is left after `F12.delete-active`, whose last `conversation start` left the browser on a conversation: run `control-openhands browser goto /settings/meta-llm` and create one through Custom as in `F12.phone` (name `QA_router_light`, classifier `deepseek-flash`, prompt `'Pick one model for: {{ instance_text }}'`, `wait-text 'Meta-profile \"QA_router_light\" saved'`), then `control-openhands browser goto /settings/app`, `control-openhands browser click 'testid=color-theme-input'`, `control-openhands browser click 'role=option[name=\"Light+\"]'`, `control-openhands browser goto /settings/meta-llm` and `control-openhands browser eval \"(()=>{const h=[...document.querySelectorAll('h2')].find(e=>e.textContent.includes('Available meta-profiles'));const n=document.querySelector('[data-testid^=meta-profile-row-] span');return {heading:getComputedStyle(h).color,rowName:getComputedStyle(n).color,bg:getComputedStyle(document.body).backgroundColor}})()\"`. It returns `{heading: \"rgb(31, 31, 31)\", rowName: \"rgb(31, 31, 31)\", bg: \"rgb(255, 255, 255)\"}`: dark text, the same ink as LLM profile names on `/settings/llm`. `control-openhands browser screenshot --feature F12.light-theme --name list` shows the heading `Available meta-profiles` and the name `QA_router_light` next to its summary and Active badge. Restore with `role=option[name=\"OpenHands-Neutral\"]` the same way, then delete `QA_router_light` through its row menu (`wait-text 'Meta-profile \"QA_router_light\" deleted'`) so `F12.phone` starts on a router-less page.",
          "ids": [
            "F12.light-theme"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Phone (`F12.phone`, also re-proves `F12.create-first` on a router-less page).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/meta-llm`, `control-openhands browser click 'testid=add-meta-profile'` and `control-openhands browser bbox 'testid=meta-profile-template-modal'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`). Choose Custom, fill `QA_router_phone`, classifier `deepseek-flash` and prompt `'Pick one model for: {{ instance_text }}'`; `control-openhands browser bbox 'testid=meta-profile-editor'` has no page overflow (taller than the screen, scrolls). Save, reload, then `control-openhands browser bbox 'testid=meta-profile-row-QA_router_phone'` and, after `control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_phone'`, `control-openhands browser bbox 'testid=meta-profile-actions-menu'` are inside the viewport without page overflow. `control-openhands browser screenshot --feature F12.phone --name list` shows `QA_route…` truncated, the summary, the badge and the switch on. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F12.phone",
            "F12.create-first"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Restart persistence (`F12.list`).",
          "body": "Run `control-openhands restart`, `control-openhands doctor`, `control-openhands browser goto /settings/meta-llm` and `control-openhands browser count 'testid=meta-profile-row-QA_router_phone >> testid=meta-profile-active-badge'`; it is `1`.",
          "ids": [
            "F12.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Cleanup.",
          "body": "Switch **Run on first message** off through its label first (it disables once no router is left), then delete each `QA_*` router through the row menu. Remove the arranged LLM profile and connection with `control-openhands api DELETE /api/profiles/deepseek-chat --write` and `control-openhands api DELETE /api/llm/provider-connections/<connection id> --write` (`<connection id>` from `control-openhands api GET /api/llm/provider-connections`), or through Settings → LLM (F10).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F13",
      "title": "Agent profiles",
      "file": "F13-agent-profiles.md",
      "page": "F13-agent-profiles.html",
      "sha256": "cc3e5a04f6846be6fe6a336b16f4abff740976c78eeddffcf1dd29cb46425984",
      "behaviors": [
        {
          "id": "F13.list",
          "description": "under **Available Profiles**, rows (sorted by name) show the profile name, then its LLM profile (OpenHands) or `ACP (external subprocess)`, a **Default** badge on the active profile and a `...` menu (Edit, Set as active, Delete)."
        },
        {
          "id": "F13.create",
          "description": "**Add agent profile** opens the editor; Save toasts `Profile \"<name>\" created` and the row is listed after a reload."
        },
        {
          "id": "F13.name-validation",
          "description": "an empty or malformed name marks the field invalid (red rule) and disables Save; a duplicate name disables Save without a message."
        },
        {
          "id": "F13.editor-no-llm",
          "description": "with no LLM profiles, an OpenHands profile shows `Create an LLM profile first to define an OpenHands agent.` and Save toasts `Select an LLM profile for this agent.` without creating anything."
        },
        {
          "id": "F13.openhands-options",
          "description": "OpenHands profiles offer an LLM profile dropdown, **Enable sub-agents** (off by default), **Let the agent switch LLM profiles** (on by default) and **Parallel tool calls** (default 1, minimum 1); values persist and reopen."
        },
        {
          "id": "F13.edit",
          "description": "Edit reopens the stored values with Save disabled until something changes; renaming keeps the profile id; Back and Cancel leave without saving."
        },
        {
          "id": "F13.stale-llm-ref",
          "description": "editing a profile whose LLM profile no longer exists preselects the active LLM profile and enables Save, so saving heals the reference."
        },
        {
          "id": "F13.set-active",
          "description": "**Set as active** toasts `Switched to profile \"<name>\"`, moves the Default badge and is disabled on the active row."
        },
        {
          "id": "F13.active-drives-conversation",
          "description": "a conversation started from Home after activation launches from that profile (its id and settings are stamped on the conversation)."
        },
        {
          "id": "F13.llm-pill-precedence",
          "description": "when the active profile pins a different LLM profile than the Home LLM pill, the launch uses the pill's model and not the profile's other settings (by design)."
        },
        {
          "id": "F13.mcp-scope",
          "description": "**MCP servers** offers **All servers** (every configured server listed, switched on and disabled) or **Choose servers** (a switch per configured server); the choice persists; a chosen server that was removed stays listed with a red warning, and launching with it is refused."
        },
        {
          "id": "F13.secrets-scope",
          "description": "**Secrets** offers **No profile restriction** (every switch on and disabled) or **Choose secrets**; on an ACP profile, Choose preselects that provider's secret names; the selection persists."
        },
        {
          "id": "F13.acp-form",
          "description": "choosing **ACP (external subprocess)** shows Preset, Command and Model; each preset prefills its command and model, **Custom** empties the command and disables Save until one is typed, typing a preset's exact command selects that preset, and the Model list's **Custom** opens a free-text model field."
        },
        {
          "id": "F13.acp-credentials",
          "description": "built-in presets show a Credentials section (password fields), an auth banner (signed in / configured), a conflict warning for mutually exclusive Claude credentials, and `Already saved — leave blank to keep` for stored values. Credentials are saved as global secrets."
        },
        {
          "id": "F13.acp-preset-credentials",
          "description": "the Credentials fields follow the preset: Claude Code `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`; Codex `OPENAI_API_KEY`, `CODEX_AUTH_JSON` (multi-line), `OPENAI_BASE_URL`; Gemini CLI `GOOGLE_APPLICATION_CREDENTIALS_JSON` (multi-line), `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_API_KEY`, `GEMINI_BASE_URL`; **Custom** has no Credentials section. With no host login and no stored credential (Gemini here) no auth banner shows."
        },
        {
          "id": "F13.menu-keyboard",
          "description": "opening the row's `...` menu focuses Edit; ArrowDown/ArrowUp move focus between the enabled items (wrapping, and skipping the disabled **Set as active** on the active row), Enter runs the focused item, and Tab, Escape or a click outside close the menu."
        },
        {
          "id": "F13.delete",
          "description": "Delete asks for confirmation; Cancel keeps the row, Delete removes it. Deleting the active profile leaves none active; deleting the last profile makes the server re-seed `default`."
        },
        {
          "id": "F13.phone",
          "description": "the list, row menu and editor fit a 390 px viewport without horizontal overflow."
        },
        {
          "id": "F13.acp-conversation",
          "description": "a conversation launched from an active ACP profile runs the external agent. Blocked without an approved provider account."
        },
        {
          "id": "F13.cloud-read-only",
          "description": "on a Cloud backend, organization members see rows without the `...` menu or **Add agent profile**. Blocked without a Cloud account."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "List (`F13.list`).",
          "body": "From `/` run `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'`, then `control-openhands browser url` (`/settings/agents`) and `control-openhands browser text 'testid=agent-profile-row'`: `default`, its LLM profile `default`, `Default` on three lines. `control-openhands browser count 'testid=agent-profile-active-badge'` is `1`; `control-openhands browser screenshot --feature F13.list --name list`. Command menu: run `control-openhands browser goto /` (the browser is on `/settings/agents`, where the URL wait would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' 'Agent settings'`, `control-openhands browser press Enter`, `control-openhands browser wait-url '/settings/agents(\\?|$)'`. Legacy URL: `control-openhands browser goto /settings/agent` returns `.../settings/agents`.",
          "ids": [
            "F13.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "No LLM profile (`F13.editor-no-llm`).",
          "body": "Before the preset, run `control-openhands browser click 'testid=add-agent-profile'`, then `control-openhands browser text 'testid=agent-profile-no-llm'`: `Create an LLM profile first to define an OpenHands agent.` `control-openhands browser text 'testid=agent-profile-editor-title'` is `Add agent profile` and `control-openhands browser text 'testid=agent-profile-editor-description'` is `Configure the agent below, then click Save to create this profile.` (agent copy, not the LLM page's hint, since #18035). Run `control-openhands browser fill 'testid=agent-profile-name-input' QA_NoLlm`; Save turns enabled (`control-openhands browser enabled 'testid=save-agent-profile-btn'`). Click `testid=save-agent-profile-btn` and run `control-openhands browser toasts`: `Select an LLM profile for this agent.` `api GET /api/agent-profiles` lists no `QA_NoLlm`. Leave with `control-openhands browser click 'testid=cancel-agent-profile-btn'`.",
          "ids": [
            "F13.editor-no-llm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Name rules (`F13.name-validation`).",
          "body": "In the Add editor, fill `testid=agent-profile-name-input` with `''`, `default`, `'bad name!'` and `QA_ok` in turn; after each run `control-openhands browser attr 'testid=agent-profile-name-input' aria-invalid` and `control-openhands browser enabled 'testid=save-agent-profile-btn'`. Results: `''` → `true`/`false`; `default` (duplicate) → `false`/`false`, and nothing explains why; `'bad name!'` → `true`/`false`; `QA_ok` → `false`/`true`. The rule under the field is always shown (grey) and turns red (`text-red-400`) while the name is invalid; it reads `Profile name must start with a letter or digit, and contain only letters, digits, dots, underscores, or hyphens (max 64 characters)`. Take `control-openhands browser screenshot --feature F13.name-validation --name duplicate` with `default` filled.",
          "ids": [
            "F13.name-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "ACP form (`F13.acp-form`).",
          "body": "In the Add editor run `control-openhands browser click 'testid=agent-type-selector'` and `control-openhands browser click 'role=option[name=\"ACP (external subprocess)\"]'`. `control-openhands browser value 'testid=agent-preset-selector'` is `Claude Code`, `browser value 'testid=agent-command-input'` is `npx -y --prefer-offline @agentclientprotocol/claude-agent-acp@0.63.0` and `browser value 'testid=agent-model-selector'` is `Claude Opus (1M)`; `agent-profile-no-llm` is gone (ACP needs no LLM profile). Pick `Codex` and `Gemini CLI` from `testid=agent-preset-selector`: the command becomes `npx -y --prefer-offline @agentclientprotocol/codex-acp@1.10.0` / `npx -y --prefer-offline @google/gemini-cli@0.46.0 --acp` and the model `GPT-5.5` / `Gemini 2.5 Pro`. Pick `Custom`: the command is empty and Save is disabled. `control-openhands browser fill 'testid=agent-command-input' 'npx -y qa-custom-acp --stdio'` enables Save. Filling the exact Codex command flips the preset to `Codex`; appending ` --verbose` flips it back to `Custom`. Model override: with a built-in preset, run `control-openhands browser choose 'testid=agent-model-selector' 'Custom'` (or open the model list with its toggle button and `control-openhands browser click 'role=option[name=\"Custom\"][exact]'`); `control-openhands browser count 'testid=agent-model-input'` is `1` and empty. The **Custom** preset shows `agent-model-input` directly (no model list).",
          "ids": [
            "F13.acp-form"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Create an ACP profile (`F13.create`).",
          "body": "With preset `Custom`, fill `testid=agent-profile-name-input` with `QA_acp_custom`, `testid=agent-command-input` with `'npx -y qa-custom-acp --stdio'` and `testid=agent-model-input` with `qa-model`, click `testid=save-agent-profile-btn`, then `control-openhands browser wait-text 'Profile \"QA_acp_custom\" created' --timeout 10000`, `control-openhands browser reload` and `control-openhands browser text 'testid=agent-profile-row >> has-text=QA_acp_custom'`: `QA_acp_custom` / `ACP (external subprocess)`. `control-openhands api GET /api/agent-profiles/QA_acp_custom` shows `acp_server` `custom`, the command and `acp_model` `qa-model`.",
          "ids": [
            "F13.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Edit and rename (`F13.edit`).",
          "body": "Run `control-openhands browser click 'testid=agent-profile-row >> has-text=QA_acp_custom >> testid=agent-profile-menu-trigger'`; `control-openhands browser snapshot 'testid=agent-profile-actions-menu'` lists `Edit`, `Set as active`, `Delete`. Click `testid=agent-profile-edit`: the title is `Edit agent profile`, the description `Editing profile \"QA_acp_custom\" - save to apply changes`, the type/preset/command/model values are the stored ones, and Save is disabled. Note the `id` from `api GET /api/agent-profiles`, fill the name with `QA_acp_renamed`, click Save, `control-openhands browser wait-text 'Profile \"QA_acp_renamed\" updated' --timeout 10000` and `browser reload`: `browser count 'testid=agent-profile-row >> has-text=QA_acp_custom'` is `0`, the renamed row is `1` and `api GET /api/agent-profiles` shows the same id under the new name. Back/Cancel: open any editor, change a field, click `testid=back-to-agent-profiles` (or `testid=cancel-agent-profile-btn`); `browser count 'testid=add-agent-profile'` is `1` and the API is unchanged.",
          "ids": [
            "F13.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Credentials (`F13.acp-credentials`).",
          "body": "In a new ACP editor with preset `Claude Code`, `control-openhands browser testids --hidden --filter acp` lists `settings-acp-secret-CLAUDE_CODE_OAUTH_TOKEN`, `-ANTHROPIC_API_KEY`, `-ANTHROPIC_BASE_URL` and one banner (`settings-acp-auth-detected` when the agent-server machine has a Claude CLI login, as this sandbox does: `You're already signed in to Claude Code — you can leave the fields below blank.`). `control-openhands browser attr 'testid=settings-acp-secret-ANTHROPIC_API_KEY' type` is `password`. Fill `settings-acp-secret-CLAUDE_CODE_OAUTH_TOKEN` with `qa-dummy-oauth` and `settings-acp-secret-ANTHROPIC_API_KEY` with `qa-dummy-key`; `control-openhands browser text 'testid=acp-credential-conflict-warning'` is `Setting ANTHROPIC_API_KEY alongside CLAUDE_CODE_OAUTH_TOKEN breaks its authentication — leave one of them blank.` (screenshot `--feature F13.acp-credentials --name conflict` after `browser scroll 'testid=acp-credential-conflict-warning'`). Cancel; `api GET /api/settings/secrets` gains nothing. Saved credential: new profile `QA_codex`, ACP, preset `Codex`, fill `testid=settings-acp-secret-OPENAI_API_KEY` with `qa-dummy-openai`, Save (`Profile \"QA_codex\" created`); `api GET /api/settings/secrets` now lists `OPENAI_API_KEY` and the profile has `acp_server` `codex`, `acp_command` `null`, `acp_model` `gpt-5.5`. Reopen it: `control-openhands browser testids --hidden --filter settings-acp-auth` shows `settings-acp-auth-configured` (`Credentials for Codex are configured — you can leave the fields below blank.`) and `control-openhands browser attr 'testid=settings-acp-secret-OPENAI_API_KEY' placeholder` is `Already saved — leave blank to keep` with an empty value.",
          "ids": [
            "F13.acp-credentials"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Secrets scope (`F13.secrets-scope`).",
          "body": "In the `QA_codex` editor, `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=agent-settings-secret-list] input')].map(i=>i.closest('li').innerText.split('\\n')[0]+'='+i.checked+(i.disabled?'(disabled)':''))\"` shows every name `=true(disabled)` under `No profile restriction`. Open `testid=agent-settings-secrets-mode`, `control-openhands browser click 'role=option[name=\"Choose secrets\"]'` and rerun the eval: `OPENHANDS_AUTOMATION_API_KEY=false`, `OPENAI_API_KEY=true`, `CODEX_AUTH_JSON=true`, `OPENAI_BASE_URL=true`. Save (`Profile \"QA_codex\" updated`); `api GET /api/agent-profiles/QA_codex` has those three in `secret_refs`, and after `browser reload` and Edit the mode and switches are the same. On an OpenHands profile, Choose starts with nothing selected; a switch toggles by its name: in the Add editor pick `Choose secrets`, then `control-openhands browser click 'testid=agent-settings-secret-list >> text=OPENHANDS_AUTOMATION_API_KEY'` turns `document.querySelector('[data-testid=agent-settings-secret-OPENHANDS_AUTOMATION_API_KEY]').checked` from `false` to `true`.",
          "ids": [
            "F13.secrets-scope"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Stale LLM reference (`F13.stale-llm-ref`).",
          "body": "After `control-openhands llm preset deepseek`, run `control-openhands browser reload`, open `testid=agent-profile-row >> has-text=default >> testid=agent-profile-menu-trigger` → `testid=agent-profile-edit`, `control-openhands browser wait 'testid=agent-profile-llm-selector'`. `browser value 'testid=agent-profile-llm-selector'` is `deepseek-flash (deepseek/deepseek-flash)` and Save is enabled although nothing was touched. Save (`Profile \"default\" updated`), `browser reload`: the row reads `default` / `deepseek-flash` / `Default` and `api GET /api/agent-profiles/default` has `llm_profile_ref` `deepseek-flash` with the same id. Screenshot `--feature F13.stale-llm-ref --name fallback` before saving.",
          "ids": [
            "F13.stale-llm-ref"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "OpenHands options (`F13.openhands-options`, `F13.create`).",
          "body": "Click `testid=add-agent-profile`, fill the name `QA_oh`. Defaults: `browser value 'testid=agent-profile-llm-selector'` is the active LLM profile, `control-openhands browser eval \"document.querySelector('[data-testid=agent-settings-enable-sub-agents]').checked\"` is `false`, the same for `agent-settings-enable-switch-llm-tool` is `true`, `browser value 'testid=sdk-settings-tool_concurrency_limit'` is `1`. Click `'testid=agent-settings-screen >> text=Enable sub-agents'` and `'testid=agent-settings-screen >> text=Let the agent switch LLM profiles'` (the checks flip). Fill `testid=sdk-settings-tool_concurrency_limit` with `0` and click Save: `browser toasts` shows `Parallel tool calls must be at least 1` and `browser eval \"document.querySelector('[data-testid=sdk-settings-tool_concurrency_limit]').validationMessage\"` is `Value must be greater than or equal to 1.` Fill `2`, `control-openhands browser choose 'testid=agent-profile-llm-selector' 'deepseek-pro (deepseek/deepseek-v4-pro)'`, Save (`Profile \"QA_oh\" created`), `browser reload`: the row reads `QA_oh` / `deepseek-pro`, and `api GET /api/agent-profiles/QA_oh` has `llm_profile_ref` `deepseek-pro`, `enable_sub_agents` `true`, `enable_switch_llm_tool` `false`, `tool_concurrency_limit` `2`. Reopening shows the same values with Save disabled.",
          "ids": [
            "F13.openhands-options",
            "F13.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Set as active (`F13.set-active`).",
          "body": "Run `control-openhands browser click 'testid=agent-profile-row >> has-text=QA_oh >> testid=agent-profile-menu-trigger'`, `control-openhands browser click 'testid=agent-profile-set-active'` and `control-openhands browser wait-text 'Switched to profile \"QA_oh\"' --timeout 10000`. After `browser reload`, `browser count 'testid=agent-profile-row >> has-text=QA_oh >> testid=agent-profile-active-badge'` and `browser count 'testid=agent-profile-active-badge'` are both `1`. Reopen the QA_oh menu: `browser enabled 'testid=agent-profile-set-active'` is `false` (screenshot `--feature F13.set-active --name menu`); `control-openhands browser press Escape` closes it (`browser count 'testid=agent-profile-actions-menu'` is `0`).",
          "ids": [
            "F13.set-active"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Pill wins over a pinned LLM (`F13.llm-pill-precedence`).",
          "body": "With `QA_oh` active (pinned to `deepseek-pro`) and the Home pill on `deepseek-flash` (`browser text 'testid=chat-input-llm-profile'` on `/`), run `control-openhands conversation start --prompt \"Reply with only: qa-profile-ok\" --wait --timeout 240`. The output's `model` is `deepseek/deepseek-flash`, and `control-openhands api GET /api/conversations/<id>` (id from that output) has `\"launched_agent_profile\": null` and `tool_concurrency_limit` `1`: the profile was not applied.",
          "ids": [
            "F13.llm-pill-precedence"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Active profile drives the launch (`F13.active-drives-conversation`).",
          "body": "Run `control-openhands browser goto /settings/agents` (the previous `conversation start` left the browser on the conversation), edit `QA_oh`, run `control-openhands browser choose 'testid=agent-profile-llm-selector' 'deepseek-flash (deepseek/deepseek-flash)'` (the toggle-button opener failed 5 of 5 times here: see Gotchas), check `browser value 'testid=agent-profile-llm-selector'`, Save (`Profile \"QA_oh\" updated`). Run the same `conversation start` again; `api GET /api/conversations/<id>` now has `launched_agent_profile.agent_profile_id` equal to QA_oh's id from `api GET /api/agent-profiles` and `tool_concurrency_limit` `2`; `control-openhands conversation events <id> --kinds MessageEvent` contains `qa-profile-ok`.",
          "ids": [
            "F13.active-drives-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "MCP scope (`F13.mcp-scope`).",
          "body": "With `qa_mcp_a` and `qa_mcp_b` arranged, `browser goto /settings/agents`, Edit `QA_oh`: `browser value 'testid=agent-settings-mcp-mode'` is `All servers` and the eval below (with `+(i.disabled?'(disabled)':'')` appended) shows both `=true(disabled)`. Pick `Choose servers` from `testid=agent-settings-mcp-mode`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=agent-settings-mcp-list] input')].map(i=>i.dataset.testid+'='+i.checked)\"` shows both `=true`. Run `control-openhands browser click 'testid=agent-settings-mcp-list >> text=qa_mcp_b'` (now `false`), Save; `api GET /api/agent-profiles/QA_oh` has `mcp_server_refs` `[\"qa_mcp_a\"]`. Dangling: arrange `control-openhands api DELETE /api/settings/mcp/qa_mcp_a --write`, `browser reload`, Edit `QA_oh`: `control-openhands browser text 'testid=agent-settings-mcp-dangling'` is `qa_mcp_a is no longer configured. Saving with it selected will stop this agent from starting — clear it or re-add the server.` (screenshot `--feature F13.mcp-scope --name dangling`). Leave without saving, `browser goto /`, `browser reload`, `control-openhands browser fill 'testid=chat-input' 'Reply with only: qa-dangling'`, `control-openhands browser click 'testid=submit-button' --observe 'role=status' --observe-ms 4000` and `control-openhands browser toasts --history` (QA_oh must still be active and pinned to the pill's LLM, as `F13.active-drives-conversation` leaves it; otherwise the launch skips the profile and nothing is refused): the launch is refused (one `POST /api/conversations` 422 in `browser errors --app-only`; earlier failures in the session are listed too): `browser toasts --history` shows `Creating conversation…`, then one toast carrying the server's `detail.message` (`MCP server(s) not configured: qa_mcp_a` on Agent Server 1.53.0), and `browser text 'testid=chat-input'` still reads `Reply with only: qa-dangling`. Clear the composer with `browser fill 'testid=chat-input' ''`, then clear the ref: `control-openhands browser goto /settings/agents`, Edit `QA_oh`, `browser click 'testid=agent-settings-mcp-list >> text=qa_mcp_a'` (the warning count drops to `0`), pick `All servers`, Save; `mcp_server_refs` is `null`.",
          "ids": [
            "F13.mcp-scope"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Credential fields per preset (`F13.acp-preset-credentials`).",
          "body": "In the Add editor, `control-openhands browser choose 'testid=agent-type-selector' 'ACP (external subprocess)'`, then for each of `Codex`, `Gemini CLI` and `Custom` run `control-openhands browser choose 'testid=agent-preset-selector' '<preset>'` and `control-openhands browser testids --hidden --filter settings-acp`: Codex lists `settings-acp-secret-OPENAI_API_KEY` (input), `-CODEX_AUTH_JSON` (textarea), `-OPENAI_BASE_URL`; Gemini CLI lists `-GOOGLE_APPLICATION_CREDENTIALS_JSON` (textarea), `-GOOGLE_CLOUD_PROJECT`, `-GOOGLE_CLOUD_LOCATION`, `-GOOGLE_GENAI_USE_VERTEXAI`, `-GEMINI_API_KEY`, `-GEMINI_BASE_URL`; Custom lists nothing and `browser eval \"document.body.innerText.includes('Credentials')\"` is `false`. For Gemini, `control-openhands browser wait 'testid=settings-acp-auth-checking' --state detached --timeout 15000`, then `browser testids --hidden --filter settings-acp-auth` has count `0` (no login, no stored credential); `browser eval \"document.querySelector('[data-testid=settings-acp-secret-GEMINI_API_KEY]').type\"` is `password`, `GOOGLE_CLOUD_PROJECT` is `text`. Screenshot `--feature F13.acp-preset-credentials --name gemini` after `browser scroll 'testid=settings-acp-secret-GOOGLE_APPLICATION_CREDENTIALS_JSON'`. Cancel (Codex shows `settings-acp-auth-configured` instead while `OPENAI_API_KEY` from `F13.acp-credentials` is still saved).",
          "ids": [
            "F13.acp-preset-credentials"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Menu keyboard (`F13.menu-keyboard`).",
          "body": "Run `control-openhands browser goto /settings/agents`. It needs a non-active row: run `control-openhands browser click 'testid=add-agent-profile'`, `control-openhands browser fill 'testid=agent-profile-name-input' QA_kb`, `control-openhands browser click 'testid=save-agent-profile-btn'` and `control-openhands browser wait-text 'Profile \"QA_kb\" created' --timeout 10000`. Open its menu with `control-openhands browser click 'testid=agent-profile-row >> has-text=QA_kb >> testid=agent-profile-menu-trigger'` and right away run `control-openhands browser eval \"document.activeElement?.dataset?.testid\"`, then `control-openhands browser press ArrowDown` and the same eval. Expected: `agent-profile-edit`, then `agent-profile-set-active` (opening the menu moves focus to its first item, as the LLM profile menu does in `F10.actions-menu`). Known failure: both evals read `agent-profile-menu-trigger`; focus stays on the trigger and ArrowDown does nothing (#18060). Then run `control-openhands browser focus 'testid=agent-profile-edit'`, and `control-openhands browser press ArrowDown` followed by that eval four times: `agent-profile-set-active`, `agent-profile-delete`, `agent-profile-edit`, `agent-profile-set-active`; `browser press ArrowUp` returns to `agent-profile-edit`. `control-openhands browser press Escape` closes the menu (`control-openhands browser count 'testid=agent-profile-actions-menu'` is `0`); reopen it, run `control-openhands browser focus 'testid=agent-profile-delete'` and `control-openhands browser press Tab`: closed; reopen it and run `control-openhands browser mouse-click 900 600`: closed. Active row: open its menu with `control-openhands browser click '[data-testid=agent-profile-row]:has([data-testid=agent-profile-active-badge]) >> testid=agent-profile-menu-trigger'` (`control-openhands browser snapshot 'testid=agent-profile-actions-menu'` shows `menuitem \"Set as active\" [disabled]`), run `control-openhands browser focus 'testid=agent-profile-edit'`, then `control-openhands browser press ArrowDown` and the eval, then `control-openhands browser press ArrowUp` and the eval. Expected: `agent-profile-delete` (the disabled **Set as active** is skipped), then `agent-profile-edit`. Known failure: `agent-profile-edit` (ArrowDown tries to focus the disabled item, which cannot take focus), then `agent-profile-delete` (ArrowUp wraps) (#18060). With the menu still open, run `control-openhands browser focus 'testid=agent-profile-edit'` and `control-openhands browser press Enter`: `control-openhands browser text 'testid=agent-profile-editor-title'` is `Edit agent profile`. Leave the editor with `control-openhands browser click 'testid=cancel-agent-profile-btn'` (`browser count 'testid=add-agent-profile'` is `1`). Delete `QA_kb`: reopen its menu, `control-openhands browser click 'testid=agent-profile-delete'`, `control-openhands browser click 'testid=delete-agent-profile-confirm'` and `control-openhands browser wait-text 'Profile \"QA_kb\" deleted' --timeout 10000`.",
          "ids": [
            "F13.menu-keyboard"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Delete (`F13.delete`).",
          "body": "Run `control-openhands browser click 'testid=agent-profile-row >> has-text=QA_acp_renamed >> testid=agent-profile-menu-trigger'`, `control-openhands browser click 'testid=agent-profile-delete'`; `control-openhands browser text 'role=dialog'` reads `Delete Profile` and `Are you sure you want to delete the profile \"QA_acp_renamed\"? This action cannot be undone.` `control-openhands browser click 'role=dialog >> role=button[name=\"Cancel\"]'` keeps the row (count `1`). Repeat and `control-openhands browser click 'testid=delete-agent-profile-confirm'`, `browser wait-text 'Profile \"QA_acp_renamed\" deleted' --timeout 10000`, `browser reload`: count `0`. Delete the active `QA_oh` the same way: after `browser reload`, `browser count 'testid=agent-profile-active-badge'` is `0` and `api GET /api/agent-profiles` has `active_agent_profile_id` `null`. Delete `QA_codex` and `default`: the list again shows one row, `default` / `deepseek-flash` / `Default`, with a new id (the server re-seeds it).",
          "ids": [
            "F13.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Phone (`F13.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/agents`, `control-openhands browser bbox 'testid=agent-profile-row'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `browser screenshot --feature F13.phone --name list`. Open the row menu: `browser bbox 'testid=agent-profile-actions-menu'` is inside the viewport. `browser press Escape`, click `testid=add-agent-profile`: `browser bbox 'testid=agent-settings-screen'` is 348 px wide at x 16 with no page overflow (`insideViewport` is `false` only because the form is taller than the screen). Switch to ACP, `browser scroll 'testid=settings-acp-secret-ANTHROPIC_API_KEY'` and screenshot `--name acp-credentials`. Cancel and `control-openhands browser viewport desktop`.",
          "ids": [
            "F13.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Errors.",
          "body": "After the family, `control-openhands browser errors --app-only` shows no page errors and no `[i18n] Missing translation` warnings (the Parallel tool calls label and description are translated since #18035), only the deliberate 422s from the dangling-MCP launch.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Blocked (`F13.acp-conversation`, `F13.cloud-read-only`).",
          "body": "Record `control-openhands evidence add --feature F13.acp-conversation --result blocked ...` naming the missing provider CLI/account, and the same for `F13.cloud-read-only` naming the Cloud member login.",
          "ids": [
            "F13.acp-conversation",
            "F13.cloud-read-only"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Cleanup.",
          "body": "Delete the `OPENAI_API_KEY` credential through Secrets: `control-openhands browser goto /settings/secrets`, `control-openhands browser click 'testid=secret-item >> has-text=OPENAI_API_KEY >> testid=delete-secret-button'`, `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`. Remove `qa_mcp_b` with `control-openhands api DELETE /api/settings/mcp/qa_mcp_b --write`. Leave `default` active.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F14",
      "title": "Secrets",
      "file": "F14-secrets.md",
      "page": "F14-secrets.html",
      "sha256": "f074fc7011200c74024f043117441f89e01ddf8c581c954d01617a61e92e5751",
      "behaviors": [
        {
          "id": "F14.list",
          "description": "the Secrets page lists each secret's name and description with Edit and Delete actions; the launcher-seeded `OPENHANDS_AUTOMATION_API_KEY` is always present."
        },
        {
          "id": "F14.create",
          "description": "adding a secret persists it; it is listed after a reload."
        },
        {
          "id": "F14.create-validation",
          "description": "duplicate names, names that break `^[a-zA-Z][a-zA-Z0-9_]{0,63}$` and empty values are refused with a message."
        },
        {
          "id": "F14.edit",
          "description": "editing the description with the value left blank keeps the stored value; Save stays disabled until something changes."
        },
        {
          "id": "F14.delete",
          "description": "deleting asks for confirmation; Cancel keeps the row, Confirm removes it for good."
        },
        {
          "id": "F14.agent-access",
          "description": "a conversation started after the secret was saved sees it as an environment variable."
        },
        {
          "id": "F14.phone",
          "description": "the page and its forms fit a 390 px viewport without horizontal overflow."
        },
        {
          "id": "F14.form-back",
          "description": "the Add and Edit forms replace the list with an **Add a Secret** / **Edit a Secret** title and a **Back** button; Back (like Cancel) returns to the list and discards the draft."
        },
        {
          "id": "F14.rename",
          "description": "the Edit form's name is editable; renaming with the value left blank keeps the stored value under the new name, and renaming onto an existing name is refused with `Secret already exists`."
        },
        {
          "id": "F14.edit-value",
          "description": "typing a value in the Edit form (labelled \"Secret Value (leave blank to preserve the existing value)\") replaces the stored value for new conversations."
        },
        {
          "id": "F14.delete-escape",
          "description": "Escape closes the delete confirmation without deleting."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open the list (`F14.list`).",
          "body": "Navigate from the sidebar. Run `control-openhands browser click 'testid=backend-selector-settings-link'`, `control-openhands browser click 'testid=sidebar-settings-/settings/secrets'`, then `control-openhands browser count 'testid=secret-item >> has-text=OPENHANDS_AUTOMATION_API_KEY'`. The count is `1` and the URL is `/settings/secrets`.",
          "ids": [
            "F14.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command-menu entry (`F14.list`).",
          "body": "Run `control-openhands browser goto /` (the previous bullet left `/settings/secrets`, which would make the URL check pass vacuously), then `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Secrets`, `control-openhands browser press Enter`, then `control-openhands browser url`. The URL ends in `/settings/secrets`.",
          "ids": [
            "F14.list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Create (`F14.create`).",
          "body": "Run `control-openhands browser click 'testid=add-secret-button'`, `control-openhands browser fill 'testid=add-secret-form >> testid=name-input' QA_TMP_SECRET`, `control-openhands browser fill 'testid=add-secret-form >> testid=value-input' dummy-value-123`, `control-openhands browser fill 'testid=add-secret-form >> testid=description-input' 'QA dummy'`, `control-openhands browser click 'testid=add-secret-form >> testid=submit-button'`, then `control-openhands browser reload` and `control-openhands browser count 'testid=secret-item >> has-text=QA_TMP_SECRET'`. The count is `1` after the reload.",
          "ids": [
            "F14.create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Duplicate name (`F14.create-validation`).",
          "body": "Run `control-openhands browser click 'testid=add-secret-button'`, fill `name-input` with `QA_TMP_SECRET` and `value-input` with `other` as above, click `testid=add-secret-form >> testid=submit-button`, then `control-openhands browser snapshot 'testid=add-secret-form'`. The snapshot shows the paragraph `Secret already exists` and the form stays open.",
          "ids": [
            "F14.create-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Invalid name (`F14.create-validation`).",
          "body": "In the same form run `control-openhands browser fill 'testid=add-secret-form >> testid=name-input' 'bad name!'`, click submit, then `control-openhands browser eval \"document.querySelector('[data-testid=add-secret-form] [data-testid=name-input]').validationMessage\"`. The value is the browser's pattern message (`Please match the requested format.` in Chromium) and no secret is created (`api GET /api/settings/secrets`). Close with `control-openhands browser click 'testid=add-secret-form >> testid=cancel-button'`.",
          "ids": [
            "F14.create-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Empty value (`F14.create-validation`).",
          "body": "Run `control-openhands browser click 'testid=add-secret-button'`, fill `testid=add-secret-form >> testid=name-input` with `QA_EMPTY_VALUE` and leave `value-input` empty. `control-openhands browser enabled 'testid=add-secret-form >> testid=submit-button'` is `false`, and `control-openhands browser eval \"document.querySelector('[data-testid=add-secret-form] [data-testid=value-input]').validationMessage\"` is `Please fill out this field.` Close with `testid=add-secret-form >> testid=cancel-button`; `api GET /api/settings/secrets` lists no `QA_EMPTY_VALUE`.",
          "ids": [
            "F14.create-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Edit keeps the value (`F14.edit`).",
          "body": "Run `control-openhands browser click 'testid=secret-item >> has-text=QA_TMP_SECRET >> testid=edit-secret-button'`, `control-openhands browser value 'testid=edit-secret-form >> testid=value-input'` (empty) and `control-openhands browser enabled 'testid=edit-secret-form >> testid=submit-button'` (`false`). Fill `testid=edit-secret-form >> testid=description-input` with `QA dummy edited`; `enabled` turns `true`. Click `testid=edit-secret-form >> testid=submit-button`, `control-openhands browser reload`, then `control-openhands browser text 'testid=secret-item >> has-text=QA_TMP_SECRET'`. The row text is `QA_TMP_SECRET` and `QA dummy edited` separated by a tab (`\"QA_TMP_SECRET\\tQA dummy edited\"` in the JSON output).",
          "ids": [
            "F14.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Delete with confirmation (`F14.delete`).",
          "body": "Run `control-openhands browser click 'testid=secret-item >> has-text=QA_TMP_SECRET >> testid=delete-secret-button'`; the dialog `testid=confirmation-modal` reads `Are you sure you want to delete this key?`. Run `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'`; the row count stays `1`. Repeat the delete click, then `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`, `control-openhands browser reload` and `control-openhands browser count 'testid=secret-item >> has-text=QA_TMP_SECRET'`. The count is `0`.",
          "ids": [
            "F14.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Agent receives the secret (`F14.agent-access`).",
          "body": "Create `QA_AGENT_SECRET` with value `qa-dummy-4821` through the form as in Create. Run `control-openhands conversation start --prompt \"Run exactly this command and reply with only its output: python3 -c \\\"import os; print(os.environ.get('QA_AGENT_SECRET') == 'qa-dummy-4821')\\\"\" --wait --timeout 240`, then `control-openhands conversation events <id> --kinds ObservationEvent`. The terminal observation's text is `True`. Assert the observation, not the agent's reply: deepseek-flash often declines to echo anything about a secret (see Gotchas). Keep `QA_AGENT_SECRET` for Rename and Replace value below; they delete it.",
          "ids": [
            "F14.agent-access"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Phone layout (`F14.phone`).",
          "body": "Run `control-openhands browser goto /settings/secrets` (`conversation start` left the browser on the conversation), `control-openhands browser viewport phone`, `control-openhands browser bbox 'testid=secrets-settings-screen'` and `control-openhands browser screenshot --feature F14.phone --name list`. `insideViewport` is `true` and `pageHorizontalOverflow` is `false`; the screenshot shows the table with Edit and Delete icons. Then `control-openhands browser click 'testid=add-secret-button'`, `control-openhands browser bbox 'testid=add-secret-form'` (`insideViewport` `true`, no page overflow) and `control-openhands browser screenshot --feature F14.phone --name add-form`; cancel the form. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F14.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Back discards the draft (`F14.form-back`).",
          "body": "On `/settings/secrets` run `control-openhands browser click 'testid=add-secret-button'` and `control-openhands browser text 'testid=secret-editor-title'` (`Add a Secret`). Fill `testid=add-secret-form >> testid=name-input` with `QA_BACK_SECRET` and `value-input` with `dummy`, then `control-openhands browser click 'testid=back-to-secrets'`. `control-openhands browser count 'testid=add-secret-form'` is `0`, the list is back (`browser count 'testid=secret-item'` > `0`) and `control-openhands api GET /api/settings/secrets` lists no `QA_BACK_SECRET`. Reopening Add shows an empty `name-input` (`browser value`); leave with `testid=back-to-secrets`. The Edit form shows `Edit a Secret` in the same `secret-editor-title`.",
          "ids": [
            "F14.form-back"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Rename keeps the value (`F14.rename`).",
          "body": "Needs `QA_AGENT_SECRET` from Agent receives the secret. Run `control-openhands browser goto /settings/secrets`, `control-openhands browser click 'testid=secret-item >> has-text=QA_AGENT_SECRET >> testid=edit-secret-button'`, fill `testid=edit-secret-form >> testid=name-input` with `OPENHANDS_AUTOMATION_API_KEY` and click `testid=edit-secret-form >> testid=submit-button`: `control-openhands browser snapshot 'testid=edit-secret-form'` shows the paragraph `Secret already exists` and the form stays open. Fill `name-input` with `QA_RENAMED_SECRET` (value left blank), click submit, `control-openhands browser reload`; `browser count 'testid=secret-item >> has-text=QA_RENAMED_SECRET'` is `1`, the `QA_AGENT_SECRET` count is `0`, and `api GET /api/settings/secrets` lists only the new name. Then run `control-openhands conversation start --prompt \"Run exactly this command and reply with only its output: python3 -c \\\"import os; print(os.environ.get('QA_RENAMED_SECRET') == 'qa-dummy-4821', 'QA_AGENT_SECRET' in os.environ)\\\"\" --wait --timeout 240` and `control-openhands conversation events <id> --kinds ObservationEvent`: the observation is `True False`.",
          "ids": [
            "F14.rename"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Replace the value (`F14.edit-value`).",
          "body": "Run `control-openhands browser goto /settings/secrets`, `control-openhands browser click 'testid=secret-item >> has-text=QA_RENAMED_SECRET >> testid=edit-secret-button'` and `control-openhands browser snapshot 'testid=edit-secret-form'` (the value textbox is named `Secret Value (leave blank to preserve the existing value)`). Fill `testid=edit-secret-form >> testid=value-input` with `qa-dummy-7350`; `browser enabled 'testid=edit-secret-form >> testid=submit-button'` is `true`. Click submit; `browser count 'testid=edit-secret-form'` is `0`. Start a conversation with the prompt `Run exactly this command and reply with only its output: python3 -c \"import os; print(os.environ.get('QA_RENAMED_SECRET') == 'qa-dummy-7350')\"` (quoted as above) and read its `ObservationEvent`: `True`.",
          "ids": [
            "F14.edit-value"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Escape cancels delete (`F14.delete-escape`).",
          "body": "Run `control-openhands browser goto /settings/secrets`, `control-openhands browser click 'testid=secret-item >> has-text=QA_RENAMED_SECRET >> testid=delete-secret-button'`, `control-openhands browser press Escape`, then `control-openhands browser count 'testid=confirmation-modal'` (`0`) and `browser count 'testid=secret-item >> has-text=QA_RENAMED_SECRET'` (`1`). Clean up: delete `QA_RENAMED_SECRET` with `delete-secret-button` → `testid=confirmation-modal >> testid=confirm-button`, `browser reload`; `api GET /api/settings/secrets` lists only `OPENHANDS_AUTOMATION_API_KEY`.",
          "ids": [
            "F14.delete-escape"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F15",
      "title": "Condenser, agent context and verification settings",
      "file": "F15-agent-behavior-settings.md",
      "page": "F15-agent-behavior-settings.html",
      "sha256": "06e7e672cede7ccfc0e1f4b432e2362bcbb57bb903529f8f0b4be4c7af080492",
      "behaviors": [
        {
          "id": "F15.condenser-page",
          "description": "Condenser shows its subtitle, Basic and All tabs (no Advanced), and in Basic only the **Enable Memory Condensation** switch."
        },
        {
          "id": "F15.condenser-all-view",
          "description": "the All tab lists the eight condenser fields with Title Case labels, plain descriptions and their stored values (Max Size `240`, Keep First Events `2`, …)."
        },
        {
          "id": "F15.condenser-save",
          "description": "changing Max Size and saving shows **Saving...**, then the saved toast; after a reload the page opens on All with the new value."
        },
        {
          "id": "F15.condenser-dependents",
          "description": "turning condensation off hides the six size/reset fields (Condenser Kind stays); turning it back on un-dirties Save."
        },
        {
          "id": "F15.condenser-kind",
          "description": "Condenser Kind is a dropdown (**LLM Summarizer**, **None**, stored as `llm_summarizing` and `no_op`); the choice persists, and switching back restores the summarizer defaults."
        },
        {
          "id": "F15.save-dirty-state",
          "description": "Save is disabled on load, enabled after an edit, and disabled again when the edit is reverted by hand."
        },
        {
          "id": "F15.save-errors",
          "description": "a non-integer Max Size is refused in the browser with a toast; a negative one is refused by the server with a 422 toast; nothing is stored."
        },
        {
          "id": "F15.view-scoped-save",
          "description": "saving from a narrower tier keeps the stored values of fields that tier does not show."
        },
        {
          "id": "F15.agent-context-page",
          "description": "Agent Context shows a single **Persistent Agent Memory** switch with its help text and no tab bar."
        },
        {
          "id": "F15.agent-context-save",
          "description": "toggling Persistent Agent Memory and saving persists after reload."
        },
        {
          "id": "F15.verification-page",
          "description": "Verification shows Basic, Advanced and All tabs; with defaults every tier shows only **Confirmation Mode** and **Enable Critic**."
        },
        {
          "id": "F15.verification-critic-fields",
          "description": "Confirmation Mode reveals Security Analyzer (Advanced); Enable Critic reveals Iterative Refinement and Critic API Key (Basic) plus mode, server URL and model (All); Iterative Refinement reveals threshold and max iterations."
        },
        {
          "id": "F15.confirmation-mode-save",
          "description": "Confirmation Mode and Security Analyzer (conversation settings) persist after reload, alongside the agent-settings fields on the same page."
        },
        {
          "id": "F15.confirmation-mode-effect",
          "description": "with Confirmation Mode on and Security Analyzer **None**, a new conversation stops before its first command with **Do you want to continue with this action?** and Cancel/Continue; Continue runs it."
        },
        {
          "id": "F15.critic-tuning",
          "description": "Critic Mode is a dropdown (`Finish and Message`, `All Actions`); the mode, Critic Threshold and Max Refinement Iterations persist after reload, and an out-of-range threshold (`1.5`) is refused with the 422 toast."
        },
        {
          "id": "F15.critic-save",
          "description": "critic text fields persist after reload; the Critic API Key comes back masked; clearing the fields and turning the critic off restores the defaults."
        },
        {
          "id": "F15.agent-context-effect",
          "description": "with Persistent Agent Memory on, a new conversation's system prompt carries the `.openhands/memory/` instructions; with it off, it does not."
        },
        {
          "id": "F15.tier-after-save",
          "description": "after a save the tab bar re-derives the tier from the stored values without a reload: saving the last minor field back to its default moves the page from All to Basic."
        },
        {
          "id": "F15.unsaved-navigation",
          "description": "leaving a page with unsaved edits shows no prompt and discards them."
        },
        {
          "id": "F15.command-menu",
          "description": "the command menu offers **Condenser settings**, **Agent Context** and **Verification settings**, and each opens its page."
        },
        {
          "id": "F15.direct-url",
          "description": "each page renders when opened by URL."
        },
        {
          "id": "F15.phone",
          "description": "at 390 px the fields stack in one column without horizontal overflow."
        },
        {
          "id": "F15.schema-unavailable",
          "description": "a backend without the settings-schema endpoints shows `SDK settings schema unavailable.` with an upgrade hint instead of the form (blocked: needs such a backend)."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open Condenser from the sidebar (`F15.condenser-page`).",
          "body": "Run `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings/agents(\\?|$)'`, `control-openhands browser click 'testid=sidebar-settings-/settings/condenser' --expect-url '/settings/condenser(\\?|$)'`, `control-openhands browser text 'testid=settings-page-subtitle'` (`Summarize long chats to stay within context limits.`) and `control-openhands browser snapshot 'testid=condenser-settings-screen'`. The snapshot shows tabs `Basic` [selected] and `All`, the text `Enable Memory Condensation` and `button \"Save Changes\" [disabled]`; `control-openhands browser count 'testid=sdk-section-advanced-toggle'` is `0`. Read the switch with `control-openhands browser eval \"document.querySelector('[data-testid=\\\"sdk-settings-condenser.enabled\\\"]').checked\"` (`true`).",
          "ids": [
            "F15.condenser-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "All tier (`F15.condenser-all-view`).",
          "body": "Run `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser count 'testid=condenser-settings-screen >> input'` (`8`) and `control-openhands browser value 'testid=sdk-settings-condenser.max_size'` (`240`). `control-openhands browser snapshot 'testid=condenser-settings-screen'` labels the fields `Enable Memory Condensation`, `Max Size`, `Condenser Kind` (combobox value `LLM Summarizer`), `Max Tokens`, `Keep First Events`, `Minimum Progress`, `Hard Reset Retries` and `Hard Reset Scaling`, each with a plain-language paragraph and all but the switch marked `Optional` (Condenser Kind: `How the condenser shortens long conversations. LLM Summarizer replaces older events with a summary written by the LLM; None keeps the full history.`).",
          "ids": [
            "F15.condenser-all-view"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Dirty state (`F15.save-dirty-state`).",
          "body": "On the All tier: `control-openhands browser enabled 'testid=save-button'` is `false`; `control-openhands browser fill 'testid=sdk-settings-condenser.max_size' 120` makes it `true`; `control-openhands browser fill 'testid=sdk-settings-condenser.max_size' 240` makes it `false` again.",
          "ids": [
            "F15.save-dirty-state"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Save and reload (`F15.condenser-save`).",
          "body": "Fill Max Size with `120`, then `control-openhands browser click 'testid=save-button' --observe 'testid=save-button' --observe-ms 3000` (observed states `Save Changes` → `Saving...` → `Save Changes`) and `control-openhands browser toasts` (`Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.`). `control-openhands browser enabled 'testid=save-button'` is `false`. Run `control-openhands browser reload`, `control-openhands browser snapshot 'role=tablist'` (`All` [selected]: the page opens on the tier that shows the overridden field) and `control-openhands browser value 'testid=sdk-settings-condenser.max_size'` (`120`). `control-openhands api GET /api/settings` shows `max_size` `120`. Keep it for the next bullet.",
          "ids": [
            "F15.condenser-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Dependent fields (`F15.condenser-dependents`).",
          "body": "On the All tier run `control-openhands browser click 'testid=condenser-settings-screen >> text=Enable Memory Condensation'`; the switch reads `false` and `control-openhands browser count 'testid=condenser-settings-screen >> input'` is `2` (the switch and Condenser Kind). Click the label again: the count is `8` and `browser enabled 'testid=save-button'` is `false`.",
          "ids": [
            "F15.condenser-dependents"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Basic save keeps hidden fields (`F15.view-scoped-save`).",
          "body": "With Max Size stored as `120`, run `control-openhands browser click 'testid=sdk-section-basic-toggle'`, `control-openhands browser click 'testid=condenser-settings-screen >> text=Enable Memory Condensation'` and `control-openhands browser click 'testid=save-button'`. `control-openhands api GET /api/settings` now shows `enabled` `false` and `max_size` `120`. Known failure (reproduced 2026-10-06): `max_size` is `240`, because the save sends defaults for every field Basic does not show (#18049). After `browser reload` the page opens on Basic with the switch off. Restore: click the label again, `browser click 'testid=save-button'`, `browser reload`; the switch reads `true` and the API shows `enabled` `true` (with `max_size` `240` today, from the reset above).",
          "ids": [
            "F15.view-scoped-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Invalid values (`F15.save-errors`).",
          "body": "On the All tier run `control-openhands browser fill 'testid=sdk-settings-condenser.max_size' 1.5`, `control-openhands browser click 'testid=save-button'` and `control-openhands browser toasts` (`Expected an integer value, received: 1.5`; no request is sent). Then wait for the toast to close, fill `-5`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=save-button' --observe 'testid=save-button' --observe-ms 2500` (`Saving...` for about 1.5 s), `control-openhands browser toasts` (`HTTP request failed (422 Unprocessable Entity): {\"detail\":\"Settings validation failed\"}`) and `control-openhands browser network` (three `PATCH /api/settings`). The API still shows `max_size` `240`; `browser reload` drops the bad draft.",
          "ids": [
            "F15.save-errors"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Condenser Kind (`F15.condenser-kind`).",
          "body": "On the All tier run `control-openhands browser click 'testid=sdk-settings-condenser.condenser_kind'`, `control-openhands browser snapshot 'role=listbox'` (options `LLM Summarizer` [selected] and `None`), `control-openhands browser click 'role=listbox >> role=option[name=\"None\"]'` and `control-openhands browser click 'testid=save-button'`. After `browser reload` the page opens on All, `browser value 'testid=sdk-settings-condenser.condenser_kind'` is `None`, and `control-openhands api GET /api/settings --pick agent_settings.condenser` is just `{\"enabled\": true, \"condenser_kind\": \"no_op\"}` (the labels are Canvas copy; the stored values are unchanged). The summarizer fields should hide while **None** is selected. Known failure (reproduced 2026-10-06): Max Size, Max Tokens, Keep First Events, Minimum Progress, Hard Reset Retries and Hard Reset Scaling stay with their defaults, and `browser fill 'testid=sdk-settings-condenser.max_size' 100` then Save gives `Settings saved` while the API still shows only `enabled` and `condenser_kind` and a reload shows `240` again (#18049; `browser screenshot --feature F15.condenser-kind --name none-shows-summarizer-fields`). Restore by choosing `LLM Summarizer` the same way and saving: after reload the page opens on Basic and the API shows all eight defaults again (`condenser_kind` `llm_summarizing`).",
          "ids": [
            "F15.condenser-kind"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Agent Context (`F15.agent-context-page`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-settings-/settings/agent-context' --expect-url '/settings/agent-context(\\?|$)'`, `control-openhands browser text 'testid=settings-page-subtitle'` (`Let the agent keep notes and recall them in new conversations.`), `control-openhands browser count 'role=tablist'` (`0`) and `control-openhands browser snapshot 'testid=agent-context-settings-screen'` (text `Persistent Agent Memory`, the paragraph about `.openhands/memory/`, `button \"Save Changes\" [disabled]`).",
          "ids": [
            "F15.agent-context-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Persistent memory persists (`F15.agent-context-save`).",
          "body": "Run `control-openhands browser click 'testid=agent-context-settings-screen >> text=Persistent Agent Memory'`, `control-openhands browser click 'testid=save-button'`, `control-openhands browser reload` and `control-openhands browser eval \"document.querySelector('[data-testid=\\\"sdk-settings-agent_context.load_memory\\\"]').checked\"` (`true`; API `agent_context.load_memory` `true`). Restore: click the label, save, reload; the value is `false`.",
          "ids": [
            "F15.agent-context-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Verification (`F15.verification-page`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-settings-/settings/verification' --expect-url '/settings/verification(\\?|$)'`, `control-openhands browser text 'testid=settings-page-subtitle'` (`Confirmation prompts and security checks on actions.`) and `control-openhands browser snapshot 'testid=verification-settings-screen'`: tabs `Basic` [selected], `Advanced`, `All`; texts `Confirmation Mode` and `Enable Critic`. `control-openhands browser count 'testid=verification-settings-screen >> input'` is `2` on Advanced and on All too.",
          "ids": [
            "F15.verification-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Critic and analyzer fields (`F15.verification-critic-fields`).",
          "body": "On All run `control-openhands browser click 'testid=verification-settings-screen >> text=Confirmation Mode'`, `control-openhands browser click 'testid=verification-settings-screen >> text=\"Enable Critic\"'` and `control-openhands browser click 'testid=verification-settings-screen >> text=\"Enable Iterative Refinement\"'`. `control-openhands browser testids 'testid=verification-settings-screen' --hidden` now lists `sdk-settings-security_analyzer`, `sdk-settings-verification.critic_mode`, `sdk-settings-verification.critic_threshold` (`0.6`), `sdk-settings-verification.max_refinement_iterations` (`3`), `sdk-settings-verification.critic_server_url`, `sdk-settings-verification.critic_model_name`, `sdk-settings-verification.critic_api_key` and `help-link-verification.critic_api_key`. `control-openhands browser attr 'testid=sdk-settings-verification.critic_api_key' type` is `password`; `control-openhands browser eval \"document.querySelector('[data-testid=\\\"help-link-verification.critic_api_key\\\"] a')?.href\"` is `https://app.all-hands.dev/settings/api-keys`. `control-openhands browser screenshot --feature F15.verification-critic-fields --name all-critic-on` shows the API key spanning both columns. Basic (`sdk-section-basic-toggle`) keeps `confirmation_mode`, `critic_enabled`, `enable_iterative_refinement` and `critic_api_key`; Advanced adds `security_analyzer`. The three switches are an unsaved draft that survives tab switches: run `control-openhands browser reload` before the next bullet, otherwise its Confirmation Mode click turns the switch off again and the save stores the critic draft.",
          "ids": [
            "F15.verification-critic-fields"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Confirmation mode persists (`F15.confirmation-mode-save`).",
          "body": "On `/settings/verification` (Basic) run `control-openhands browser click 'testid=verification-settings-screen >> text=Confirmation Mode'`, `control-openhands browser click 'testid=save-button'`, `control-openhands browser reload` and `control-openhands browser eval \"document.querySelector('[data-testid=\\\"sdk-settings-confirmation_mode\\\"]').checked\"` (`true`). Then `control-openhands browser click 'testid=sdk-section-advanced-toggle'`, `control-openhands browser click 'testid=sdk-settings-security_analyzer'`, `control-openhands browser snapshot 'role=listbox'` (options `LLM` [selected], `None`), `control-openhands browser click 'role=listbox >> role=option[name=\"None\"]'`, `control-openhands browser click 'testid=save-button'` and `control-openhands browser reload`: the tab bar shows `Advanced` [selected] and `control-openhands browser value 'testid=sdk-settings-security_analyzer'` is `None`. `control-openhands api GET /api/settings` shows `conversation_settings.confirmation_mode` `true`, `security_analyzer` `none`. Keep both for the next bullet.",
          "ids": [
            "F15.confirmation-mode-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Confirmation stops the agent (`F15.confirmation-mode-effect`).",
          "body": "Run `control-openhands conversation start --prompt \"Run this shell command: echo qa-f15-confirm\" --wait --until waiting_for_confirmation,finished,idle,error --timeout 180`; the status is `waiting_for_confirmation`. `control-openhands browser wait-text 'Do you want to continue with this action?' --timeout 15000` succeeds and `control-openhands browser screenshot --feature F15.confirmation-mode-effect --name awaiting` shows **Cancel ⇧⌘⌫** and **Continue ⌘↩** under the pending command. Run `control-openhands browser click 'testid=action-confirm-button'`, `control-openhands conversation wait <id> --until finished,idle,error --timeout 120` (`<id>` from the start output) and `control-openhands conversation events <id> --kinds ObservationEvent`: the terminal observation is `qa-f15-confirm`. If the status is still `waiting_for_confirmation` the agent asked again (see Gotchas): confirm again. Restore on `/settings/verification` (`control-openhands browser goto /settings/verification`; the browser is on the conversation): `browser click 'testid=sdk-section-basic-toggle'`, click the Confirmation Mode label, `browser click 'testid=save-button'`; the Basic save also resets Security Analyzer to `llm` (API `confirmation_mode` `false`, `security_analyzer` `llm`).",
          "ids": [
            "F15.confirmation-mode-effect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Critic values persist (`F15.critic-save`).",
          "body": "On the All tier click `testid=verification-settings-screen >> text=\"Enable Critic\"`, run `control-openhands browser fill 'testid=sdk-settings-verification.critic_model_name' qa-critic-model`, `control-openhands browser fill 'testid=sdk-settings-verification.critic_api_key' qa-dummy-critic-key`, `control-openhands browser click 'testid=save-button'` and `control-openhands browser reload`. The page opens on `All`; `browser value 'testid=sdk-settings-verification.critic_model_name'` is `qa-critic-model` and `browser value 'testid=sdk-settings-verification.critic_api_key'` is `**********`. Restore on All: fill both fields with `''`, click `text=\"Enable Critic\"` again, save, reload: the page opens on Basic and the API shows `critic_enabled` `false`, `critic_model_name` and `critic_api_key` `null`.",
          "ids": [
            "F15.critic-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Critic mode, threshold and iterations (`F15.critic-tuning`).",
          "body": "On `/settings/verification` run `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser click 'testid=verification-settings-screen >> text=\"Enable Critic\"'`, `control-openhands browser click 'testid=sdk-settings-verification.critic_mode'` and `control-openhands browser snapshot 'role=listbox'` (options `Finish and Message` [selected], `All Actions`). Run `control-openhands browser click 'role=listbox >> role=option[name=\"All Actions\"]'`, `control-openhands browser click 'testid=verification-settings-screen >> text=\"Enable Iterative Refinement\"'`, `control-openhands browser fill 'testid=sdk-settings-verification.critic_threshold' 0.75`, `control-openhands browser fill 'testid=sdk-settings-verification.max_refinement_iterations' 5`, `control-openhands browser click 'testid=save-button'` and `control-openhands browser reload`: the page opens on `All`, `browser value` reads `All Actions`, `0.75` and `5`, and `control-openhands api GET /api/settings --pick agent_settings.verification` shows `critic_mode` `all_actions`, `critic_threshold` `0.75`, `max_refinement_iterations` `5`. Then `browser fill 'testid=sdk-settings-verification.critic_threshold' 1.5`, `browser network --clear`, `browser click 'testid=save-button'`, `browser toasts` (`HTTP request failed (422 Unprocessable Entity): {\"detail\":\"Settings validation failed\"}`, three `PATCH /api/settings` with 422 in `browser network`); the API still shows `0.75`. Restore: `browser reload`, `browser click 'testid=sdk-section-basic-toggle'`, click `text=\"Enable Iterative Refinement\"` and `text=\"Enable Critic\"` off, `browser click 'testid=save-button'`: the Basic save resets mode, threshold and iterations, and the API shows the verification defaults.",
          "ids": [
            "F15.critic-tuning"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Tier follows the stored values after a save (`F15.tier-after-save`).",
          "body": "On `/settings/condenser` (`control-openhands browser goto /settings/condenser`; the previous bullet is on `/settings/verification`) run `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser fill 'testid=sdk-settings-condenser.keep_first' 3`, `control-openhands browser click 'testid=save-button'`, `control-openhands browser wait-text 'Settings saved' --timeout 5000` and `control-openhands browser snapshot 'role=tablist'` (`All` [selected]). Then `browser fill 'testid=sdk-settings-condenser.keep_first' 2`, `browser click 'testid=save-button'`, `control-openhands browser wait 'testid=sdk-settings-condenser.keep_first' --state detached --timeout 5000` (`detached`) and `browser snapshot 'role=tablist'`: `Basic` [selected] without any reload; the API shows `keep_first` `2` (restored).",
          "ids": [
            "F15.tier-after-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Memory reaches the agent (`F15.agent-context-effect`).",
          "body": "Run `control-openhands browser goto /settings/agent-context`, turn Persistent Agent Memory on and save as in `F15.agent-context-save`, then run `control-openhands conversation start --prompt \"Reply with only: ok\" --wait --timeout 180` and `control-openhands api GET '/api/conversations/<id>/events/search?limit=1'`. The first event is the `SystemPromptEvent`; its text contains `Project memory: ` followed by `` `.openhands/memory/` under the workspace root``. The same call for a conversation started with memory off (for example the one from `F15.confirmation-mode-effect`) has no `.openhands/memory`. Restore memory off through the page (`control-openhands browser goto /settings/agent-context` first: `conversation start` left the browser on the conversation).",
          "ids": [
            "F15.agent-context-effect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Leaving discards edits (`F15.unsaved-navigation`).",
          "body": "Arrange an unsaved draft: `control-openhands browser click 'testid=sidebar-settings-/settings/verification' --expect-url '/settings/verification(\\?|$)'`, `control-openhands browser click 'testid=sdk-section-all-toggle'`, then click `testid=verification-settings-screen >> text=Confirmation Mode`, `>> text=\"Enable Critic\"` and `>> text=\"Enable Iterative Refinement\"` (do not save); `browser enabled 'testid=save-button'` is `true`. Run `control-openhands browser click 'testid=sidebar-settings-/settings/condenser' --expect-url '/settings/condenser(\\?|$)'`, `control-openhands browser dialogs` (none), `control-openhands browser click 'testid=sidebar-settings-/settings/verification' --expect-url '/settings/verification(\\?|$)'`, then `control-openhands browser eval \"[document.querySelector('[data-testid=\\\"sdk-settings-confirmation_mode\\\"]').checked, document.querySelector('[data-testid=\\\"sdk-settings-verification.critic_enabled\\\"]').checked]\"` (`[false, false]`) and `browser enabled 'testid=save-button'` (`false`).",
          "ids": [
            "F15.unsaved-navigation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Command menu (`F15.command-menu`).",
          "body": "Run `control-openhands browser goto /`, `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Condenser`, `control-openhands browser snapshot 'testid=command-menu'` (option `Condenser settings Tune context condensation behavior. Go` [selected]), `control-openhands browser press Enter` and `control-openhands browser wait-url '/settings/condenser(\\?|$)'`. Repeat with `Verification` (option `Verification settings Adjust risk and confirmation controls. Go`, URL `/settings/verification`) and with `Agent Context` (option `Agent Context Let the agent keep notes and recall them in new conversations. Go` [selected], URL `/settings/agent-context`, subtitle `Let the agent keep notes and recall them in new conversations.`). Then `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' memory` and `control-openhands browser snapshot 'testid=command-menu'`: two options, `Condenser settings …` and `Agent Context …` (hovering selects an option, so click the one you want rather than pressing Enter); close with `control-openhands browser press Escape` (`browser count 'testid=command-menu'` is `0`).",
          "ids": [
            "F15.command-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Direct URLs (`F15.direct-url`).",
          "body": "Run `control-openhands browser goto /settings/condenser` and `control-openhands browser wait 'testid=condenser-settings-screen'`; likewise `/settings/agent-context` with `testid=agent-context-settings-screen` and `/settings/verification` with `testid=verification-settings-screen`. Each wait returns `visible`.",
          "ids": [
            "F15.direct-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Phone (`F15.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/verification`, `control-openhands browser click 'testid=sdk-section-all-toggle'`, `control-openhands browser click 'testid=verification-settings-screen >> text=\"Enable Critic\"'` (unsaved), `control-openhands browser bbox 'testid=verification-settings-screen'` (width `348`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F15.phone --name verification-all` (one column; the settings nav is replaced by a back chevron). Do the same for `/settings/condenser` All. Return with `control-openhands browser viewport desktop` and `browser reload` to drop the draft.",
          "ids": [
            "F15.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Schema unavailable (`F15.schema-unavailable`, blocked).",
          "body": "`control-openhands service stop agent-server` does not reach this state: after `browser reload` the whole app is replaced by the **Manage backends** dialog (`Disconnected`, 502). Bring the stack back with `control-openhands restart`. The state needs a backend whose schema endpoints 404 (see Preconditions); there `browser text 'testid=sdk-schema-unavailable'` should start with `SDK settings schema unavailable.`",
          "ids": [
            "F15.schema-unavailable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Errors sweep.",
          "body": "`control-openhands browser errors --clear`, open the three pages and their All tabs, then `control-openhands browser errors --app-only`: no page errors and no warnings (the condenser fields are translated since #18035).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F16",
      "title": "Application settings",
      "file": "F16-application-settings.md",
      "page": "F16-application-settings.html",
      "sha256": "3a3d08d3a40c5f4315819e1515da2d5a7f7e6f991585039ebefe019a35f50742",
      "behaviors": [
        {
          "id": "F16.open",
          "description": "the page opens from the settings navigation, the command menu and the direct URL; every section renders and **Save Changes** is disabled while nothing has changed."
        },
        {
          "id": "F16.language",
          "description": "choosing a language and saving switches the whole UI at once and persists after a reload; `<html lang>` and `<html dir>` follow the chosen language (`rtl` for Arabic)."
        },
        {
          "id": "F16.color-theme",
          "description": "choosing a colour theme applies it immediately without Save, persists after a reload and on other pages, and is never sent to the backend."
        },
        {
          "id": "F16.analytics",
          "description": "the \"Send anonymous usage data\" switch saves with the form and sets browser telemetry consent to granted/denied."
        },
        {
          "id": "F16.analytics-cloud",
          "description": "on a Cloud backend the analytics switch is forced on and disabled."
        },
        {
          "id": "F16.sound",
          "description": "the \"Sound Notifications\" switch saves with the form; with it on, a sound plays when the agent finishes or needs input."
        },
        {
          "id": "F16.checklist",
          "description": "the \"Show Getting Started checklist\" switch hides or shows the sidebar checklist immediately, without Save, and survives a reload."
        },
        {
          "id": "F16.title-model",
          "description": "the Title generation model dropdown offers \"Automatic (recommended)\" plus one `name · model` option per LLM profile; the choice persists and new conversations still get an automatic title."
        },
        {
          "id": "F16.title-model-fallback",
          "description": "if the chosen profile is deleted (from LLM settings, or outside the UI), the dropdown shows Automatic and the form stays clean."
        },
        {
          "id": "F16.manage-profiles-link",
          "description": "\"Manage LLM profiles\" goes to `/settings/llm`."
        },
        {
          "id": "F16.voice-endpoint",
          "description": "Voice input Base URL, API Key (masked) and Model save on every keystroke in this browser only, independently of Save; the key lives in session storage."
        },
        {
          "id": "F16.git-identity",
          "description": "Git Username and Git Email save with the form, persist after a reload, and are what the agent's commits use."
        },
        {
          "id": "F16.git-identity-validation",
          "description": "an email without `@` is refused by native validation; empty fields save the defaults `openhands` / `openhands@all-hands.dev`."
        },
        {
          "id": "F16.save-states",
          "description": "Save is enabled only while a saved field differs from the stored value; while a save is pending the whole form, Save included, is replaced by the `app-settings-skeleton` (so the \"Saving...\" label in the source never shows); success toasts \"Settings saved\", failure toasts the HTTP error and should keep the user's edits for a retry."
        },
        {
          "id": "F16.keyboard",
          "description": "every control, including the three switches, is reachable with Tab and exposed to assistive technology."
        },
        {
          "id": "F16.phone",
          "description": "the form fits a 390 px viewport without horizontal overflow and Save is reachable by scrolling."
        },
        {
          "id": "F16.title-model-clear",
          "description": "the Title generation model combobox has a clear (×) button, shown on hover; it resets the choice to Automatic (recommended)."
        },
        {
          "id": "F16.dropdown-filter",
          "description": "typing into the Language (or any) combobox filters its options; Escape restores the stored value and leaves Save disabled."
        },
        {
          "id": "F16.browser-scope",
          "description": "backend-saved preferences (language and the rest of the Save group) follow the user to a fresh browser profile, while theme and checklist visibility return to their defaults there."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open from navigation (`F16.open`).",
          "body": "From `/` run `control-openhands browser click 'testid=backend-selector-settings-link'`, `control-openhands browser click 'testid=sidebar-settings-/settings/app'`, then `control-openhands browser url`, `control-openhands browser snapshot 'testid=app-settings-screen'` and `control-openhands browser screenshot --feature F16.open --name desktop`. The URL ends in `/settings/app`; the snapshot shows comboboxes \"Language\" (`English`), \"Color Theme\" (`OpenHands-Neutral`) and \"Title generation model\" (`Automatic (recommended)`), headings \"Conversation titles\", \"Voice input\" and \"Git Settings\", and `button \"Save Changes\" [disabled]`.",
          "ids": [
            "F16.open"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command-menu and URL entries (`F16.open`).",
          "body": "Run `control-openhands browser goto /` (the previous bullet ends on `/settings/app`, where the URL check would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Application`, `control-openhands browser snapshot 'testid=command-menu'` (option \"Application settings Change app language, theme, and preferences. Go\" is `[selected]`), `control-openhands browser press Enter`, then `control-openhands browser url`; it ends in `/settings/app`. For the URL entry run `control-openhands browser goto /settings/app`, `control-openhands browser enabled 'testid=submit-button'` (`false`) and `control-openhands browser attr 'testid=sidebar-settings-/settings/app' aria-current` (`page`).",
          "ids": [
            "F16.open"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Language (`F16.language`).",
          "body": "Run `control-openhands browser click 'testid=language-input'` (15 options), `control-openhands browser click 'role=option[name=\"Français\"]'`, `control-openhands browser enabled 'testid=submit-button'` (`true`), `control-openhands browser click 'testid=submit-button'`, then `control-openhands browser text 'testid=submit-button'`. It reads `Enregistrer les modifications` without a reload. Run `control-openhands browser reload`, `control-openhands browser value 'testid=language-input'` (`Français`), `control-openhands browser screenshot --feature F16.language --name french` (the screenshot, not a scoped snapshot, shows the sidebar's \"Nouvelle discussion\" and the headings \"Titres des conversations\", \"Saisie vocale\") and `control-openhands api GET /api/settings` (`language` is `fr`). Then check the document language with `control-openhands browser eval \"document.documentElement.lang + ' ' + document.documentElement.dir\"` (`fr ltr`) and the translated sidebar trigger with `control-openhands browser attr 'testid=command-menu-trigger' aria-label` (`Rechercher des commandes`). Choose `role=option[name=\"Arabic\"]` the same way (click the input, the option, Save, reload): the `eval` reads `ar rtl`, and `control-openhands browser bbox 'aside[data-collapsed]'` has `x` `1140`, so the 300 px sidebar now sits at the right edge of the 1440 px viewport. `control-openhands browser screenshot --feature F16.language --name arabic` shows the whole layout flipped right to left: the sidebar on the right, the settings navigation to its left, and the form on the left with its Arabic labels and values aligned right. Restore: `control-openhands browser click 'testid=language-input'`, `control-openhands browser click 'role=option[name=\"English\"]'`, `control-openhands browser click 'testid=submit-button'`, `control-openhands browser reload`, `control-openhands browser text 'testid=submit-button'` reads `Save Changes` and the `eval` reads `en ltr`.",
          "ids": [
            "F16.language"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Colour theme (`F16.color-theme`).",
          "body": "Run `control-openhands browser click 'testid=color-theme-input'`, `control-openhands browser snapshot 'role=listbox'` (OpenHands-DeepSea, OpenHands-Neutral [selected], OpenHands-Neo, Light+, Solarized Light), `control-openhands browser click 'role=option[name=\"Light+\"]'`, then `control-openhands browser eval \"document.documentElement.style.colorScheme + ' ' + localStorage.getItem('openhands-color-theme')\"` (`light light-plus`) and `control-openhands browser enabled 'testid=submit-button'` (`false`: no Save needed). `control-openhands browser screenshot --feature F16.color-theme --name light-plus` shows a white page. Second views: `control-openhands browser reload` then `control-openhands browser value 'testid=color-theme-input'` (`Light+`); `control-openhands browser goto /` then `control-openhands browser eval \"getComputedStyle(document.body).backgroundColor + ' ' + document.documentElement.style.colorScheme\"` (`rgb(255, 255, 255) light`); `control-openhands api GET /api/settings` contains no theme key. Restore on `/settings/app` (`control-openhands browser goto /settings/app`; the second view left `/`) by choosing `role=option[name=\"OpenHands-Neutral\"]` the same way (`dark openhands-neutral`).",
          "ids": [
            "F16.color-theme"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Analytics consent (`F16.analytics`).",
          "body": "Run `control-openhands browser click 'testid=app-settings-screen >> text=Send anonymous usage data'`, read the switch with the `eval` from Preconditions (`true`), `control-openhands browser click 'testid=submit-button'`, `control-openhands browser wait-text 'Settings saved'`, `control-openhands browser reload`, read the switch again (`true`), `control-openhands api GET /api/settings` (`user_consents_to_analytics: true`) and `control-openhands browser eval \"localStorage.getItem('openhands-telemetry-consent')\"` (`granted`). Restore by clicking the label again and saving; consent reads `denied` and the API `false`.",
          "ids": [
            "F16.analytics"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Cloud forces analytics (`F16.analytics-cloud`).",
          "body": "Blocked: needs a Cloud backend selected in the backend selector. Expected: `control-openhands browser enabled 'testid=enable-analytics-switch'` is `false` and the switch reads checked.",
          "ids": [
            "F16.analytics-cloud"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Sound notifications (`F16.sound`).",
          "body": "Run `control-openhands browser click 'testid=app-settings-screen >> text=Sound Notifications'`, `control-openhands browser click 'testid=submit-button'`, `control-openhands browser wait-text 'Settings saved'`, `control-openhands browser reload`, read `enable-sound-notifications-switch` (`true`) and `control-openhands api GET /api/settings` (`enable_sound_notifications: true`). Restore by toggling and saving again (`false`). Audible half: with the switch on and saved, `control-openhands conversation start --prompt \"Reply with only the word ok. Do not run any tools.\" --wait --timeout 180`, then `control-openhands browser media`: `plays` lists one `/assets/notification-<hash>.mp3` at the moment the conversation finished. With the switch off, the same check after a conversation reads `plays: []` (the `F16.git-identity` conversation below doubles as that negative check). Turn the switch off and save afterwards (`control-openhands browser goto /settings/app` first: `conversation start` left the browser on the conversation).",
          "ids": [
            "F16.sound"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Getting Started checklist (`F16.checklist`).",
          "body": "Run `control-openhands browser count 'testid=sidebar-onboarding-checklist'` (`1`), `control-openhands browser click 'testid=app-settings-screen >> text=Show Getting Started checklist'`, then the same `count` (`0`, immediately) and `control-openhands browser enabled 'testid=submit-button'` (`false`). `control-openhands browser screenshot --feature F16.checklist --name hidden` shows the sidebar without the \"Getting started\" card. `control-openhands browser reload` keeps the count at `0` and the switch unchecked. Click the label again to restore; the count returns to `1`.",
          "ids": [
            "F16.checklist"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Title generation model (`F16.title-model`).",
          "body": "Run `control-openhands browser click 'testid=title-llm-profile-input'` and `control-openhands browser snapshot 'role=listbox'`: options `Automatic (recommended)`, `deepseek-flash · deepseek/deepseek-flash`, `deepseek-pro · deepseek/deepseek-v4-pro`. Run `control-openhands browser click 'role=option >> has-text=deepseek-pro'`, `control-openhands browser click 'testid=submit-button'`, `control-openhands browser wait-text 'Settings saved'`, `control-openhands browser reload`, `control-openhands browser value 'testid=title-llm-profile-input'` (`deepseek-pro · deepseek/deepseek-v4-pro`) and `control-openhands api GET /api/settings` (`title_llm_profile: \"deepseek-pro\"`). Then `control-openhands conversation start --prompt \"Reply with only the word ok. Do not run any tools.\" --wait --timeout 180` and, a few seconds later, `control-openhands conversation status <id>` with the `id` it printed: `title` is a generated title: an emoji and a short phrase whose wording varies (for example `🔧 Acknowledge with ok only`). The start output's own `title` may already be set; when it is still `null`, the title is written a few seconds after the run finishes. Return with `control-openhands browser goto /settings/app`, choose `role=option[name=\"Automatic (recommended)\"]`, save; `title_llm_profile` is then absent from `app_preferences`.",
          "ids": [
            "F16.title-model"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Manage LLM profiles link (`F16.manage-profiles-link`).",
          "body": "Run `control-openhands browser click 'role=link[name=\"Manage LLM profiles\"]'` then `control-openhands browser url`; it ends in `/settings/llm` and `control-openhands browser count 'testid=profile-row'` lists the profiles.",
          "ids": [
            "F16.manage-profiles-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Deleted profile falls back (`F16.title-model-fallback`).",
          "body": "Arrange a throwaway profile: `control-openhands llm set --profile qa-title --model deepseek/deepseek-flash --api-key-file <key file> --no-activate --no-validate`. On `/settings/app` select it (`control-openhands browser click 'testid=title-llm-profile-input'`, `control-openhands browser click 'role=option >> has-text=qa-title'`, save). Delete it through the UI: `control-openhands browser click 'role=link[name=\"Manage LLM profiles\"]'`, `control-openhands browser click 'testid=profile-row >> has-text=qa-title >> testid=profile-menu-trigger'`, `control-openhands browser click 'testid=profile-actions-menu >> testid=profile-delete'`, `control-openhands browser click 'testid=delete-profile-confirm'`, then `control-openhands browser click 'testid=sidebar-settings-/settings/app'`. `control-openhands browser value 'testid=title-llm-profile-input'` is `Automatic (recommended)`, Save is disabled, and `title_llm_profile` is absent from the API's `app_preferences`. For a preference that goes stale outside the UI, arrange `control-openhands api PATCH /api/settings --data '{\"misc_settings_diff\":{\"app_preferences\":{\"title_llm_profile\":\"qa-missing\"}}}' --write`, then `control-openhands browser reload`: the dropdown still shows `Automatic (recommended)` and Save stays disabled, while the API keeps `qa-missing` until the next Save of this form. Any Save writes the form's current preferences, which no longer hold the stale key, so saving an unrelated change removes it: toggle Sound Notifications, save, toggle back, save.",
          "ids": [
            "F16.title-model-fallback"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Voice input endpoint (`F16.voice-endpoint`).",
          "body": "Run `control-openhands browser fill 'testid=transcription-base-url-input' http://127.0.0.1:9/v1`, `control-openhands browser fill 'testid=transcription-api-key-input' qa-dummy-key`, `control-openhands browser fill 'testid=transcription-model-input' qa-whisper`, then `control-openhands browser enabled 'testid=submit-button'` (`false`) and `control-openhands browser attr 'testid=transcription-api-key-input' type` (`password`). After `control-openhands browser reload`, `control-openhands browser value 'testid=transcription-model-input'` is `qa-whisper` (same for the other two). `control-openhands browser eval \"localStorage.getItem('openhands-transcription-endpoint') + ' | session=' + sessionStorage.getItem('openhands-transcription-api-key')\"` reads `{\"baseUrl\":\"http://127.0.0.1:9/v1\",\"model\":\"qa-whisper\"} | session=qa-dummy-key`; the API body has no `qa-whisper`. Clear each with `control-openhands browser fill 'testid=transcription-base-url-input' ''` (and the other two). Using the endpoint from the composer microphone is not driven here (needs microphone capture and a transcription server).",
          "ids": [
            "F16.voice-endpoint"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Git identity (`F16.git-identity`).",
          "body": "Run `control-openhands browser fill 'testid=git-user-name-input' 'QA Tester'`, `control-openhands browser fill 'testid=git-user-email-input' 'qa-tester@example.com'`, `control-openhands browser click 'testid=submit-button'`, `control-openhands browser wait-text 'Settings saved'`, `control-openhands browser reload`, then `control-openhands browser value 'testid=git-user-name-input'` (`QA Tester`) and `control-openhands api GET /api/settings` (`git_user_name`, `git_user_email` set). Agent half: `control-openhands conversation start --prompt \"Run exactly this command and reply with only its output: env | grep -c '^GIT_'; git init -q qa_t && cd qa_t && git commit --allow-empty -qm x; git log -1 --format='%an <%ae>'\" --wait --timeout 240`, then `control-openhands conversation events <id> --kinds ObservationEvent,MessageEvent` with the `id` from the start output. Expected: `QA Tester <qa-tester@example.com>`. **Known failure:** the observation is `0 Author identity unknown ...`: the identity is stored only in `misc_settings.app_preferences` and never reaches the agent.",
          "ids": [
            "F16.git-identity"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Git validation and defaults (`F16.git-identity-validation`).",
          "body": "Run `control-openhands browser fill 'testid=git-user-email-input' 'not-an-email'`, `control-openhands browser click 'testid=submit-button'`, then `control-openhands browser eval \"document.querySelector('[data-testid=git-user-email-input]').validationMessage\"` (`Please include an '@' in the email address. ...`); the API email is unchanged. Then `control-openhands browser fill 'testid=git-user-email-input' ''`, `control-openhands browser fill 'testid=git-user-name-input' ''`, click submit, `control-openhands browser wait-text 'Settings saved'`, `control-openhands browser reload`: the inputs read `openhands` and `openhands@all-hands.dev`, which also restores the baseline.",
          "ids": [
            "F16.git-identity-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Save button states (`F16.save-states`).",
          "body": "Click the analytics label once: `control-openhands browser enabled 'testid=submit-button'` is `true`; click it again: `false` (back to the stored value). Each successful save above made while the UI is in English shows the toast found by `control-openhands browser wait-text 'Settings saved'`; the Arabic save and the English restore in `F16.language` are made from a French and an Arabic UI and toast `Paramètres enregistrés` and `تم حفظ الإعدادات` instead (see Gotchas). Pending and failure: run `control-openhands service stop agent-server` (the open page keeps its form), click `testid=app-settings-screen >> text=Sound Notifications`, start a sampler with `control-openhands browser eval \"(() => { window.__seen = []; window.__iv = setInterval(() => { const s = document.querySelector('[data-testid=submit-button]'); const k = (s ? s.textContent : '-') + ' skeleton=' + !!document.querySelector('[data-testid=app-settings-skeleton]'); if (window.__seen.at(-1) !== k) window.__seen.push(k); }, 10); return 'started'; })()\"`, click `testid=submit-button`, `control-openhands browser wait-text 'HTTP request failed'` (the toast starts `HTTP request failed (502 Bad Gateway)` and names the refused Agent Server port), then `control-openhands browser eval \"(() => { clearInterval(window.__iv); return window.__seen.join(' -> '); })()\"`: it starts with `Save Changes skeleton=false -> - skeleton=true`, i.e. the form and its Save button are replaced by the skeleton while pending; `Saving...` never appears. By the time the failure toast is read the form has usually come back, adding `-> Save Changes skeleton=false`; assert the prefix. Expected after the failure: the Sound switch stays on so the user can retry. **Known failure:** `control-openhands browser eval \"document.querySelector('[data-testid=enable-sound-notifications-switch]').checked\"` is `false` and Save is disabled: the edit is silently discarded. Bring the backend back with `control-openhands restart`, `control-openhands doctor`, `control-openhands browser goto /settings/app`.",
          "ids": [
            "F16.save-states"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Keyboard and screen readers (`F16.keyboard`).",
          "body": "Run `control-openhands browser focus 'testid=color-theme-input'`, then repeat `control-openhands browser press Tab` and `control-openhands browser eval \"(document.activeElement.getAttribute('data-testid')||'') + ' ' + document.activeElement.tagName\"`. Expected (the acceptance criterion): focus visits the three switches before `title-llm-profile-input`. **Known failure, today's result:** the first Tab lands on `title-llm-profile-input`; `control-openhands browser snapshot 'testid=app-settings-screen' --feature F16.keyboard --name aria` shows the switches only as `text: Send anonymous usage data Sound Notifications Show Getting Started checklist`, with no switch or checkbox nodes.",
          "ids": [
            "F16.keyboard"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Phone layout (`F16.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /settings/app`, `control-openhands browser bbox 'testid=app-settings-screen'` (`pageHorizontalOverflow: false`, width 348 at x 16), `control-openhands browser screenshot --feature F16.phone --name top`, `control-openhands browser scroll 'testid=submit-button'`, `control-openhands browser bbox 'testid=submit-button'` (`insideViewport: true`) and `control-openhands browser screenshot --feature F16.phone --name bottom`. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F16.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Clear the title model (`F16.title-model-clear`).",
          "body": "Run `control-openhands browser click 'testid=title-llm-profile-input'`, `control-openhands browser click 'role=option >> has-text=deepseek-flash'` (Save enables), `control-openhands browser hover 'testid=title-llm-profile-input'`, then `control-openhands browser click 'testid=title-llm-profile-input >> xpath=../..//button[not(@aria-label)]'`. `control-openhands browser value 'testid=title-llm-profile-input'` reads `Automatic (recommended)` and `control-openhands browser enabled 'testid=submit-button'` is `false`. Without the hover the click times out (`<div ...> intercepts pointer events`).",
          "ids": [
            "F16.title-model-clear"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Filter a dropdown by typing (`F16.dropdown-filter`).",
          "body": "Run `control-openhands browser fill 'testid=language-input' Deu` and `control-openhands browser snapshot 'role=listbox'`: the only option is `Deutsch`. Save is briefly `true` while the text is typed; `control-openhands browser press Escape` restores `control-openhands browser value 'testid=language-input'` to `English` and `control-openhands browser enabled 'testid=submit-button'` to `false`.",
          "ids": [
            "F16.dropdown-filter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Browser vs backend scope (`F16.browser-scope`).",
          "body": "Choose `Deutsch` in `testid=language-input` and save, choose `Solarized Light` in `testid=color-theme-input`, and hide the checklist with `control-openhands browser click 'testid=show-getting-started-checklist-switch >> xpath=ancestor::label'` (the label text is German now). Then `control-openhands browser reset`, `control-openhands onboard --skip`, `control-openhands browser goto /settings/app`, `control-openhands browser wait 'testid=submit-button'`: `control-openhands browser text 'testid=submit-button'` is `Änderungen speichern` and `control-openhands browser value 'testid=language-input'` is `Deutsch` (backend), while `control-openhands browser value 'testid=color-theme-input'` is `OpenHands-Neutral`, `control-openhands browser eval \"localStorage.getItem('openhands-color-theme')\"` is `null` and `control-openhands browser count 'testid=sidebar-onboarding-checklist'` is `1` (browser-only, back to defaults). Restore English (the option keeps its English name in the German UI): `control-openhands browser click 'testid=language-input'`, `control-openhands browser click 'role=option[name=\"English\"]'`, `control-openhands browser click 'testid=submit-button'`, `control-openhands browser reload`, `control-openhands browser wait 'testid=submit-button'`; `control-openhands browser text 'testid=submit-button'` reads `Save Changes` and `control-openhands api GET /api/settings --pick misc_settings.app_preferences.language` is `en`.",
          "ids": [
            "F16.browser-scope"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "After the family",
          "body": "After the family, `control-openhands browser errors --app-only` reports `appErrors: 0`. Requests to `registry.npmjs.org` (update check) and Google Fonts fail through the sandbox proxy; they are not app errors.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F17",
      "title": "Customize hub and MCP servers",
      "file": "F17-mcp-servers.md",
      "page": "F17-mcp-servers.html",
      "sha256": "dcfcced248ec53b3963960fd7c5c08031d93a7d5f65b0d7d143ddb1919e79851",
      "behaviors": [
        {
          "id": "F17.customize-redirect",
          "description": "on desktop, the sidebar **Customize** link, the URL `/customize` and the command-menu items **Customize** and **MCP servers** all land on `/mcp`."
        },
        {
          "id": "F17.desktop-subnav",
          "description": "from 1024 px up, Customize pages show a left aside headed **Customize** with MCP Servers, Skills, Plugins and Apps, the active row marked, and the \"synced from\" backend badge."
        },
        {
          "id": "F17.mobile-hub",
          "description": "below 1024 px (phones and tablets), `/customize` is a hub with the same four rows and badge; at phone width detail pages show a Back chevron to the hub; widening the window to 1024 px or more on `/customize` redirects to `/mcp`."
        },
        {
          "id": "F17.breakpoint-sweep",
          "description": "at 767, 768, 820, 1023, 1024 and 1440 px, `/customize` is the hub below 1024 px and the desktop aside from 1024 px up, and the MCP page keeps **Add custom server**, a search box wider than 150 px and no horizontal overflow."
        },
        {
          "id": "F17.cloud-nav",
          "description": "with a Cloud backend active, Plugins and Apps are hidden and Skills becomes an external link to `<cloud host>/settings/skills` (blocked here)."
        },
        {
          "id": "F17.page-states",
          "description": "`/mcp` shows the **Model Context Protocol (MCP)** header, **Add custom server**, the toolbar, an **Installed** section (empty-state copy when nothing is installed) and the **Library** grid, without page errors."
        },
        {
          "id": "F17.search",
          "description": "one search box filters the library and the installed list (catalog name, description and args count); no match shows `No matches for your search.` in each section; the X clears it."
        },
        {
          "id": "F17.section-filter",
          "description": "the All / Installed / Library dropdown shows only the chosen sections."
        },
        {
          "id": "F17.library-catalog",
          "description": "library cards (logo, name, transport, description, + toggle) open the install modal by click, + toggle or Enter; the modal closes with Cancel, X or Escape."
        },
        {
          "id": "F17.install-remote",
          "description": "a remote (HTTP) entry shows its fixed URL read-only and a `type=\"password\"` credential field that is required."
        },
        {
          "id": "F17.install-oauth",
          "description": "an entry whose server runs OAuth itself shows the OAuth info panel instead of a key field."
        },
        {
          "id": "F17.install-unsupported",
          "description": "entries the local backend cannot install (provider-OAuth only, 12 today such as Sentry) should be hidden or explain themselves; today their modal is empty and Install does nothing (fail)."
        },
        {
          "id": "F17.install-args",
          "description": "an entry with argument fields (Filesystem's **Paths (space separated)**) requires them and appends each whitespace-separated token to the stored command's args."
        },
        {
          "id": "F17.install-stdio",
          "description": "a STDIO entry shows its read-only command; Install shows `Verifying…`, `Saving...`, toasts `MCP server saved.` and adds a card with the catalog name and description."
        },
        {
          "id": "F17.install-error",
          "description": "a failed install test keeps the modal open with a red error and saves nothing."
        },
        {
          "id": "F17.save-as-secret",
          "description": "password credential fields carry **Also save as secret** (checked by default, with a tooltip); after install the value is listed in Settings → Secrets."
        },
        {
          "id": "F17.custom-validation",
          "description": "the custom editor refuses bad input with an inline message (URL required/invalid/wrong protocol, name invalid or duplicate, command required or with spaces, env/header not `KEY=value`, header required, OAuth secret without client ID, timeout out of range). A malformed header line reuses the environment-variable message (fail)."
        },
        {
          "id": "F17.test-connection",
          "description": "**Test connection** in the editor shows `Verifying…`, then a green `Connected — N tool(s) available` or a red categorized error; Add/Save tests first and stays open on failure."
        },
        {
          "id": "F17.custom-add",
          "description": "**Add Server** saves and closes; the card is listed after a reload."
        },
        {
          "id": "F17.custom-add-remote",
          "description": "a custom Streamable HTTP (SHTTP) server with a **Bearer token** connects, saves as an `HTTP` card showing its URL, reopens with the token masked, and its tools reach the agent as `<server>_<tool>`."
        },
        {
          "id": "F17.server-health",
          "description": "each card's health row runs **Test connection** (`Checking connection…`), then shows a verdict with a coloured dot; a failure is red, worded for the transport (a STDIO command that cannot start, a remote server that cannot be reached) with the Agent Server's detail, and offers **Retry**."
        },
        {
          "id": "F17.enable-disable",
          "description": "the card toggle disables a server without removing its configuration; the state persists and a disabled server is withheld from new conversations."
        },
        {
          "id": "F17.custom-edit",
          "description": "clicking a card opens **Edit MCP server** prefilled (secrets shown as `**********`); Save tests and persists the change."
        },
        {
          "id": "F17.stored-secret-reuse",
          "description": "a saved server's env values and tokens come back from the settings API and in the editor as `**********`; the editor's Test connection swaps each placeholder for the stored encrypted value (never the placeholder text, never the plaintext) and Save leaves untouched secrets out of its sparse patch, so a Save from the edit form that keeps them changes only the edited fields."
        },
        {
          "id": "F17.single-request-mutations",
          "description": "adding, editing or deleting one server sends exactly one `POST`, `PATCH` or `DELETE` to `/api/settings/mcp/<name>` and leaves sibling servers and their stored credentials untouched."
        },
        {
          "id": "F17.credential-probe",
          "description": "a catalog entry whose server lists its tools with any credentials (Slack) also gets a read-only credential probe during Install and Test connection: a failed probe reads `Credential check failed: <provider error>` and saves nothing (Known failure, reproduced 2026-10-08: any errored probe call, a network `fetch failed` included, is worded as a credential failure; #18161). A hosted server that rejects the token at connect (GitHub) never reaches a probe and reads `Connection failed: … 401 Unauthorized …`, also saving nothing (Known failure, reproduced 2026-10-08: the Agent Server's `POST /api/mcp/test` returns the 401 as `error_kind` `unknown`, so the modal cannot call it a credential failure; OpenHands/software-agent-sdk#5607); a server the catalog does not know gets no probe."
        },
        {
          "id": "F17.delete-server",
          "description": "**Delete** in the editor asks for confirmation; Cancel keeps the server, Confirm removes it with `MCP server removed.`"
        },
        {
          "id": "F17.native-git-tabs",
          "description": "on a Cloud backend, GitHub/GitLab/Bitbucket offer a recommended native-integration tab next to the MCP tab (blocked here)."
        },
        {
          "id": "F17.agent-uses-server",
          "description": "a conversation started after the save can call the server's tools."
        },
        {
          "id": "F17.phone",
          "description": "the MCP page, the install modal and the custom editor fit a 390 px viewport."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Desktop entry points (`F17.customize-redirect`).",
          "body": "Run `control-openhands browser goto /conversations`, `control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/mcp(\\?|$)'` and `control-openhands browser count 'testid=mcp-page'` (`1`). Run `control-openhands browser goto /customize` and `control-openhands browser wait-url '/mcp(\\?|$)'`. Command menu: run `control-openhands browser goto /conversations` (the browser is on `/mcp`, where the URL wait would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' MCP`, `control-openhands browser press Enter` and `control-openhands browser wait-url '/mcp(\\?|$)'`; repeat with the term `Customize` (its only option reads `Customize Browse skills, plugins, and integrations.`). Every path ends on `/mcp`.",
          "ids": [
            "F17.customize-redirect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Desktop sub-navigation (`F17.desktop-subnav`).",
          "body": "On `/mcp` run `control-openhands browser text 'testid=extensions-navbar-desktop'`: `Customize`, `MCP Servers`, `Skills`, `Plugins`, `Apps`, then `These settings are synced from Local backend (<base url>)`. `control-openhands browser attr 'testid=sidebar-extensions-/mcp' aria-current` is `page`. Run `control-openhands browser click 'testid=sidebar-extensions-/skills' --expect-url '/skills(\\?|$)'`; that row's `aria-current` becomes `page`. Do the same for `/plugins` and `/apps`, then return with `control-openhands browser click 'testid=sidebar-extensions-/mcp' --expect-url '/mcp(\\?|$)'`. `control-openhands browser screenshot 'testid=extensions-navbar-desktop' --feature F17.desktop-subnav --name subnav` shows the MCP Servers row highlighted.",
          "ids": [
            "F17.desktop-subnav"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Phone hub (`F17.mobile-hub`, `F17.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /customize` and `control-openhands browser text 'testid=extensions-mobile-hub'` (the same five lines as the desktop aside); `control-openhands browser screenshot --feature F17.mobile-hub --name hub`. Run `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\\?|$)'`; `control-openhands browser attr 'testid=sidebar-mobile-back-button' aria-label` is `Customize`, and `control-openhands browser count 'testid=extensions-navbar-desktop >> visible'` is `0`. `control-openhands browser bbox 'testid=mcp-page'` has `insideViewport` `true` and `pageHorizontalOverflow` `false`; `control-openhands browser screenshot --feature F17.phone --name mcp-page` shows the header, toolbar and one column of cards. Run `control-openhands browser click 'testid=sidebar-mobile-back-button' --expect-url '/customize(\\?|$)'`; `control-openhands browser count 'testid=extensions-mobile-hub'` is `1`. Drawer entry: `control-openhands browser goto /conversations`, `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'` and `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=sidebar-skills-link' --expect-url '/customize(\\?|$)'`; the hub is shown. Tablets get the hub too: `control-openhands browser viewport tablet` (820 px) keeps it on `/customize`; run `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\\?|$)'`, then `control-openhands browser visible 'testid=extensions-navbar-desktop'` and `control-openhands browser visible 'testid=sidebar-mobile-back-button'` (both `false`), `control-openhands browser bbox 'role=main'` (520 px wide, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F17.mobile-hub --name tablet-mcp`; the rail's `control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/customize(\\?|$)'` brings the hub back. Then `control-openhands browser viewport desktop` and `control-openhands browser wait-url '/mcp(\\?|$)'`: widening on `/customize` redirects to `/mcp`.",
          "ids": [
            "F17.mobile-hub",
            "F17.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Widths around the breakpoint (`F17.breakpoint-sweep`).",
          "body": "For each width `W` in `767`, `768`, `820`, `1023`, `1024`, `1440` run `control-openhands browser viewport Wx1024` and `control-openhands browser goto /customize`. Below 1024 px `control-openhands browser visible 'testid=extensions-mobile-hub'` is `true` and `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\\?|$)'` opens the MCP page; from 1024 px up `control-openhands browser wait-url '/mcp(\\?|$)'` passes at once and `control-openhands browser count 'testid=extensions-mobile-hub'` is `0`. On `/mcp`, `control-openhands browser visible 'testid=extensions-navbar-desktop'` is `false` below 1024 px and `true` from 1024 px (the switch is exactly 1023 → 1024), `control-openhands browser visible 'testid=mcp-add-custom-server'` is `true`, `control-openhands browser bbox 'testid=mcp-search-input'` is wider than 150 px (622, 323, 375, 578, 257 and 673 today) and `control-openhands browser bbox 'role=main'` has `pageHorizontalOverflow` `false` with equal `scrollWidth` and `clientWidth` (`main` is full width at 767 px, `W − 300` beside the rail from 768 to 1023 px, `W − 640` beside the rail and the aside from 1024 px). `control-openhands browser fill 'testid=mcp-search-input' GitHub` keeps `control-openhands browser count 'testid=mcp-marketplace-card-github'` at `1`; `control-openhands browser click 'testid=mcp-search-clear'`. At the boundary take `control-openhands browser screenshot --feature F17.breakpoint-sweep --name mcp-1023` and `--name mcp-1024`. Finish with `control-openhands browser viewport desktop`.",
          "ids": [
            "F17.breakpoint-sweep"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Page and empty state (`F17.page-states`).",
          "body": "On a fresh run run `control-openhands browser goto /mcp`, `control-openhands browser text 'testid=mcp-installed-empty'` (`No MCP servers installed yet.` and `Pick one from the marketplace below to get started.`) and `control-openhands browser count '[data-testid^=\"mcp-marketplace-card-\"]'` (`55` today; the catalog ships with `@openhands/extensions`). `control-openhands browser eval \"[...document.querySelectorAll('main h2')].map(h=>h.textContent)\"` is `[\"Model Context Protocol (MCP)\",\"Installed\",\"Library\"]`. Take `control-openhands browser screenshot --feature F17.page-states --name empty-desktop`. After the family, `control-openhands browser errors --app-only` shows `pageErrors` `0`.",
          "ids": [
            "F17.page-states"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Search (`F17.search`).",
          "body": "Run `control-openhands browser fill 'testid=mcp-search-input' time` and `control-openhands browser count '[data-testid^=\"mcp-marketplace-card-\"]'` (`1`, `mcp-marketplace-card-time`). Fill `qa-zzz-nothing`; `control-openhands browser text 'testid=mcp-marketplace-empty'` is `No matches for your search.` (the installed list's search needs installed cards: it is driven at the end of `F17.install-stdio`). Run `control-openhands browser click 'testid=mcp-search-clear'`; `control-openhands browser value 'testid=mcp-search-input'` is `\"\"` and the card count is back to `55`.",
          "ids": [
            "F17.search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Section filter (`F17.section-filter`).",
          "body": "Run `control-openhands browser click 'testid=mcp-section-filter >> testid=dropdown-trigger'`, `control-openhands browser snapshot 'testid=mcp-section-filter'` (`button \"Filter MCP servers\" [expanded]: All` and `menu \"Filter MCP servers\"` with `All` [checked], `Installed`, `Library`) and `control-openhands browser click 'testid=mcp-section-filter-library'`; the `main h2` list above loses `Installed`. Open the dropdown again and click `testid=mcp-section-filter-installed`; `control-openhands browser count 'testid=mcp-marketplace-section'` is `0` and `control-openhands browser text 'testid=mcp-section-filter >> testid=dropdown-trigger'` is `Installed`. Restore with `testid=mcp-section-filter-all`.",
          "ids": [
            "F17.section-filter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Open and close the install modal (`F17.library-catalog`).",
          "body": "Run `control-openhands browser click 'testid=mcp-marketplace-card-time'`, `control-openhands browser text 'testid=mcp-install-modal'` (`Time`, its description, `View documentation →`, `Command`, `Cancel`, `Install`) and `control-openhands browser value 'testid=mcp-install-field-command-readonly'` (`uvx mcp-server-time`; `browser enabled` is `false`). Close with `control-openhands browser click 'testid=mcp-install-cancel'`; `control-openhands browser count 'testid=mcp-install-modal'` is `0`. Reopen with `control-openhands browser click 'testid=mcp-marketplace-toggle-time'` and close with `control-openhands browser click 'testid=mcp-install-modal-close'`. Keyboard: `control-openhands browser focus 'testid=mcp-marketplace-card-time'`, `control-openhands browser press Enter` (count `1`), `control-openhands browser press Escape` (count `0`).",
          "ids": [
            "F17.library-catalog"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Remote entry, masked key (`F17.install-remote`).",
          "body": "Run `control-openhands browser click 'testid=mcp-marketplace-card-stripe'`; `control-openhands browser value 'testid=mcp-install-modal >> testid=mcp-install-field-url'` is `https://mcp.stripe.com/` and `browser enabled` on it is `false`. Click `testid=mcp-install-modal >> testid=mcp-install-submit` with the key empty: `control-openhands browser eval \"document.querySelector('[data-testid=mcp-install-field-api_key]').validationMessage\"` is `Please fill out this field.` and no request is sent. Run `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' rk_test_qa_dummy_000`; `control-openhands browser attr 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' type` is `password` and `control-openhands browser screenshot 'testid=mcp-install-modal' --feature F17.install-remote --name stripe-masked` shows dots. Cancel; never click Install with a dummy key on a hosted server.",
          "ids": [
            "F17.install-remote"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "OAuth entry (`F17.install-oauth`).",
          "body": "Run `control-openhands browser click 'testid=mcp-marketplace-card-granola'` and `control-openhands browser text 'testid=mcp-install-modal >> testid=mcp-install-oauth-info'`: `This server uses OAuth for authentication. Click Install to connect — you will be redirected to authorize access.` and `No API key needed. The server handles the OAuth flow automatically.` The URL field reads `https://mcp.granola.ai/mcp` and there is no `mcp-install-field-api_key`. Cancel; Install would start a real OAuth flow.",
          "ids": [
            "F17.install-oauth"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Entry that cannot be installed (`F17.install-unsupported`).",
          "body": "Run `control-openhands browser click 'testid=mcp-marketplace-card-sentry'`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit'`, then `control-openhands browser network --last 5`, `control-openhands browser count 'testid=mcp-install-modal'` and `control-openhands browser toasts`. Expected: such entries are hidden from the library, or the modal explains that the local backend cannot install them. Today the modal shows only the title, description and docs link, Install sends no request, shows nothing and the modal stays open (fail; `cloudflare-bindings` behaves the same). Cancel.",
          "ids": [
            "F17.install-unsupported"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Install fails, modal stays (`F17.install-error`).",
          "body": "Make the server unable to start: behind the TLS proxy, move the seeded trust settings aside (shell arrange: `mv \"$OH_VERIFY_RUN/private/home/.config/uv/uv.toml\"{,.off}` and `mv \"$OH_VERIFY_RUN/private/home/.npmrc\"{,.off}`) and use an entry uv has never downloaded in this run, such as Fetch (Time succeeds from the uv cache once installed). Run `control-openhands browser click 'testid=mcp-marketplace-card-fetch'` (`browser value 'testid=mcp-install-field-command-readonly'` is `uvx mcp-server-fetch`) and `control-openhands browser click 'testid=mcp-install-submit' --observe 'testid=mcp-install-submit' --observe-ms 15000`; the observed labels are `Install`, `Verifying… [disabled]`, `Install` (about 6 s). `control-openhands browser text 'testid=mcp-install-modal-error'` is `Connection failed: McpError: Connection closed` and the modal stays open (`<run>/private/stack.log` shows `UnknownIssuer`). An npx entry such as Memory fails differently: after about 17 s the error reads `Connection timed out. Check the URL and try again.` Cancel, `control-openhands browser reload`, and `control-openhands browser count 'testid=mcp-installed-empty'` is still `1` (no `fetch` card). Restore the trust files (`mv ….off` back) before the next bullet.",
          "ids": [
            "F17.install-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Install a STDIO entry (`F17.install-stdio`).",
          "body": "With the TLS precondition, run `control-openhands browser click 'testid=mcp-marketplace-card-time'` and `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 20000`: `Install`, `Verifying…`, `Saving...`, then `<absent>` (closed). After `control-openhands browser reload`, `control-openhands browser text 'testid=mcp-server-item >> has-text=Timezone-aware'` is `Time`, `STDIO`, `Timezone-aware current time, conversions, and timestamp formatting.`, `uvx mcp-server-time`, `Not checked yet`, `Test connection`; its `data-server-id` is `time`. Installing it again (same two commands, then `browser reload`) adds a second card (`time_1`): the library is add-only and its + toggle never shows installed (`aria-checked` stays `false`). The copy keeps the Time catalog identity (#18034): `control-openhands browser text '[data-server-id=time_1]'` is `time_1`, `STDIO`, `Timezone-aware current time, conversions, and timestamp formatting.`, `uvx mcp-server-time`, `Not checked yet`, `Test connection` (the title is the settings key because it differs from the catalog id), and `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=mcp-server-item]')].map(c=>c.dataset.serverId+':'+c.querySelector('span[aria-hidden=true][title]')?.title)\"` is `[\"time:Time\",\"time_1:Time\"]` (both badges come from the Time entry). Time has no logo, so both installed cards show the generic puzzle glyph where its library card shows a robot glyph. Search the installed list: `control-openhands browser fill 'testid=mcp-search-input' Timezone` keeps both cards (`browser eval \"[...document.querySelectorAll('[data-testid=mcp-server-item]')].map(c=>c.dataset.serverId)\"` is `[\"time\",\"time_1\"]`, matched by the catalog description) and one library card; `control-openhands browser fill 'testid=mcp-search-input' qa-zzz-nothing` makes `control-openhands browser text 'testid=mcp-installed-empty-search'` `No matches for your search.`; clear with `control-openhands browser click 'testid=mcp-search-clear'`. `control-openhands browser screenshot --feature F17.install-stdio --name time-and-time_1` shows both cards with the description.",
          "ids": [
            "F17.install-stdio"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Argument fields (`F17.install-args`).",
          "body": "Run `control-openhands browser click 'testid=mcp-marketplace-card-filesystem'`; `control-openhands browser snapshot 'testid=mcp-install-modal'` shows the read-only `Command` `npx -y @modelcontextprotocol/server-filesystem`, a `Paths (space separated)` field and `Each whitespace-separated token is appended as its own argument.`; `control-openhands browser attr 'testid=mcp-install-modal >> role=link' target` is `_blank` (**View documentation →**, `rel` `noreferrer`). Click `testid=mcp-install-submit` with the field empty: `control-openhands browser eval \"document.querySelector('[data-testid=mcp-install-field-paths]').validationMessage\"` is `Please fill out this field.` and `browser network` records no request. Shell arrange `mkdir -p \"$OH_VERIFY_RUN/workspace/qa-fs-a\" \"$OH_VERIFY_RUN/workspace/qa-fs-b\"`, then `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-paths' \"$OH_VERIFY_RUN/workspace/qa-fs-a $OH_VERIFY_RUN/workspace/qa-fs-b\"` and `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 90000` (`Install`, `Verifying…`, `Saving...`, `<absent>`). After `browser reload`, `control-openhands browser text '[data-server-id=filesystem]'` ends the command line with both paths, and `control-openhands api GET /api/settings --pick agent_settings.mcp_config.filesystem` has `args` `[\"-y\",\"@modelcontextprotocol/server-filesystem\",\"<…>/qa-fs-a\",\"<…>/qa-fs-b\"]`.",
          "ids": [
            "F17.install-args"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Save as secret (`F17.save-as-secret`).",
          "body": "With the TLS precondition and no `TAVILY_API_KEY` secret (`control-openhands api GET /api/settings/secrets`), run `control-openhands browser click 'testid=mcp-marketplace-card-tavily'`. `control-openhands browser attr 'testid=mcp-install-modal >> testid=mcp-install-field-TAVILY_API_KEY' type` is `password`, `control-openhands browser eval \"document.querySelector('[data-testid=mcp-install-save-secret-TAVILY_API_KEY] input').checked\"` is `true`, and `control-openhands browser tooltip 'testid=mcp-install-modal >> role=button[name=/secret/i]'` is `MCP credentials aren't shared with automations. Save as a secret to make this value available to automations.` Run `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-TAVILY_API_KEY' tvly-qa-dummy-000` and `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'role=status' --observe-ms 60000`; the observed toasts are `MCP server saved.` and `Saved to secrets: TAVILY_API_KEY`. Then `control-openhands browser goto /settings/secrets` and `control-openhands browser count 'testid=secret-item >> has-text=TAVILY_API_KEY'` is `1`. The GitHub card's hosted token has the same toggle (`mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN`, checked). Clean up by deleting the `tavily` server (Delete below) and the secret through F14's Delete recipe.",
          "ids": [
            "F17.save-as-secret"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Editor validation (`F17.custom-validation`).",
          "body": "Run `control-openhands browser goto /mcp` (Save as secret ends on `/settings/secrets`), `control-openhands browser click 'testid=mcp-add-custom-server'`. With the default SSE type, click `testid=add-mcp-server-form >> testid=submit-button` after each change and read `control-openhands browser text 'testid=add-mcp-server-form >> css=p.text-red-500 >> nth=0'`: empty URL → `URL is required`; `control-openhands browser fill 'testid=add-mcp-server-form >> testid=url-input' 'not a url'` → `Invalid URL format`; `ftp://127.0.0.1/mcp` → `URL must use http:// or https://`; URL `http://127.0.0.1:9/sse` plus `testid=server-name-input` `'bad name'` → `Name can only contain letters, numbers, hyphens, and underscores`; name `qa_sse`, then `control-openhands browser click 'testid=add-mcp-server-form >> testid=auth-mode-dropdown'` and `control-openhands browser click 'role=option[name=\"Header\"]'` → `Header authentication requires a header`; auth `OAuth` with `testid=oauth-client-secret-input` filled `dummy-secret` → `OAuth client secret requires a client ID`. Switch type with `control-openhands browser click 'testid=add-mcp-server-form >> testid=server-type-dropdown'` and `control-openhands browser click 'role=option[name=\"SHTTP\"]'`, set auth back to `None`; `testid=timeout-input` `0` → `Timeout must be positive`, `5000` → `Timeout cannot exceed 3600 seconds (1 hour)`. Switch to `STDIO`: empty → `Name is required`; `testid=name-input` `'bad name!'` → the invalid-name message; name `qa_time` → `Command is required`; `testid=command-input` `'uvx mcp-server-time'` → `Command cannot contain spaces`; command `uvx` plus `testid=env-input` `NOEQUALS` → `Environment variables must follow KEY=value format`. Once a STDIO `qa_time` exists (or any installed STDIO id such as `time`), the same name gives `A STDIO server with this name already exists`. `control-openhands browser press Escape` closes the editor. Header format: reopen the editor, pick `SHTTP`, fill `url-input` `http://127.0.0.1:9/mcp`, auth `Header`, `control-openhands browser fill 'testid=add-mcp-server-form >> testid=headers-input' NOEQUALS` and submit. Expected a header-specific message; today it reads `Environment variables must follow KEY=value format` (fail: the header check reuses the env validator's copy).",
          "ids": [
            "F17.custom-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Test connection (`F17.test-connection`).",
          "body": "Failure: in an SHTTP form with URL `http://127.0.0.1:9/mcp` and timeout `10`, click `testid=add-mcp-server-form >> testid=mcp-test-connection`, `control-openhands browser wait 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000`; the text is `Could not reach the server (check the URL and server type): Client failed to connect: All connection attempts failed` (the remote wording followed by the Agent Server's detail) and its class contains `text-red-500`. Clicking **Add Server** leaves the editor open (`browser count 'testid=mcp-custom-editor'` is `1`). Success: in a STDIO form fill `testid=name-input` `qa_time`, `testid=command-input` `uvx`, `control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' mcp-server-time` and `control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' UV_NATIVE_TLS=true`, then `control-openhands browser click 'testid=add-mcp-server-form >> testid=mcp-test-connection' --observe 'testid=add-mcp-server-form >> testid=mcp-test-connection' --observe-ms 20000` (`Test connection`, `Verifying…`, `Test connection`), the same `wait` and `control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message'`: `Connected — 2 tool(s) available`, class `text-green-500`. `control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.test-connection --name stdio-success`.",
          "ids": [
            "F17.test-connection"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Add the server (`F17.custom-add`).",
          "body": "In the successful STDIO form run `control-openhands browser click 'testid=add-mcp-server-form >> testid=submit-button'` and `control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000`, then `control-openhands browser reload` and `control-openhands browser text 'testid=mcp-server-item >> has-text=qa_time'`: `qa_time`, `STDIO`, `Timezone-aware current time, conversions, and timestamp formatting.`, `uvx mcp-server-time`, `Not checked yet`, `Test connection` (it runs the Time entry's command and args, so it shows that entry's description and badge under its own name, #18034). `control-openhands browser attr 'testid=mcp-server-item >> has-text=qa_time' data-server-id` is `qa_time` (the id in the per-card test ids below) and its `aria-label` is `Edit qa_time`.",
          "ids": [
            "F17.custom-add"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Custom remote server (`F17.custom-add-remote`).",
          "body": "With the Streamable HTTP fixture from Preconditions running, run `control-openhands browser click 'testid=mcp-add-custom-server'`, pick `SHTTP` (`server-type-dropdown`, then `role=option[name=\"SHTTP\"]`), fill `testid=add-mcp-server-form >> testid=server-name-input` `qa_shttp` and `url-input` `http://127.0.0.1:38417/mcp`, pick auth `Bearer token` (`auth-mode-dropdown`, then `role=option[name=\"Bearer token\"]`) and fill `testid=add-mcp-server-form >> testid=api-key-input` `qa-dummy-token` (`browser attr ... type` is `password`). Click `testid=add-mcp-server-form >> testid=mcp-test-connection`; `control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000` is `Connected — 13 tool(s) available`. Click `testid=add-mcp-server-form >> testid=submit-button`, wait for `testid=mcp-custom-editor` to detach and `browser reload`: `control-openhands browser text '[data-server-id=qa_shttp]'` is `qa_shttp`, `HTTP`, `http://127.0.0.1:38417/mcp`, `Not checked yet`, `Test connection`; `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_shttp` has `transport` `http` and `auth` `{\"strategy\":\"bearer\",\"value\":\"**********\"}`. Its health probe reads `Reachable — credentials not verified`. Reopen it with `control-openhands browser click 'testid=mcp-server-detail-qa_shttp'`: `browser snapshot 'testid=mcp-custom-editor'` shows `Edit MCP server`, combobox `Authentication` `Bearer token`, and `control-openhands browser value 'testid=edit-mcp-server-form >> testid=api-key-input'` is `**********`; Escape. Agent side: `control-openhands conversation start --prompt \"Call the qa_shttp MCP server's echo tool with message qa-ping. Do not use the terminal. Reply with only the tool's output.\" --wait --timeout 240`, then `control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent`: tool `qa_shttp_echo`, observation `Echo: qa-ping`. Run `control-openhands browser goto /mcp` afterwards.",
          "ids": [
            "F17.custom-add-remote"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Card health (`F17.server-health`).",
          "body": "Run `control-openhands browser click 'testid=mcp-health-probe-qa_time' --observe 'testid=mcp-health-label-qa_time' --observe-ms 20000`: `Not checked yet`, `Checking connection…`, `Reachable — credentials not verified`. `control-openhands browser text 'testid=mcp-server-health-qa_time'` adds the hint `This check only proves the server is reachable and lists its tools; it does not verify your credentials.`, `control-openhands browser attr 'testid=mcp-server-health-qa_time >> testid=mcp-health-dot' data-status` is `healthy-connectivity`, and the click did not open the editor (`browser count 'testid=mcp-custom-editor'` is `0`). Failure: the editor refuses to save a broken server, so arrange one with `control-openhands api POST /api/settings/mcp/qa_broken --data '{\"transport\":\"stdio\",\"command\":\"qa-no-such-command\",\"enabled\":false}' --write`, `control-openhands browser reload`, then click `testid=mcp-health-probe-qa_broken`: the label is `Could not start the server command: Client failed to connect: [Errno 2] No such file or directory: 'qa-no-such-command'` in red (STDIO wording, no URL hint; the card clamps it to two lines, `browser text 'testid=mcp-server-health-qa_broken'` reads it whole), the dot's `data-status` is `failed` and the button reads `Retry`; `control-openhands browser screenshot 'testid=mcp-server-item >> has-text=qa_broken' --feature F17.server-health --name failed`.",
          "ids": [
            "F17.server-health"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Delete (`F17.delete-server`).",
          "body": "Run `control-openhands browser click 'testid=mcp-server-detail-qa_broken'` (a click on the card's centre lands on its health row and opens nothing: see Gotchas), `control-openhands browser click 'testid=mcp-custom-editor-delete'` and `control-openhands browser text 'testid=confirmation-modal'` (`Are you sure you want to delete this server?`, `Cancel`, `Confirm`). Run `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'`; the editor is still open. Click Delete again, then `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'` and `control-openhands browser toasts` (`MCP server removed.`). After `control-openhands browser reload`, `control-openhands browser count 'testid=mcp-server-item >> has-text=qa_broken'` is `0`, and `control-openhands api GET /api/settings` has no `qa_broken`.",
          "ids": [
            "F17.delete-server"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Edit (`F17.custom-edit`).",
          "body": "Run `control-openhands browser click 'testid=mcp-server-detail-qa_time'` (the detail line opens the editor on every card; a plain click on the card's centre happens to open it on `qa_time`, whose Time description fills the centre, but not on a card without a description; keyboard: `browser focus '[data-server-id=qa_time]'` then `browser press Enter`) and `control-openhands browser snapshot 'testid=mcp-custom-editor'`: heading `Edit MCP server`, no Server Type field, a `Delete` button; `control-openhands browser value 'testid=edit-mcp-server-form >> testid=env-input'` is `UV_NATIVE_TLS=**********`. Run `control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'mcp-server-time\\n--local-timezone\\nUTC'` (bash ANSI-C quoting for the newlines), click `testid=edit-mcp-server-form >> testid=mcp-test-connection` (still `Connected — 2 tool(s) available`: the stored env value is reused), then `control-openhands browser click 'testid=edit-mcp-server-form >> testid=submit-button'` and wait for `testid=mcp-custom-editor` to detach. The card shows a verdict at once (seeded from the save's test). After `control-openhands browser reload`, `control-openhands browser text 'testid=mcp-server-detail-qa_time'` is `uvx mcp-server-time --local-timezone UTC`.",
          "ids": [
            "F17.custom-edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Stored secrets are reused, not their placeholder (`F17.stored-secret-reuse`).",
          "body": "Make a STDIO server that only starts with the right secret: `control-openhands fixture mcp-server --name qa-vault` prints `<node>` and `<server.mjs>`. Run `control-openhands browser click 'testid=mcp-add-custom-server'`, pick `STDIO`, fill `testid=add-mcp-server-form >> testid=name-input` `qa_vault` and `testid=add-mcp-server-form >> testid=command-input` `/bin/sh`, then `control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' $'-c\\ntest \"$QA_VAULT_TOKEN\" = qa-vault-secret-000 && exec \"$QA_NODE\" \"$QA_SERVER\"'` and `control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' $'QA_VAULT_TOKEN=qa-vault-secret-000\\nQA_NODE=<node>\\nQA_SERVER=<server.mjs>'`. Run `control-openhands browser network --clear`, click `testid=add-mcp-server-form >> testid=mcp-test-connection`; `control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000` is `Connected — 1 tool(s) available`. Direct proof of what the page sent: `control-openhands browser network --bodies --filter 'api/mcp/test' --last 1` lists one `POST /api/mcp/test` (`200`) whose `body` is `{\"server\":{\"type\":\"stdio\",\"command\":\"/bin/sh\",\"args\":[\"-c\",\"test \\\"$QA_VAULT_TOKEN\\\" = qa-vault-secret-000 && exec \\\"$QA_NODE\\\" \\\"$QA_SERVER\\\"\"],\"env\":{\"QA_VAULT_TOKEN\":\"<redacted 19 chars>\",\"QA_NODE\":\"<redacted 40 chars>\",\"QA_SERVER\":\"<redacted 142 chars>\"}},\"name\":\"qa_vault\"}`: the command and args in clear, each env value replaced by the CLI with the length of the real value (19 for `qa-vault-secret-000`; 40 and 142 are `<node>` and `<server.mjs>` on this machine, `${#var}` in a shell). Click `testid=add-mcp-server-form >> testid=submit-button`, `control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000` and `control-openhands browser reload`. `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault` has `env` `{\"QA_VAULT_TOKEN\":\"**********\",\"QA_NODE\":\"**********\",\"QA_SERVER\":\"**********\"}` (every env value is redacted; the page reads this same endpoint, there is no `GET /api/settings/mcp`). Run `control-openhands browser click 'testid=mcp-server-detail-qa_vault'`; `control-openhands browser value 'testid=edit-mcp-server-form >> testid=env-input'` is the three lines `QA_VAULT_TOKEN=**********`, `QA_NODE=**********`, `QA_SERVER=**********`. Run `control-openhands browser network --clear` and click `testid=edit-mcp-server-form >> testid=mcp-test-connection`: `control-openhands browser text 'testid=edit-mcp-server-form >> testid=mcp-test-message' --timeout 60000` is `Connected — 1 tool(s) available`, which the shell gate only allows when the stored token, binary and path were sent (`control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.stored-secret-reuse --name masked-test-ok`). The same `control-openhands browser network --bodies --filter 'api/mcp/test' --last 1` row now carries `\"env\":{\"QA_VAULT_TOKEN\":\"<redacted 120 chars>\",\"QA_NODE\":\"<redacted 140 chars>\",\"QA_SERVER\":\"<redacted 268 chars>\"}`: neither the `**********` placeholder (which `--bodies` would keep as is) nor the typed lengths. `control-openhands browser network --last 5` shows why: a `GET /api/settings` (and `/server_info`) right before the POST. The page fetches the settings in `encrypted` mode and swaps each placeholder for the stored *encrypted* value (`src/api/mcp-service/mcp-redacted-credentials.ts`), which the Agent Server decrypts, so the browser never holds the plaintext and the lengths are those of Fernet tokens of the real values (19 → 120, 40 → 140, 142 → 268). Counter-check that the gate bites: `control-openhands browser fill 'testid=edit-mcp-server-form >> testid=env-input' $'QA_VAULT_TOKEN=qa-wrong\\nQA_NODE=**********\\nQA_SERVER=**********'`, `control-openhands browser network --clear`, click Test connection again and `control-openhands browser wait 'testid=edit-mcp-server-form >> testid=mcp-test-message >> text=Connection' --timeout 90000`; the text is red and reads `Connection failed: McpError: Connection closed` or, when the client only notices at its deadline, `Connection timed out. Check the URL and try again.` (the gate exits before the MCP handshake; see Gotchas; `--name wrong-token`), and the `--bodies` row shows `\"QA_VAULT_TOKEN\":\"<redacted 8 chars>\"` next to the two kept placeholders at `140` and `268` chars: only the retyped value replaced its stored one. `control-openhands browser click 'testid=mcp-custom-editor-close'` discards it (`count 'testid=mcp-custom-editor'` `0`); reopen with `testid=mcp-server-detail-qa_vault`: the env is the three placeholders again and Test connection is `Connected — 1 tool(s) available`. `control-openhands browser press Escape`. Save with the placeholders in place: reopen with `control-openhands browser click 'testid=mcp-server-detail-qa_vault'`, append one harmless argument (it only becomes the shell script's `$0`) with `control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'-c\\ntest \"$QA_VAULT_TOKEN\" = qa-vault-secret-000 && exec \"$QA_NODE\" \"$QA_SERVER\"\\nqa-v2'`, leave `env-input` as the three placeholders, run `control-openhands browser network --clear`, then `control-openhands browser click 'testid=edit-mcp-server-form >> testid=submit-button' --observe 'testid=edit-mcp-server-form >> testid=submit-button' --observe-ms 6000` (`Save Server`, `Save Server [disabled]`, `<absent>`) and `control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000`; `control-openhands browser network --bodies --filter 'api/settings/mcp' --last 3` lists one `PATCH /api/settings/mcp/qa_vault` (`200`) whose `body` is `{\"transport\":\"stdio\",\"command\":\"/bin/sh\",\"args\":[\"-c\",\"test \\\"$QA_VAULT_TOKEN\\\" = qa-vault-secret-000 && exec \\\"$QA_NODE\\\" \\\"$QA_SERVER\\\"\",\"qa-v2\"]}`: a sparse patch with no `env` key at all (untouched secrets are left out, so the server keeps its stored values; neither the placeholder nor an encrypted value travels). The same `control-openhands browser network --filter 'api/settings/mcp' --last 3` without `--bodies` lists that row without a `body` field. The card's health label reads `Reachable — credentials not verified`. After `control-openhands browser reload`, `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault.env` is still `{\"QA_VAULT_TOKEN\":\"**********\",\"QA_NODE\":\"**********\",\"QA_SERVER\":\"**********\"}`, `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault.args` ends with `qa-v2`, and `control-openhands browser text 'testid=mcp-server-detail-qa_vault'` ends with `\"$QA_SERVER\" qa-v2`. Reopen with `control-openhands browser click 'testid=mcp-server-detail-qa_vault'` (the env is the three placeholders), run `control-openhands browser network --clear` and click `testid=edit-mcp-server-form >> testid=mcp-test-connection`: `control-openhands browser text 'testid=edit-mcp-server-form >> testid=mcp-test-message' --timeout 60000` is `Connected — 1 tool(s) available` again, which the gate only allows if the Save kept the real token, binary and path rather than storing the placeholder text (`--name saved-with-placeholders`), and the `--bodies` row carries `qa-v2` in `args` with the three encrypted lengths (`120`, `140`, `268`) in `env`. `control-openhands browser press Escape`.",
          "ids": [
            "F17.stored-secret-reuse"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "One request per mutation (`F17.single-request-mutations`).",
          "body": "With `qa_vault` as the sibling, run `control-openhands browser network --clear`, `control-openhands browser click 'testid=mcp-add-custom-server'`, pick `STDIO`, fill `testid=add-mcp-server-form >> testid=name-input` `qa_docs`, `testid=add-mcp-server-form >> testid=command-input` `<node>` and `testid=add-mcp-server-form >> testid=args-input` `<server.mjs>`, click `testid=add-mcp-server-form >> testid=submit-button` and `control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000`. `control-openhands browser network --filter '/api/settings/mcp'` has `total` `1`, a `POST /api/settings/mcp/qa_docs` with status `201`, and `control-openhands browser network --filter qa_vault` has `total` `0`. Edit: `control-openhands browser network --clear`, `control-openhands browser click 'testid=mcp-server-detail-qa_docs'`, `control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'<server.mjs>\\n--qa-v2'`, click `testid=edit-mcp-server-form >> testid=submit-button` and the same `wait`; the filter now lists one `PATCH /api/settings/mcp/qa_docs` (`200`) and `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_docs.args` ends with `--qa-v2`. Delete: `control-openhands browser network --clear`, `control-openhands browser click 'testid=mcp-server-detail-qa_docs'`, `control-openhands browser click 'testid=mcp-custom-editor-delete'`, `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'` and `control-openhands browser wait '[data-server-id=qa_docs]' --state detached --timeout 10000`; the filter lists one `DELETE /api/settings/mcp/qa_docs` (`200`). Sibling: `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault` is unchanged from the end of the previous bullet (command `/bin/sh`, args still ending with `qa-v2`, the three `**********` env values, `enabled` `true`), and after `control-openhands browser click 'testid=mcp-server-detail-qa_vault'` its Test connection still reads `Connected — 1 tool(s) available`: the stored secrets survived the sibling's writes. `control-openhands browser press Escape`.",
          "ids": [
            "F17.single-request-mutations"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Credential probe (`F17.credential-probe`).",
          "body": "GitHub (hosted): run `control-openhands browser click 'testid=mcp-marketplace-card-github'`, `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' ghp_qaInvalid000`, `control-openhands browser network --clear`, then `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 20000` (`Install`, `Verifying…`, `Install`) and `control-openhands browser text 'testid=mcp-install-modal-error'`. The server refuses the token before any tool is listed, so the probe never runs and the text is `Connection failed: HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.githubcopilot.com/mcp/' …`; the modal stays open (`control-openhands browser count 'testid=mcp-install-modal'` is `1`), `control-openhands browser network --filter 'api/(mcp|settings)'` shows one `POST /api/mcp/test` and no `/api/settings/mcp` write, `control-openhands api GET /api/settings --pick agent_settings.mcp_config.github` has no value and `control-openhands api GET /api/settings/secrets` lists no `GITHUB_PERSONAL_ACCESS_TOKEN` (`control-openhands browser screenshot 'testid=mcp-install-modal' --feature F17.credential-probe --name github-invalid`); `control-openhands browser click 'testid=mcp-install-cancel'`. Slack (stdio; it lists 8 tools with any token, so only the probe, a channel listing, can tell a bad token): `control-openhands browser click 'testid=mcp-marketplace-card-slack'`, `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-SLACK_TEAM_ID' T_QA_INVALID`, `control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-SLACK_BOT_TOKEN' xoxb-qa-invalid-000` (`type` `password`), `control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit'` and `control-openhands browser wait 'testid=mcp-install-modal-error' --timeout 150000` (the first run downloads the package through npx, about a minute: a plain click, then one long `wait`, rather than `--observe-ms`, which records a few transient states). `control-openhands browser text 'testid=mcp-install-modal-error'` is `Credential check failed: invalid_auth` with direct internet; behind the TLS-intercepting proxy the probe's own HTTPS call fails first and it reads `Credential check failed: fetch failed`. Either way the modal stays open and `control-openhands api GET /api/settings --pick agent_settings.mcp_config.slack` has no value (`--name slack-invalid`); `control-openhands browser click 'testid=mcp-install-cancel'`. For Slack's own verdict behind the proxy, run the same command as a custom server, which keeps the entry's probe (#18034): `control-openhands browser click 'testid=mcp-add-custom-server'`, pick `STDIO`, fill `testid=add-mcp-server-form >> testid=name-input` `qa_slack`, `testid=add-mcp-server-form >> testid=command-input` `npx`, `control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' $'-y\\n@zencoderai/slack-mcp-server'` and `control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' $'SLACK_TEAM_ID=T_QA_INVALID\\nSLACK_BOT_TOKEN=xoxb-qa-invalid-000\\nNODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt'` (your proxy's CA bundle), then `control-openhands browser click 'testid=add-mcp-server-form >> testid=mcp-test-connection'` and `control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 90000`: `Credential check failed: invalid_auth`, class `text-red-500` (`control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.credential-probe --name slack-custom-invalid-auth`). `control-openhands browser click 'testid=add-mcp-server-form >> testid=submit-button' --observe 'testid=add-mcp-server-form >> testid=submit-button' --observe-ms 20000` (`Add Server`, `Add Server [disabled]`, `Add Server`: Save tests first) keeps the editor open (`control-openhands browser count 'testid=mcp-custom-editor'` is `1`) with the same red message, and `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_slack` has no value; close it with `control-openhands browser click 'testid=mcp-custom-editor-close'` (an Escape pressed while the Save's test still runs is ignored). A server the catalog does not know gets no probe: `control-openhands browser click 'testid=mcp-health-probe-qa_vault' --observe 'testid=mcp-health-label-qa_vault' --observe-ms 20000` ends on `Reachable — credentials not verified` (the Card health wording).",
          "ids": [
            "F17.credential-probe"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "The agent uses it (`F17.agent-uses-server`).",
          "body": "With `qa_time` enabled run `control-openhands conversation start --prompt \"Use the qa_time MCP server's get_current_time tool with timezone UTC. Do not use the terminal. Reply with only the tool's datetime value.\" --wait --timeout 240`, then `control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent` with the `id` it printed. There is an `ActionEvent` with tool `qa_time_get_current_time` (MCP tools reach the agent as `<server id>_<tool>`), its observation reads `[Tool 'qa_time_get_current_time' executed.] { \"timezone\": \"UTC\", \"datetime\": … }`, and the agent's reply is that `datetime`. `control-openhands conversation events <id> --grep qa_time_get_current_time --from-start` also matches the `SystemPromptEvent` (the tool was offered). `control-openhands browser screenshot --feature F17.agent-uses-server --name conversation` shows the tool step (its title is model-written, e.g. `Get current time in UTC via qa_time MCP`) and the datetime reply. `conversation start` leaves the browser on the conversation: `control-openhands browser goto /mcp` next.",
          "ids": [
            "F17.agent-uses-server"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Enable and disable (`F17.enable-disable`).",
          "body": "On `/mcp` run `control-openhands browser attr 'testid=mcp-installed-toggle-qa_time' aria-checked` (`true`; `aria-label` `Disable server`), `control-openhands browser click 'testid=mcp-installed-toggle-qa_time'`, `control-openhands browser reload` and the same `attr` (`false`; label `Enable server`). `control-openhands api GET /api/settings` shows `agent_settings.mcp_config.qa_time.enabled` `false` with command, args and env intact; `control-openhands browser screenshot 'testid=mcp-server-item >> has-text=qa_time' --feature F17.enable-disable --name disabled` shows the + icon. With no other server exposing that tool, run `control-openhands conversation start --prompt \"Without running any command: is a tool named get_current_time available to you right now? Reply with only YES or NO.\" --wait --timeout 240`; the reply is `NO`, `control-openhands api GET /api/conversations/<id> --pick agent.mcp_config.qa_time.enabled` is `false`, and `control-openhands conversation events <id> --grep qa_time_get_current_time --from-start` has `count` `0` (no `SystemPromptEvent` lists it). Then `control-openhands browser goto /mcp`. Click the toggle again and reload; it is `true`.",
          "ids": [
            "F17.enable-disable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Phone modals (`F17.phone`).",
          "body": "At `browser viewport phone` on `/mcp` run `control-openhands browser click 'testid=mcp-add-custom-server'`, `control-openhands browser bbox 'testid=mcp-custom-editor'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F17.phone --name custom-editor`; close with `testid=mcp-custom-editor-close`. Do the same for `testid=mcp-marketplace-card-stripe` with `testid=mcp-install-modal` (`--name install-modal`), then `control-openhands browser viewport desktop`.",
          "ids": [
            "F17.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Cloud backend (`F17.cloud-nav`, `F17.native-git-tabs`).",
          "body": "Blocked without an OpenHands Cloud backend. On the local backend `control-openhands browser click 'testid=mcp-marketplace-card-github'` shows only the MCP form (`browser count 'testid=mcp-install-tab-native'` is `0`). With Cloud active, `browser text 'testid=extensions-navbar-desktop'` should list only MCP Servers and the cloud Skills link (`browser attr 'testid=sidebar-extensions-/skills' href` ends in `/settings/skills`), and the GitHub modal should show `mcp-install-tab-native` (Recommended) and `mcp-install-tab-mcp`.",
          "ids": [
            "F17.cloud-nav",
            "F17.native-git-tabs"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Cleanup.",
          "body": "On `/mcp` (not the conversation page `conversation start` left open), delete `qa_time`, `qa_shttp`, `qa_vault` and any `time`, `time_1`, `tavily`, `filesystem` cards through the editor's Delete: for each id, `browser focus '[data-server-id=<id>]'`, `browser press Enter`, `browser click 'testid=mcp-custom-editor-delete'`, `browser click 'testid=confirmation-modal >> testid=confirm-button'`, `browser wait '[data-server-id=<id>]' --state detached --timeout 10000`. After `browser reload`, `browser count 'testid=mcp-installed-empty'` is `1` and `api GET /api/settings --pick agent_settings.mcp_config` is `{}`. Delete the `TAVILY_API_KEY` secret on `/settings/secrets` (F14: `testid=secret-item >> has-text=TAVILY_API_KEY >> testid=delete-secret-button`, then `testid=confirmation-modal >> testid=confirm-button`), stop the Streamable HTTP fixture by its PID, and restore `deepseek-flash` as the active profile if you changed it.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F18",
      "title": "Skills catalog",
      "file": "F18-skills.md",
      "page": "F18-skills.html",
      "sha256": "1a113e5b7f0b7e3a204d410231d8007aa075f2fc98a94c9cb8f8151a5ec46e8c",
      "behaviors": [
        {
          "id": "F18.page",
          "description": "the page shows the title, description, the notice \"Skill changes apply to new conversations only.\", an **Add skill** button, a result count, the facet rail and the card grid. (Empty state \"No skills found.\" and loading skeletons are not reachable on a local backend.)"
        },
        {
          "id": "F18.search",
          "description": "typing filters cards by name, description, content or trigger; after 300 ms the query is mirrored to `?q=` without a new history entry; the X clears it; no match shows \"No skills match your search.\""
        },
        {
          "id": "F18.search-history",
          "description": "browser Back/Forward put the matching query back into the search box."
        },
        {
          "id": "F18.facets",
          "description": "the desktop facet rail (State, Recommendation, Source, Category, Type) filters with per-row counts, writes canonical URL params, disables zero-count rows and offers **Clear filters**; deep links apply and unknown values are dropped."
        },
        {
          "id": "F18.filters-modal",
          "description": "below 768 px the rail is replaced by a **Filters** button with an active-count badge that opens the same facets in a modal with Clear filters and Close."
        },
        {
          "id": "F18.card",
          "description": "each card shows icon, name, source, a two-line description and pills; extra pills collapse into a `+N` popover; click, Enter or Space opens the detail modal."
        },
        {
          "id": "F18.copy-source",
          "description": "personal and project skills (path sources) have a copy-path button on the card and in the modal that flips to \"Copied to clipboard\" for 2 s; built-in skills (source `public`) have none."
        },
        {
          "id": "F18.toggle",
          "description": "the card's plus/check toggle switches a built-in skill on or off; the choice survives a reload (`enabled_skills` allow-list on a local backend)."
        },
        {
          "id": "F18.toggle-local",
          "description": "switching a personal/project skill off writes `disabled_skills`; new conversations then do not load it, and do again once it is back on."
        },
        {
          "id": "F18.toggle-error",
          "description": "a failed save shows an error toast and nothing is persisted."
        },
        {
          "id": "F18.skill-in-chat",
          "description": "an enabled skill is loaded into new conversations and fires on its trigger; a disabled one is not."
        },
        {
          "id": "F18.project-skill",
          "description": "a skill committed under `<workspace>/.agents/skills/<name>/SKILL.md` is loaded into every conversation started in that workspace, in Local Repo and New Worktree mode alike, and its trigger word activates it (the chat shows a **Skill Ready** row naming it); workspace skills are not listed on the Skills page."
        },
        {
          "id": "F18.skill-deleted",
          "description": "a personal skill whose folder was deleted disappears from the Skills page after a reload and is not loaded into new conversations: its trigger word activates nothing."
        },
        {
          "id": "F18.personal-skill-dirs",
          "description": "personal skills load from both user folders: `~/.agents/skills/<name>/SKILL.md` and the persistence folder's `skills/<name>/SKILL.md` (`~/.openhands/skills`, which a Canvas stack moves to `$OH_PERSISTENCE_DIR/skills`). Each shows on the Skills page with its path and fires on its trigger in new conversations."
        },
        {
          "id": "F18.detail-modal",
          "description": "the detail modal shows source, an Enabled/Disabled switch, description, pills and the read-only content; switching off disables **Use skill**."
        },
        {
          "id": "F18.detail-close",
          "description": "the X, **Close**, Escape and a backdrop click close the detail modal."
        },
        {
          "id": "F18.detail-pills",
          "description": "the modal shows the full pill set (type, category, recommended, license, compatibility, version, triggers); pills that do not fit collapse into a `+N` button whose popover lists the rest without closing the modal."
        },
        {
          "id": "F18.use-skill",
          "description": "**Use skill** closes the modal, opens `/conversations` and pre-fills the composer with `/<skill-name> `."
        },
        {
          "id": "F18.add-skill-modal",
          "description": "**Add skill** opens an instructions-only modal with a copyable `/add-skill` example (which must point at an existing skill), steps, URL formats, storage notes and a docs link; Close, X and Escape close it."
        },
        {
          "id": "F18.install-banner",
          "description": "after the agent installs a skill with `/add-skill`, the conversation shows \"Installed to this workspace: …\" with **Start new conversation with this skill** (same workspace) and a dismiss X (session-only)."
        },
        {
          "id": "F18.phone",
          "description": "at 390 px the page, cards and both modals fit without horizontal overflow; `/customize` shows a hub that links to Skills and the header back button returns to it."
        },
        {
          "id": "F18.cloud-link",
          "description": "with a Cloud backend the Customize **Skills** item opens the Cloud skills page in a new tab instead of `/skills` (blocked here)."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open from the sidebar (`F18.page`).",
          "body": "From `/` run `control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/mcp(\\?|$)'`, `control-openhands browser click 'testid=sidebar-extensions-/skills' --expect-url '/skills(\\?|$)'`, then `control-openhands browser text 'testid=skills-settings-description'` (`Discover skills to add to your workspace. Search above the cards or filter by state, recommendation, and category, then open a card to see its details and use the skill. Enable or disable default skills. Disabled skills will not be loaded into agent context.`, describing this layout since #18035), `control-openhands browser text 'testid=skills-new-conversation-notice'` (`Skill changes apply to new conversations only.`) and `control-openhands browser text 'testid=skills-result-summary'` (`<N> result(s)` on a fresh run). `control-openhands browser screenshot --feature F18.page --name desktop` shows the Customize nav, the facet rail and the card grid.",
          "ids": [
            "F18.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command-menu entry (`F18.page`).",
          "body": "From `/` run `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Skills`, `control-openhands browser snapshot 'testid=command-menu'` (one option, `Customize Browse skills, plugins, and integrations. Go`), `control-openhands browser press Enter`, `control-openhands browser wait-url '/mcp(\\?|$)'`, then `control-openhands browser click 'testid=sidebar-extensions-/skills' --expect-url '/skills(\\?|$)'`.",
          "ids": [
            "F18.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Search (`F18.search`).",
          "body": "On `/skills` run `control-openhands browser fill 'testid=skills-search-input' docker`, `control-openhands browser text 'testid=skills-result-summary'` (`6 result(s)` on a fresh run: content matches count too) and, a second later, `control-openhands browser url` (ends in `/skills?q=docker`); `control-openhands browser eval \"history.length\"` is the same before and after typing. Then `control-openhands browser fill 'testid=skills-search-input' zzqa-nomatch` and `control-openhands browser text 'testid=skills-no-match'`: `No skills match your search.` with `0 result(s)`. Clear with `control-openhands browser click 'testid=skills-toolbar >> role=button[name=\"Clear search\"]'`; `control-openhands browser value 'testid=skills-search-input'` is empty, the full count is back and `?q=` leaves the URL.",
          "ids": [
            "F18.search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Back and forward (`F18.search-history`).",
          "body": "Run `control-openhands browser goto '/skills?q=docker'`, `control-openhands browser click 'testid=skill-facet-state-enabled'` (pushes `?q=docker&state=enabled`), `control-openhands browser fill 'testid=skills-search-input' github`, wait a second, then `control-openhands browser back` and `control-openhands browser value 'testid=skills-search-input'`: `docker`, URL `/skills?q=docker`. `control-openhands browser forward` gives `github` and `/skills?q=github&state=enabled`.",
          "ids": [
            "F18.search-history"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Facets (`F18.facets`).",
          "body": "On `/skills` run `control-openhands browser click 'testid=skill-facet-recommendation-recommended'` (URL `?recommendation=recommended`, `12 result(s)`), `control-openhands browser click 'testid=skill-facet-state-enabled'` (URL `?state=enabled&recommendation=recommended`: canonical order, not click order), `control-openhands browser attr 'testid=skill-facet-state-enabled' aria-checked` (`true`), `control-openhands browser enabled 'testid=skill-facet-state-disabled'` (`false`, its count reads `0`), `control-openhands browser click 'testid=skill-facet-category-agent-authoring'` (`8 result(s)`), then `control-openhands browser click 'testid=skills-clear-filters'`: URL `/skills`, all results, and `control-openhands browser count 'testid=skills-clear-filters'` is `0`.",
          "ids": [
            "F18.facets"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Source and Type facets (`F18.facets`).",
          "body": "With the `qa-hello` fixture, `control-openhands browser text 'testid=skill-facet-group-source'` lists `This project 1` and `Built in <N>`, and `testid=skill-facet-group-type` lists `Auto-discovery 1` and `Trigger-based <N>`. `control-openhands browser click 'testid=skill-facet-source-project'` gives `?source=project` and `1 result(s)`; after Clear filters, `control-openhands browser click 'testid=skill-facet-type-agentskills'` gives `?type=agentskills` and only `qa-hello` (`control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=skill-name-]')].map(e=>e.textContent).join(',')\"`). Clear filters again.",
          "ids": [
            "F18.facets"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Deep link (`F18.facets`).",
          "body": "Run `control-openhands browser goto '/skills?category=bogus&type=knowledge&source=project'` on a fresh run, `control-openhands browser text 'testid=skills-result-summary'` (`0 result(s)`) and `control-openhands browser text 'testid=skill-facet-group-source'`: `SOURCE`, `This project 0`, `Built in <N>`. A URL selection keeps its group visible even when it matches nothing, and `bogus` is ignored. Clear with `testid=skills-clear-filters`.",
          "ids": [
            "F18.facets"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Card and pill overflow (`F18.card`).",
          "body": "Run `control-openhands browser testids 'testid=skill-card-add-javadoc'` (name, `skill-source-add-javadoc` = `public`, `skill-toggle-add-javadoc`, description, pills, `skill-triggers-add-javadoc-overflow` labelled `Show 2 more`), `control-openhands browser click 'testid=skill-triggers-add-javadoc-overflow'` and `control-openhands browser text 'testid=skill-triggers-add-javadoc-overflow-popover'` (`java documentation`, `document java`); `control-openhands browser count 'testid=skill-detail-modal'` stays `0`. `control-openhands browser screenshot --feature F18.card --name overflow-popover`, then `control-openhands browser press Escape`.",
          "ids": [
            "F18.card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Keyboard open (`F18.card`).",
          "body": "Run `control-openhands browser focus 'testid=skill-card-docker'`, `control-openhands browser press Enter` and `control-openhands browser attr 'testid=skill-detail-modal' data-skill-name` (`docker`); `control-openhands browser press Escape`, focus again, `control-openhands browser press Space` and `control-openhands browser count 'testid=skill-detail-modal'` (`1`). Escape closes it.",
          "ids": [
            "F18.card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Copy the source path (`F18.copy-source`).",
          "body": "With the `qa-hello` fixture, `control-openhands browser attr 'testid=skill-source-qa-hello' title` is the full `…/.agents/skills/qa-hello/SKILL.md` path. Run `control-openhands browser click 'testid=skill-copy-source-qa-hello'`, `control-openhands browser attr 'testid=skill-copy-source-qa-hello' aria-label` (`Copied to clipboard`; `count 'testid=skill-detail-modal'` stays `0`), `control-openhands browser clipboard` (`text` is the same full `SKILL.md` path) and, after 2 s, the same `attr` (`Copy source path`). In the modal (`control-openhands browser click 'testid=skill-card-qa-hello'`) `control-openhands browser click 'testid=skill-modal-copy-source-qa-hello'` turns its label to `Copied to clipboard` and the modal stays open. `count 'testid=skill-copy-source-add-javadoc'` is `0`.",
          "ids": [
            "F18.copy-source"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Toggle a built-in skill (`F18.toggle`).",
          "body": "Run `control-openhands browser attr 'testid=skill-toggle-add-javadoc' aria-checked` (`false`; `aria-label` is `Enable skill`), `control-openhands browser click 'testid=skill-toggle-add-javadoc'` (no modal opens; `aria-checked` turns `true` and the Enabled facet count goes up by one), wait a second, `control-openhands browser reload` and read `aria-checked` again: `true`. `control-openhands api GET /api/settings --pick misc_settings.app_preferences.enabled_skills` lists `add-javadoc` (`--pick` paths start inside `body`). To switch an enabled skill off from its card, `control-openhands browser hover 'testid=skill-toggle-add-javadoc'` first (`attr ... data-showing-remove` is `true`), then click (see Gotchas).",
          "ids": [
            "F18.toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Toggle a personal skill (`F18.toggle-local`).",
          "body": "Run `control-openhands browser hover 'testid=skill-toggle-qa-hello'`, `control-openhands browser click 'testid=skill-toggle-qa-hello'`, wait a second, `control-openhands browser reload` and `control-openhands browser attr 'testid=skill-toggle-qa-hello' aria-checked` (`false`). `control-openhands api GET /api/settings` shows `disabled_skills: [\"qa-hello\"]` under `body.misc_settings.app_preferences` and `enabled_skills` without it. `control-openhands conversation start --prompt \"qa-ping\" --wait --timeout 240`, then `control-openhands api GET '/api/conversations/<id>/events/search?limit=5' --max-bytes 2000000` (id from `conversation start`): the user `MessageEvent` has `\"activated_skills\": []`. Back on `/skills` (`control-openhands browser goto /skills`; `conversation start` left the browser on the conversation), click the toggle on again (no hover needed), start another `qa-ping` conversation: `\"activated_skills\": [\"qa-hello\"]` and `control-openhands browser text 'testid=agent-message >> nth=-1'` is `QA-PONG-7731`; `disabled_skills` is `[]` again.",
          "ids": [
            "F18.toggle-local"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Skill reaches chat (`F18.skill-in-chat`).",
          "body": "Run `control-openhands browser goto /skills` (the previous bullet ends on a conversation), `control-openhands browser click 'testid=skill-toggle-flarglebargle'` (`aria-checked` `true`), then `control-openhands conversation start --prompt \"flarglebargle\" --wait --timeout 240`, `control-openhands conversation events <id> --kinds MessageEvent --from-start` (the user row has `skills: [\"flarglebargle\"]`; the `events/search?limit=5` read this bullet used before can miss the user row) and `control-openhands browser text 'testid=agent-message >> nth=-1'` (a reply praising how smart the user is). `control-openhands browser screenshot --feature F18.skill-in-chat --name enabled`. Switch it off again on `/skills` (`control-openhands browser goto /skills`, hover, click; `enabled_skills` no longer lists it) and start another conversation with `--prompt \"flarglebargle. Do not run any tools; reply in one line.\"`: the user row has no `skills`, and the agent does not praise the user (2026-10-08: `Flarglebargle to you too — what would you like help with?`).",
          "ids": [
            "F18.skill-in-chat"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Project skill in a workspace (`F18.project-skill`).",
          "body": "With the committed `qa-f18-skill` fixture, run `control-openhands browser goto /skills`, `control-openhands browser reload` and `control-openhands browser count 'testid=skill-card-qa-f18-skill'`: `0` (the catalog lists the Agent Server's own skills, not a workspace's). Then `control-openhands conversation start --workspace qa-f18-repo --prompt \"qa-f18-ping\" --wait --timeout 240` (note `<repo-skill-id>`; its `workspace` is the fixture's path) and, in the chat it leaves open, `control-openhands browser text 'testid=generic-event-message-title'` (`Skill Ready`; with a model a second title, `Invoked skill qa-f18-skill`, can follow, because the agent may also call `invoke_skill`), `control-openhands browser click 'role=button[name=\"Expand\"]'` and `control-openhands browser count 'role=button[name=\"qa-f18-skill\"]'` (`1`, under `Triggered Skill Knowledge:`); `control-openhands browser screenshot --feature F18.project-skill --name skill-ready`. With an LLM the agent also obeys the skill, and this is the moment to read it, while the chat is still open: `control-openhands browser text 'testid=agent-message >> nth=-1'` ends with `QA_SKILL_OK`; without a key the conversation ends in `error` right after the events below and that reply check is blocked (prerequisite: `DEEPSEEK_API_KEY`). Agent side, `control-openhands conversation events <repo-skill-id> --grep qa-f18-skill --from-start` matches the `SystemPromptEvent` (its `<available_skills>` lists `<name>qa-f18-skill</name>`) and the user `MessageEvent` (`skills: [\"qa-f18-skill\"]`, with excerpts showing `\"activated_skills\":[\"qa-f18-skill\"]` and the `…/qa-f18-repo/.agents/skills/qa-f18-skill/SKILL.md` path). New Worktree: run `control-openhands workspace open qa-f18-repo --mode new_worktree` (it picks the mode in the preview bar's selector and prints `mode` `new_worktree` and `preview` `qa-f18-repo` / `New Worktree`; `control-openhands browser text 'testid=home-git-control-bar-preview'` reads the same two lines), then `control-openhands conversation start --stay --prompt \"qa-f18-ping\" --wait --timeout 240` (note `<worktree-id>`): its `workspace` is `/tmp/conversation-worktrees/<worktree-id>/qa-f18-repo`, a worktree of the fixture on branch `openhands/<worktree-id>` (read-only check: `git -C /tmp/conversation-worktrees/<worktree-id>/qa-f18-repo log --oneline` lists `Add qa-f18-skill skill` above `Initial fixture commit`), `control-openhands browser text 'testid=generic-event-message-title'` is `Skill Ready` again (and the same reply check applies here, with a key), and `control-openhands conversation events <worktree-id> --grep qa-f18-skill --from-start` matches the same events with the worktree's `SKILL.md` path (a `ConversationStateUpdateEvent` carrying the agent context matches in both conversations too). Restore the mode: `control-openhands workspace open qa-f18-repo --mode local_repo` (`mode` `local_repo`, `preview` `qa-f18-repo` / `Local Repo`).",
          "ids": [
            "F18.project-skill"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Deleted personal skill (`F18.skill-deleted`).",
          "body": "Run `control-openhands fixture skill --name qa-gone --trigger qa-farewell`, `control-openhands browser goto /skills`, `control-openhands browser reload`, `control-openhands browser count 'testid=skill-card-qa-gone'` (`1`; `control-openhands browser attr 'testid=skill-source-qa-gone' title` is the run's `…/private/home/.agents/skills/qa-gone/SKILL.md` path and `control-openhands browser text 'testid=skills-result-summary'` counts one result more than before) and `control-openhands browser screenshot --feature F18.skill-deleted --name present`. Prove it loads: `control-openhands conversation start --prompt \"qa-farewell\" --wait --timeout 240` (note `<gone-a>`), `control-openhands browser text 'testid=generic-event-message-title'` (`Skill Ready`) and `control-openhands conversation events <gone-a> --grep qa-gone --from-start`: the `SystemPromptEvent` and the user `MessageEvent` match, the latter with `skills: [\"qa-gone\"]`. Delete the folder (outside the UI; it is inside the run): `rm -rf \"$OH_VERIFY_RUN/private/home/.agents/skills/qa-gone\"`. Then `control-openhands browser goto /skills`, `control-openhands browser reload`, `control-openhands browser count 'testid=skill-card-qa-gone'` (`0`; the summary is back to its earlier count), `control-openhands conversation start --prompt \"qa-farewell. Do not run any tools; reply in one line.\" --wait --timeout 240` (note `<gone-b>`; with a model, a bare `qa-farewell` sent deepseek-flash searching the disk for the word, and it quoted the run's `stack.log` line `Skill 'qa-gone' triggered by keyword 'qa-farewell'`, so `--grep qa-gone` matched 9 tool rows although the skill was not loaded), `control-openhands browser count 'text=Skill Ready'` (`0`; `control-openhands browser screenshot --feature F18.skill-deleted --name absent`) and `control-openhands conversation events <gone-b> --grep qa-gone --from-start`: `count` is `0`, and `control-openhands conversation events <gone-b> --kinds MessageEvent --from-start` shows the user message without a `skills` row. No model is needed: the skill set is fixed before the first model call.",
          "ids": [
            "F18.skill-deleted"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Both personal skill folders (`F18.personal-skill-dirs`).",
          "body": "The `qa-gone` bullet above used `~/.agents/skills`. For the other folder, write a fixture into the stack's persistence folder (outside the UI, inside the run; `fixture skill` writes only `~/.agents/skills`): `mkdir -p \"$OH_VERIFY_RUN/private/skills/qa-legacy\"` and a `SKILL.md` there with the front matter `name: qa-legacy`, `description: QA fixture skill in the persistence-dir skills folder.`, `triggers: [qa-legacy-ping]` and the body `When this skill is active, end your reply with the word QA_LEGACY_OK.` (`$OH_VERIFY_RUN/private` is the Agent Server's `OH_PERSISTENCE_DIR`, which replaces `~/.openhands`). Run `control-openhands browser goto /skills`, `control-openhands browser reload`, `control-openhands browser count 'testid=skill-card-qa-legacy'` (`1`) and `control-openhands browser attr 'testid=skill-source-qa-legacy' title` (`…/private/skills/qa-legacy/SKILL.md`). Then `control-openhands conversation start --prompt \"qa-legacy-ping. Do not run any tools; reply in one line.\" --wait --timeout 240` (note `<legacy-id>`): `control-openhands browser text 'testid=generic-event-message-title'` is `Skill Ready`, `control-openhands conversation events <legacy-id> --kinds MessageEvent --from-start` shows the user row with `skills: [\"qa-legacy\"]`, and with a model `control-openhands browser text 'testid=agent-message >> nth=-1'` ends with `QA_LEGACY_OK` (`control-openhands browser screenshot --feature F18.personal-skill-dirs --name persistence-dir-skill`). Clean up with `rm -rf \"$OH_VERIFY_RUN/private/skills/qa-legacy\"`, `browser goto /skills`, `browser reload`; the card count is `0`.",
          "ids": [
            "F18.personal-skill-dirs"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Save failure (`F18.toggle-error`).",
          "body": "Run `control-openhands browser goto /skills`, wait for `testid=skill-toggle-add-javadoc` and note its `aria-checked` (`true` when this follows the toggle bullet, which left `add-javadoc` on). Then `control-openhands service stop agent-server`, `control-openhands browser click 'testid=skill-toggle-add-javadoc' --timeout 5000` (the card flips at once), wait about 2 s (the `PATCH /api/settings` is tried three times before the error shows; `browser network` lists three `502`s) and `control-openhands browser toasts --history`: `HTTP request failed (502 Bad Gateway): \"Bad Gateway: connect ECONNREFUSED 127.0.0.1:<agent-server port>\"`. `control-openhands browser screenshot --feature F18.toggle-error --name toast`. Run `control-openhands restart --timeout 240`, `control-openhands browser goto /skills`: `aria-checked` on `testid=skill-toggle-add-javadoc` is back to the value noted before the click and `enabled_skills` is unchanged. Clear the expected 502 noise with `control-openhands browser errors --clear`.",
          "ids": [
            "F18.toggle-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Detail modal and switch (`F18.detail-modal`).",
          "body": "With `add-javadoc` on, run `control-openhands browser click 'testid=skill-card-add-javadoc'`, `control-openhands browser testids 'testid=skill-detail-modal'` (name, source, `skill-modal-enable-row-add-javadoc`, description, pills incl. license `MIT`, `skill-modal-field-content-add-javadoc`, `skill-detail-close`, `skill-detail-use-skill-add-javadoc`) and `control-openhands browser enabled 'testid=skill-detail-use-skill-add-javadoc'` (`true`). Switch off with `control-openhands browser click 'testid=skill-modal-enable-row-add-javadoc >> text=Enabled'`; `control-openhands browser eval \"document.querySelector('[data-testid=skill-modal-toggle-add-javadoc]').checked\"` is `false`, the row reads `Disabled`, `enabled` on Use skill is `false` and the card toggle behind reads `aria-checked` `false`. `control-openhands browser screenshot --feature F18.detail-modal --name disabled`. Close, reload: the card stays off and `enabled_skills` no longer lists `add-javadoc`.",
          "ids": [
            "F18.detail-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Close the modal (`F18.detail-close`).",
          "body": "With `testid=skill-card-docker` opened each time, each of `control-openhands browser click 'testid=skill-detail-modal-close'`, `control-openhands browser click 'testid=skill-detail-close'`, `control-openhands browser press Escape` and `control-openhands browser mouse-click 100 500` (backdrop) leaves `control-openhands browser count 'testid=skill-detail-modal'` at `0`.",
          "ids": [
            "F18.detail-close"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Pill overflow in the modal (`F18.detail-pills`).",
          "body": "Run `control-openhands browser click 'testid=skill-card-add-javadoc'`, `control-openhands browser text 'testid=skill-modal-pills-add-javadoc'` (`Trigger-based`, `Code quality & review`, `MIT`, `Requires Java source files`, `+3`; license and compatibility appear only in the modal), `control-openhands browser attr 'testid=skill-modal-pills-add-javadoc-overflow' aria-label` (`Show 3 more`), `control-openhands browser click 'testid=skill-modal-pills-add-javadoc-overflow'` and `control-openhands browser text 'testid=skill-modal-pills-add-javadoc-overflow-popover'` (`javadoc`, `java documentation`, `document java`); `count 'testid=skill-detail-modal'` stays `1`. `control-openhands browser screenshot --feature F18.detail-pills --name popover`. Close the popover with `control-openhands browser click 'testid=skill-modal-description-add-javadoc'` (popover count `0`, modal still open), then `control-openhands browser press Escape`. Expected as well: Escape with the popover open closes the popover (as it does on a card); at 0c446b8 it closes neither the popover nor the modal (see Gotchas).",
          "ids": [
            "F18.detail-pills"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Use skill (`F18.use-skill`).",
          "body": "Run `control-openhands browser click 'testid=skill-card-docker'`, `control-openhands browser click 'testid=skill-detail-use-skill-docker' --expect-url '/conversations(\\?|$)'`, then `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"`: `/docker ` (the skill name as a slash command), with the modal gone (`control-openhands browser count 'testid=skill-detail-modal'` is `0`) and the composer focused (`control-openhands browser eval \"document.activeElement.getAttribute('data-testid')\"` is `chat-input`). `control-openhands browser screenshot --feature F18.use-skill --name prefilled` shows `/docker` in the Home composer. Nothing is sent, and the text is applied once: leave and come back without a page load (`control-openhands browser click 'testid=sidebar-automations-link' --expect-url '/automations$'`, then `control-openhands browser click 'testid=sidebar-conversations-link' --expect-url '/conversations(\\?|$)'`) and the same `chat-input` eval returns an empty string. A `browser goto /` would prove nothing here: a page load empties the composer either way.",
          "ids": [
            "F18.use-skill"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Add skill instructions (`F18.add-skill-modal`).",
          "body": "Run `control-openhands browser goto /skills` (Use skill ends on `/conversations`), `control-openhands browser click 'testid=skills-add-skill-button'`, `control-openhands browser text 'testid=add-skill-modal'` (title `Add a skill`, the example `/add-skill https://github.com/OpenHands/extensions/tree/main/skills/code-review`, five steps, \"Supported URL formats\" with the short form `OpenHands/extensions/skills/code-review`, \"Where skills are stored\", the `GITHUB_TOKEN` note) and `control-openhands browser attr 'testid=add-skill-modal-docs-link' href` (`https://docs.openhands.dev/overview/skills/adding#adding-new-skills`; `target` is `_blank`). Copy with `control-openhands browser click 'testid=add-skill-modal-example-copy'`: its `aria-label` becomes `Copied to clipboard`, `enabled` is `false` and `control-openhands browser clipboard` returns the example command; after 2 s it reads `Copy to clipboard` again. Close with `testid=add-skill-modal-dismiss`, `testid=add-skill-modal-close` or `control-openhands browser press Escape`; `count 'testid=add-skill-modal'` is `0` each time. The example points at an existing skill (#18035): `control-openhands browser goto '/skills?q=code-review'` and `control-openhands browser text 'testid=skill-source-code-review'` (`public`) show it in the bundled catalog, which `F18.install-banner` installs from that same URL.",
          "ids": [
            "F18.add-skill-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Install banner (`F18.install-banner`).",
          "body": "Run `control-openhands conversation start --prompt \"/add-skill https://github.com/OpenHands/extensions/tree/main/skills/code-review\" --wait --timeout 300` and `control-openhands browser text 'testid=skill-install-restart-banner'`: `Installed to this workspace: code-review. Skills load when a conversation starts, so this conversation can't use them yet.` plus `Start new conversation with this skill`. `control-openhands browser screenshot --feature F18.install-banner --name banner`. Run `control-openhands browser click 'testid=skill-install-restart-action' --expect-url '/conversations/(?!<id>)[0-9a-f-]+(\\?|$)'` (`<id>` from `conversation start`; the lookahead skips the current URL), then `control-openhands conversation status <new id>` (the id in the returned URL): its `workspace` equals the install conversation's `workspace` from `conversation start`. Back on the first conversation (`control-openhands browser goto /conversations/<id>`), `control-openhands browser click 'testid=skill-install-restart-dismiss'` makes `count 'testid=skill-install-restart-banner'` `0`; after `control-openhands browser reload` it is `1` again (dismissal is session-only by design).",
          "ids": [
            "F18.install-banner"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Filters modal on a phone (`F18.filters-modal`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /skills`, `control-openhands browser visible 'testid=skill-facet-rail'` (`false`), `control-openhands browser click 'testid=skills-filters-button'`, `control-openhands browser count 'testid=skill-filters-modal-clear'` (`0`), `control-openhands browser click 'testid=skill-filters-modal >> testid=skill-facet-state-enabled'` (URL `?state=enabled`, Clear filters appears), `control-openhands browser screenshot --feature F18.filters-modal --name phone`, `control-openhands browser click 'testid=skill-filters-modal-done'`, then `control-openhands browser text 'testid=skills-filters-button'` (`Filters` and badge `1`) and the summary (`12 result(s)` on a fresh run, `13 result(s)` with the `qa-hello` fixture). Reopen and `control-openhands browser click 'testid=skill-filters-modal-clear'`: URL `/skills` and the Clear button disappears; Escape closes the modal.",
          "ids": [
            "F18.filters-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Phone layout and hub (`F18.phone`).",
          "body": "At the phone viewport run `control-openhands browser bbox 'testid=skills-page'` (`pageHorizontalOverflow` `false`), `control-openhands browser screenshot --feature F18.phone --name list`, open `testid=skill-card-docker` and `control-openhands browser bbox 'testid=skill-detail-modal'` (`insideViewport` `true`), Escape, `control-openhands browser click 'testid=skills-add-skill-button'` and `control-openhands browser bbox 'testid=add-skill-modal'` (`insideViewport` `true`), Escape. Then `control-openhands browser goto /customize`, `control-openhands browser text 'testid=extensions-mobile-hub'` (`Customize`, `MCP Servers`, `Skills`, `Plugins`, `Apps`), `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/skills' --expect-url '/skills(\\?|$)'` and `control-openhands browser click 'testid=sidebar-mobile-back-button' --expect-url '/customize(\\?|$)'`. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F18.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Cloud link (`F18.cloud-link`).",
          "body": "Blocked: needs a Cloud backend. Expected: `control-openhands browser attr 'testid=sidebar-extensions-/skills' href` is `<cloud host>/settings/skills` with `target` `_blank`, and the Plugins and Apps items are hidden.",
          "ids": [
            "F18.cloud-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Restore.",
          "body": "Leave `add-javadoc` and `flarglebargle` off and `qa-hello` on; the fixture skill and the conversations vanish with the run's private state.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F19",
      "title": "Plugins and plugin launch",
      "file": "F19-plugins.md",
      "page": "F19-plugins.html",
      "sha256": "116ff50c0d6be5b194761338a248fa77067f437b42db1c3c49d8961d7cb3e015",
      "behaviors": [
        {
          "id": "F19.page",
          "description": "`/plugins` shows the **Plugins** heading, the description \"Browse, install, enable, and uninstall plugins. Enabled plugins load automatically into new conversations.\", an enabled **Add plugin** button on a local backend, and the catalog as cards."
        },
        {
          "id": "F19.search",
          "description": "the search box filters by name, description, source, path and ref; a query matching nothing shows `No plugins match your search.`; the X clears the query."
        },
        {
          "id": "F19.status-filter",
          "description": "the All / Installed / Available / Local segments filter the grid; the active one has `aria-pressed=\"true\"`; the filter resets to All on reload."
        },
        {
          "id": "F19.detail-modal",
          "description": "clicking a card (or Enter/Space on a focused card) opens a modal with name, source, description, **Skills in this plugin bundle**, **Files** and footer actions; X, **Close** and Escape close it."
        },
        {
          "id": "F19.files-browser",
          "description": "in the modal's Files tree, expanding a folder and selecting a file shows its highlighted content with line numbers; selecting it again closes the viewer."
        },
        {
          "id": "F19.install-card",
          "description": "**Install** on a card installs the plugin: toast `Plugin installed.`, the button becomes an enable switch, a version pill appears, the card sorts first and the install survives a reload."
        },
        {
          "id": "F19.install-modal",
          "description": "**Install** in the detail modal does the same for the open plugin; the modal stays open and switches to the installed footer."
        },
        {
          "id": "F19.enable-toggle",
          "description": "the card switch (named `Disable plugin` / `Enable plugin`) and the modal **Enabled/Disabled** switch turn an installed plugin off and on; the state persists after reload."
        },
        {
          "id": "F19.enabled-autoload",
          "description": "an installed, enabled plugin loads into every new conversation; disabled, it does not."
        },
        {
          "id": "F19.refresh",
          "description": "**Update** in an installed plugin's modal re-fetches it from its recorded source and shows `Plugin updated.`"
        },
        {
          "id": "F19.uninstall",
          "description": "**Uninstall** removes the plugin at once (no confirmation), shows `Plugin uninstalled.`, closes the modal and returns the card to **Install**."
        },
        {
          "id": "F19.add-plugin",
          "description": "**Add plugin** opens **Add a plugin** with Source (required), Ref and Path (optional); Install stays disabled until Source has text; a valid source installs and closes the modal."
        },
        {
          "id": "F19.add-plugin-cancel",
          "description": "the Add modal's X, **Close**, Escape and a backdrop click close it without installing; reopening starts with empty fields."
        },
        {
          "id": "F19.add-plugin-error",
          "description": "an unusable source shows one error toast with the server's reason and keeps the modal open with the typed values."
        },
        {
          "id": "F19.installed-coordinates",
          "description": "a plugin installed with a source, ref and path shows that source on its card and in the modal header, and modal pills `v<version>`, `Ref <resolved commit>` and `Path <repo path>` next to the Enabled switch."
        },
        {
          "id": "F19.local-plugin",
          "description": "a plugin found in `~/.agents/plugins` shows a **Local** badge (no Install or switch), is listed under the Local filter, and its modal has a Local pill, Files, no Install/Uninstall and no Start Conversation."
        },
        {
          "id": "F19.start-conversation",
          "description": "**Start Conversation** in the detail modal opens `/launch?plugins=<base64 JSON>` for that plugin."
        },
        {
          "id": "F19.launch-review",
          "description": "`/launch` shows a modal titled `Launch <plugin>` (one plugin) or `Launch Plugin` (several), the optional `message` with HTML stripped and capped at 500 characters, and each plugin as `<source> / <path> @ <ref>`; X and Escape go to `/conversations`."
        },
        {
          "id": "F19.launch-parameters",
          "description": "plugins with `parameters` get an expanded, collapsible section with a text input, number input (blank becomes 0) or checkbox per value; edits are kept."
        },
        {
          "id": "F19.launch-trust",
          "description": "**Start Conversation** stays disabled until the trust checkbox (`I trust this plugin from <sources> …`) is ticked; then it creates the conversation (sending the message, if any), opens `/conversations/<id>`, and the plugin is loaded there."
        },
        {
          "id": "F19.launch-dev-params",
          "description": "`/launch?plugin_source=…&plugin_ref=…&plugin_repo_path=…&message=…` opens the same review for one plugin."
        },
        {
          "id": "F19.launch-errors",
          "description": "bare `/launch` or an empty array says `No plugins were specified…`; bad base64, invalid JSON, a non-array or an item without `source` says `The plugin configuration is invalid…`; **Go Home** opens `/conversations`."
        },
        {
          "id": "F19.launch-creation-failed",
          "description": "when conversation creation fails, the screen shows `Unable to Launch` / `Failed to Start Conversation` with **Go Home** and **Try Again**; Try Again reloads the page, which brings back the review modal with the trust box cleared."
        },
        {
          "id": "F19.launch-unicode",
          "description": "a deep link whose JSON contains non-ASCII text (UTF-8 base64) shows that text correctly."
        },
        {
          "id": "F19.phone",
          "description": "at 390 px the toolbar stacks, cards fit, and the detail, Add and launch modals stay inside the viewport."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open the page (`F19.page`).",
          "body": "Run `control-openhands browser goto /conversations`, `control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/mcp(\\?|$)'`, `control-openhands browser click 'testid=sidebar-extensions-/plugins' --expect-url '/plugins(\\?|$)'`, then `control-openhands browser text 'testid=skills-plugins-screen >> h2'` (`Plugins`), `control-openhands browser text 'testid=plugins-settings-description'` (the description above), `control-openhands browser enabled 'testid=plugins-add-plugin-button'` (`true`) and `control-openhands browser count '[data-testid^=\"plugin-card-\"]'` (9 with today's catalog). `control-openhands browser screenshot --feature F19.page --name catalog` shows two columns of cards, each with name, truncated source path, a two-line description and **Install**.",
          "ids": [
            "F19.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command-menu entry (`F19.page`).",
          "body": "From `/conversations` run `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Plugins`, `control-openhands browser snapshot 'testid=command-menu'` (one option, `Customize Browse skills, plugins, and integrations. Go`), `control-openhands browser press Enter`, `control-openhands browser wait-url '/mcp(\\?|$)'`, then `control-openhands browser click 'testid=sidebar-extensions-/plugins' --expect-url '/plugins(\\?|$)'`.",
          "ids": [
            "F19.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Search (`F19.search`).",
          "body": "Run `control-openhands browser fill 'testid=plugins-search-input' weather` and `control-openhands browser count '[data-testid^=\"plugin-card-\"]'` (`1`). `control-openhands browser fill 'testid=plugins-search-input' alakazam` also leaves `1` (`magic-test`, matched on its description). `control-openhands browser fill 'testid=plugins-search-input' zzz-none`, then `control-openhands browser text 'testid=plugins-no-match'`: `No plugins match your search.` Run `control-openhands browser click 'testid=plugins-toolbar >> role=button[name=\"Clear search\"]'`; `control-openhands browser value 'testid=plugins-search-input'` is empty and the count is back to 9.",
          "ids": [
            "F19.search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Status filter (`F19.status-filter`).",
          "body": "`control-openhands browser attr 'testid=plugins-filter-all' aria-pressed` is `true`. Run `control-openhands browser click 'testid=plugins-filter-installed'`: its `aria-pressed` is `true`, All's is `false`, and with nothing installed `control-openhands browser count 'testid=plugins-no-match'` is `1`. `control-openhands browser click 'testid=plugins-filter-available'` shows all 9 cards; `control-openhands browser click 'testid=plugins-filter-local'` shows `plugins-no-match` (no local plugin yet). After `control-openhands browser reload`, `plugins-filter-all` is pressed again.",
          "ids": [
            "F19.status-filter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Detail modal (`F19.detail-modal`).",
          "body": "Run `control-openhands browser click 'testid=plugin-card-magic-test'` and `control-openhands browser snapshot 'testid=plugin-detail-modal'`: heading `magic-test`, the source path, the description, `Skills in this plugin bundle` with `magic-word`, `Files` with `.plugin` and `skills` folders, and buttons **Install**, **Start Conversation**, **Close**. Close with `control-openhands browser click 'testid=plugin-detail-modal-close'` (`browser count 'testid=plugin-detail-modal'` is `0`). Keyboard: `control-openhands browser focus 'testid=plugin-card-city-weather'`, `control-openhands browser press Enter`, then `control-openhands browser attr 'testid=plugin-detail-modal' data-plugin-name` (`city-weather`); `control-openhands browser press Escape` closes it; `browser focus` again and `control-openhands browser press Space` reopens it; `control-openhands browser click 'testid=plugin-detail-modal-dismiss'` closes it.",
          "ids": [
            "F19.detail-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Files (`F19.files-browser`).",
          "body": "Open the `magic-test` modal again and run `control-openhands browser click 'testid=file-tree-dir-.plugin'`, `control-openhands browser click 'testid=file-tree-file-.plugin/plugin.json'`, `control-openhands browser wait 'testid=plugin-file-content >> text=magic-test' --timeout 15000` and `control-openhands browser text 'testid=plugin-file-content'`: `.plugin/plugin.json` followed by numbered JSON lines (`2  \"name\": \"magic-test\",`). `control-openhands browser screenshot --feature F19.files-browser --name plugin-json` shows the tree with the file highlighted above a syntax-coloured viewer. Click the same file again; `control-openhands browser count 'testid=plugin-file-content'` is `0`. Close with `testid=plugin-detail-modal-close`.",
          "ids": [
            "F19.files-browser"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Install from a card (`F19.install-card`).",
          "body": "Run `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=plugin-install-magic-test'`, `control-openhands browser wait 'testid=plugin-toggle-magic-test' --timeout 60000` and `control-openhands browser toasts` (`Plugin installed.`). `control-openhands browser text 'testid=plugin-version-magic-test'` is `v1.0.0` and `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=plugin-card-]')].map(e=>e.dataset.testid)[0]\"` is `plugin-card-magic-test`. After `control-openhands browser reload` and `control-openhands browser click 'testid=plugins-filter-installed'`, `browser count '[data-testid^=\"plugin-card-\"]'` is `1`, and `control-openhands api GET /api/plugins/installed` lists `magic-test` with `\"enabled\": true`. Click `testid=plugins-filter-all`.",
          "ids": [
            "F19.install-card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Enable switch (`F19.enable-toggle`).",
          "body": "Run `control-openhands browser attr 'testid=plugin-toggle-magic-test' aria-checked` (`true`) and `control-openhands browser attr 'testid=plugin-toggle-magic-test' aria-label` (`Disable plugin`), `control-openhands browser hover 'testid=plugin-toggle-magic-test'`, `control-openhands browser click 'testid=plugin-toggle-magic-test'`, wait three seconds, then `browser attr ... aria-checked` again (`false`, with `aria-label` `Enable plugin`); after `control-openhands browser reload` it is still `false` and `api GET /api/plugins/installed` shows `\"enabled\": false`. Today the disable is often lost: the reload's concurrent reads hit the metadata race (Gotchas) and the API, then the switch, read `\"enabled\": true` again (2 of 3 trials; the PATCH itself returns 200). If that happens, disable it once more before the modal step, which needs the plugin disabled. In the modal: `control-openhands browser click 'testid=plugin-card-magic-test'`, `control-openhands browser click 'testid=plugin-detail-modal >> text=\"Disabled\"'`, then `control-openhands browser eval \"document.querySelector('[data-testid=plugin-modal-toggle-magic-test]').checked\"` (`true`); the API shows `\"enabled\": true` and, after closing the modal, the card switch's `aria-checked` is `true`.",
          "ids": [
            "F19.enable-toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Enabled plugins auto-load (`F19.enabled-autoload`).",
          "body": "With `magic-test` enabled, run `control-openhands conversation start --prompt \"alakazam\" --wait --timeout 240` and `control-openhands browser text 'testid=agent-message >> nth=-1'`: `The magic worked! Plugin loaded successfully. 🎩✨`. Then `control-openhands browser goto /plugins`, disable it with the card switch (hover, click; API `\"enabled\": false`) and run the same `conversation start`: the reply is an ordinary request for a task, without the phrase. `browser goto /plugins` again; `magic-test` stays installed and disabled. Today the revisit often re-enables it (API `\"enabled\": true`, `installed_at` reset; see Gotchas), so re-check the API before relying on the disabled state.",
          "ids": [
            "F19.enabled-autoload"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Update (`F19.refresh`).",
          "body": "Run `control-openhands browser click 'testid=plugin-card-magic-test'`, `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=plugin-detail-refresh-magic-test'`, wait a few seconds, then `control-openhands browser toasts`. Expected `Plugin updated.` (the toast fades fast: read `control-openhands browser toasts --history`). Whether it works depends on the recorded source (`control-openhands browser text 'testid=plugin-source-magic-test'`): with the catalog path it does. Known failure (reproduced 2026-10-06): a page revisit after a catalog install usually rewrites the source to `local` (OpenHands/software-agent-sdk#5496, see Gotchas), and Update then answers 500 with one toast carrying the server's reason, `Unable to parse extension source: local. Expected formats: 'github:owner/repo', git URL, or local path` (`browser screenshot --feature F19.refresh --name local-source-error`). On a plugin whose source survived (the GitHub install in **Add plugin from GitHub**, `browser text 'testid=plugin-source-magic-test'` = `github:OpenHands/extensions`), the same click logs `Updating extension magic-test from github:OpenHands/extensions` and `toasts --history` shows `Plugin updated.` Close the modal.",
          "ids": [
            "F19.refresh"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Start a conversation from an installed plugin (`F19.start-conversation`).",
          "body": "Run `control-openhands browser click 'testid=plugin-card-magic-test'`, `control-openhands browser click 'testid=plugin-detail-start-conversation-magic-test' --expect-url '/launch\\?plugins='` and `control-openhands browser eval \"JSON.parse(atob(new URLSearchParams(location.search).get('plugins')))\"`. Expected: the plugin's real source. Today it is `[{\"source\":\"local\",\"ref\":null,\"repo_path\":null}]` and the modal title is `Launch local`; after `control-openhands browser check 'testid=trust-checkbox'` and `control-openhands browser click 'testid=start-conversation-button' --expect-url '/conversations/[0-9a-f-]+'`, `control-openhands browser type 'testid=chat-input' 'alakazam'` and `control-openhands browser press Enter --selector 'testid=chat-input'` leave the message at `Sending...` (still after 25 s) with the banner `Unable to parse plugin source: local. Expected formats: 'github:owner/repo', git URL, or local path` (`control-openhands browser screenshot --feature F19.start-conversation --name local-source-conversation`). When the installed plugin kept its source (the GitHub install below), the link carries it: `[{\"source\":\"github:OpenHands/extensions\",\"ref\":\"<resolved commit>\",\"repo_path\":\"plugins/magic-test\"}]` and the title is `Launch magic-test`.",
          "ids": [
            "F19.start-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Uninstall (`F19.uninstall`).",
          "body": "Run `control-openhands browser goto /plugins`, `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=plugin-card-magic-test'`, `control-openhands browser click 'testid=plugin-detail-uninstall-magic-test'`, then `control-openhands browser count 'testid=plugin-detail-modal'` (`0`), `control-openhands browser count 'testid=confirmation-modal'` (`0`: no confirmation), `control-openhands browser toasts` (`Plugin uninstalled.`) and `control-openhands browser count 'testid=plugin-install-magic-test'` (`1`). After `control-openhands browser reload` the Install button is still there, `browser text 'testid=plugin-source-magic-test'` is the catalog path again and `api GET /api/plugins/installed` returns `{\"plugins\": []}`.",
          "ids": [
            "F19.uninstall"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Install from the modal (`F19.install-modal`).",
          "body": "Run `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=plugin-card-city-weather'`, `control-openhands browser click 'testid=plugin-detail-install-city-weather'`, `control-openhands browser wait 'testid=plugin-detail-uninstall-city-weather' --timeout 60000` and `control-openhands browser toasts` (`Plugin installed.`). The modal stays open (`browser count 'testid=plugin-detail-modal'` is `1`), `control-openhands browser eval \"document.querySelector('[data-testid=plugin-modal-toggle-city-weather]').checked\"` is `true`, and `control-openhands browser snapshot 'testid=plugin-detail-modal >> footer'` lists **Update**, **Uninstall**, **Start Conversation**, **Close**. Clean up with `control-openhands browser click 'testid=plugin-detail-uninstall-city-weather'`; `browser count 'testid=plugin-install-city-weather'` is `1`.",
          "ids": [
            "F19.install-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Cancel Add plugin (`F19.add-plugin-cancel`).",
          "body": "Run `control-openhands browser click 'testid=plugins-add-plugin-button'`, `control-openhands browser fill 'testid=add-plugin-source-input' qa-typed` and `control-openhands browser click 'testid=add-plugin-modal-close'`; `control-openhands browser count 'testid=add-plugin-modal'` is `0`. Reopen it: `control-openhands browser value 'testid=add-plugin-source-input'` is empty. Type again and close with `control-openhands browser click 'testid=add-plugin-modal-dismiss'` (count `0`); reopen and `control-openhands browser press Escape` (count `0`); reopen and `control-openhands browser mouse-click 20 500` on the backdrop (count `0`). `api GET /api/plugins/installed` still returns `{\"plugins\": []}` and `browser network` shows no `POST /api/plugins/install`.",
          "ids": [
            "F19.add-plugin-cancel"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Add plugin form and error (`F19.add-plugin`, `F19.add-plugin-error`).",
          "body": "Run `control-openhands browser click 'testid=plugins-add-plugin-button'` and `control-openhands browser snapshot 'testid=add-plugin-modal'`: heading `Add a plugin`, `Install a plugin from a Git source or local path.`, textbox `Source` (placeholder `github:owner/repo, a Git URL, or a local path`), `Ref Optional`, `Path Optional`, **Close** and a disabled **Install**. `control-openhands browser fill 'testid=add-plugin-source-input' '   '` keeps `control-openhands browser enabled 'testid=add-plugin-submit'` at `false`; `control-openhands browser fill 'testid=add-plugin-source-input' qa-not-a-source` turns it `true`. Run `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=add-plugin-submit'`, wait a few seconds, then `control-openhands browser toasts --history`, `control-openhands browser count 'testid=add-plugin-modal'` (`1`) and `control-openhands browser value 'testid=add-plugin-source-input'` (`qa-not-a-source`). The history holds one toast with the server's reason: the Agent Server answers 400 `Failed to fetch plugin source. Check that the source is valid.`, and the toast shows that text (`browser screenshot --feature F19.add-plugin-error --name toast`).",
          "ids": [
            "F19.add-plugin",
            "F19.add-plugin-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Add plugin from GitHub (`F19.add-plugin`).",
          "body": "In the same modal run `control-openhands browser fill 'testid=add-plugin-source-input' 'github:OpenHands/extensions'`, `control-openhands browser fill 'testid=add-plugin-repo-path-input' 'plugins/magic-test'`, `control-openhands browser toasts --clear`, `control-openhands browser click 'testid=add-plugin-submit'` and `control-openhands browser text 'testid=add-plugin-submit'` (`Installing…`), then `control-openhands browser wait 'testid=add-plugin-modal' --state hidden --timeout 120000` and `control-openhands browser toasts` (`Plugin installed.`). `api GET /api/plugins/installed` lists `magic-test` with four files (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.plugin/plugin.json`, `skills/magic-word/SKILL.md`) with `\"source\": \"github:OpenHands/extensions\"`, `\"repo_path\": \"plugins/magic-test\"` and a commit in `resolved_ref`; the card's `plugin-source-magic-test` reads `github:OpenHands/extensions`. The metadata race (Gotchas) can still turn it into `\"source\": \"local\"` with `repo_path: null`. Without GitHub access expect the error toast of the previous bullet instead. Clean up: `testid=plugin-card-magic-test` → `testid=plugin-detail-uninstall-magic-test`.",
          "ids": [
            "F19.add-plugin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Installed coordinates (`F19.installed-coordinates`).",
          "body": "Needs GitHub access like the previous bullet. Run `control-openhands browser click 'testid=plugins-add-plugin-button'`, `control-openhands browser fill 'testid=add-plugin-source-input' 'github:OpenHands/extensions'`, `control-openhands browser fill 'testid=add-plugin-ref-input' main`, `control-openhands browser fill 'testid=add-plugin-repo-path-input' 'plugins/city-weather'`, `control-openhands browser click 'testid=add-plugin-submit'` and `control-openhands browser wait 'testid=add-plugin-modal' --state hidden --timeout 120000` (`toasts --history`: `Plugin installed.`). `control-openhands browser text 'testid=plugin-source-city-weather'` is `github:OpenHands/extensions`; `control-openhands browser click 'testid=plugin-card-city-weather'` and `control-openhands browser snapshot 'testid=plugin-detail-modal'` show the header paragraph `github:OpenHands/extensions` and the text `v1.0.0 Ref <40-hex commit> Path plugins/city-weather Enabled` (the API's `resolved_ref`), and Files lists `.claude-plugin` and `.codex-plugin` that the catalog copy lacks. Clean up with `control-openhands browser click 'testid=plugin-detail-uninstall-city-weather'`; the card's source is the catalog path again.",
          "ids": [
            "F19.installed-coordinates"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Local plugin (`F19.local-plugin`).",
          "body": "After arranging `qa-local-plugin` (Preconditions), run `control-openhands browser reload`, `control-openhands browser text 'testid=plugin-local-badge-qa-local-plugin'` (`Local`), and `control-openhands browser count` on `testid=plugin-install-qa-local-plugin`, `testid=plugin-toggle-qa-local-plugin` and `testid=plugin-source-qa-local-plugin` (all `0`). `control-openhands browser click 'testid=plugins-filter-local'` leaves one card; under `plugins-filter-available` `browser count 'testid=plugin-card-qa-local-plugin'` is `0`. Back on All, `control-openhands browser click 'testid=plugin-card-qa-local-plugin'` and `control-openhands browser snapshot 'testid=plugin-detail-modal'`: no source line, pills `Local v1.0.0`, the bundled skill and Files, and only a **Close** button in the footer (`browser screenshot --feature F19.local-plugin --name modal`). Close with Escape. Clean up: `rm -rf \"$OH_VERIFY_RUN/private/home/.agents/plugins/qa-local-plugin\"`, `control-openhands browser reload`; the card count for `qa-local-plugin` is `0`.",
          "ids": [
            "F19.local-plugin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Start a conversation from the catalog (`F19.start-conversation`, `F19.launch-trust`).",
          "body": "With `magic-test` not installed, run `control-openhands browser click 'testid=plugin-card-magic-test'`, `control-openhands browser click 'testid=plugin-detail-start-conversation-magic-test' --expect-url '/launch\\?plugins='` and the `atob` eval above: one object with the catalog `source`, `ref: null`, `repo_path: null`. `control-openhands browser snapshot 'testid=plugin-launch-modal'` shows `Launch magic-test`, the source path and the checkbox `I trust this plugin from <source path> with the agent secrets defined in my account.`; `control-openhands browser enabled 'testid=start-conversation-button'` is `false`. Run `control-openhands browser check 'testid=trust-checkbox'` (`enabled` turns `true`) and `control-openhands browser click 'testid=start-conversation-button' --expect-url '/conversations/[0-9a-f-]+'`; `control-openhands browser url` gives `<id>`. `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser click 'testid=show-plugins-button'` and `control-openhands browser text 'testid=plugins-modal'` list `magic-test`; close with `testid=close-plugins-modal`. Then `control-openhands browser type 'testid=chat-input' 'alakazam'`, `control-openhands browser press Enter --selector 'testid=chat-input'`, `control-openhands conversation wait <id> --timeout 180` and `control-openhands browser text 'testid=agent-message >> nth=-1'`: the magic phrase.",
          "ids": [
            "F19.start-conversation",
            "F19.launch-trust"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Review screen (`F19.launch-review`).",
          "body": "Run `control-openhands browser goto '/launch?plugins=W3sic291cmNlIjoiZ2l0aHViOk9wZW5IYW5kcy9leHRlbnNpb25zIiwicmVwb19wYXRoIjoicGx1Z2lucy9jaXR5LXdlYXRoZXIifSx7InNvdXJjZSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9PcGVuSGFuZHMvZXh0ZW5zaW9ucy5naXQiLCJyZXBvX3BhdGgiOiJwbHVnaW5zL21hZ2ljLXRlc3QiLCJyZWYiOiJtYWluIn1d'` (two plugins from `OpenHands/extensions`, one as an HTTPS git URL) and `control-openhands browser snapshot 'testid=plugin-launch-modal'`: the heading `Launch Plugin` (`Launch` once); under `Plugins`, the text `city-weather OpenHands/extensions/ plugins/city-weather magic-test OpenHands/extensions/ plugins/magic-test@ main` (the screenshot renders `OpenHands/extensions / plugins/magic-test @ main`); and the checkbox `I trust this plugin from OpenHands/extensions with the agent secrets defined in my account.`, which names `OpenHands/extensions` once (`browser screenshot --feature F19.launch-review --name multi`). Message: `control-openhands browser goto '/launch?plugin_source=github%3AOpenHands%2Fextensions&message=%3Cb%3EWeather%3C%2Fb%3E%20for%20QA'` and `control-openhands browser text 'testid=plugin-launch-modal >> p'`: `Weather for QA` (tags stripped). With a 600-character `message` (`/launch?plugin_source=github%3AOpenHands%2Fextensions&message=aaa…`), `control-openhands browser eval \"document.querySelector('[data-testid=plugin-launch-modal] p').textContent.length\"` is `500`. Close: `control-openhands browser click 'testid=plugin-launch-modal >> testid=close-button' --expect-url '/conversations(\\?|$)'`; on a fresh `/launch?...`, `control-openhands browser press Escape` also lands on `/conversations`.",
          "ids": [
            "F19.launch-review"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Parameters (`F19.launch-parameters`).",
          "body": "Run `control-openhands browser goto '/launch?plugins=W3sic291cmNlIjoiZ2l0aHViOk9wZW5IYW5kcy9leHRlbnNpb25zIiwicmVwb19wYXRoIjoicGx1Z2lucy9jaXR5LXdlYXRoZXIiLCJyZWYiOiJtYWluIiwicGFyYW1ldGVycyI6eyJjaXR5IjoiUGFyaXMiLCJkYXlzIjozLCJtZXRyaWMiOnRydWV9fV0%3D'` (city-weather with `{\"city\":\"Paris\",\"days\":3,\"metric\":true}`) and `control-openhands browser snapshot 'testid=plugin-launch-modal'`: a `city-weather` section button, `ref: main`, `path: plugins/city-weather`, textbox `city` (`Paris`), spinbutton `days` (`3`) and a checked checkbox `metric`. `control-openhands browser click 'testid=plugin-section-0'` collapses it (`browser count 'testid=plugin-0-param-city'` is `0`); click again to expand. `control-openhands browser fill 'testid=plugin-0-param-city' Berlin`, `control-openhands browser fill 'testid=plugin-0-param-days' ''` (then `control-openhands browser value 'testid=plugin-0-param-days'` is `0`) and `control-openhands browser uncheck 'testid=plugin-0-param-metric'` are accepted. A link that mixes plugins with and without parameters (`/launch?plugins=W3sic291cmNlIjoiZ2l0aHViOk9wZW5IYW5kcy9leHRlbnNpb25zIiwicmVwb19wYXRoIjoicGx1Z2lucy9tYWdpYy10ZXN0In0seyJzb3VyY2UiOiJnaXRodWI6T3BlbkhhbmRzL2V4dGVuc2lvbnMiLCJyZXBvX3BhdGgiOiJwbHVnaW5zL2NpdHktd2VhdGhlciIsInBhcmFtZXRlcnMiOnsiY2l0eSI6IlBhcmlzIn19XQ%3D%3D`) lists the parameter-less one under `Additional Plugins`. Leave with Escape; do not start this one (see Gotchas).",
          "ids": [
            "F19.launch-parameters"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Dev format (`F19.launch-dev-params`).",
          "body": "Run `control-openhands browser goto '/launch?plugin_source=github%3AOpenHands%2Fextensions&plugin_ref=main&plugin_repo_path=plugins%2Fmagic-test&message=hi%20QA'` and `control-openhands browser snapshot 'testid=plugin-launch-modal'`: `Launch magic-test`, paragraph `hi QA`, text `magic-test OpenHands/extensions/ plugins/magic-test@ main`.",
          "ids": [
            "F19.launch-dev-params"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Error states (`F19.launch-errors`).",
          "body": "For each of `/launch`, `/launch?plugins=garbage`, `/launch?plugins=e30%3D` (`{}`), `/launch?plugins=W10%3D` (`[]`) and `/launch?plugins=W3sicmVmIjoibWFpbiJ9XQ%3D%3D` (`[{\"ref\":\"main\"}]`) run `control-openhands browser goto '<url>'` and `control-openhands browser text 'testid=launch-error'`. Bare and `[]` read `Unable to Launch` / `No plugins were specified. Please provide at least one plugin to launch.`; the other three read `The plugin configuration is invalid. Please check the URL and try again.` (`browser screenshot --feature F19.launch-errors --name invalid-format`). `control-openhands browser count 'testid=try-again-button'` is `0` in all of them. `control-openhands browser click 'testid=go-home-button' --expect-url '/conversations(\\?|$)'` leaves.",
          "ids": [
            "F19.launch-errors"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Creation failure (`F19.launch-creation-failed`).",
          "body": "Run `control-openhands browser goto '/launch?plugin_source=github%3AOpenHands%2Fextensions&plugin_repo_path=plugins%2Fmagic-test'`, `control-openhands browser check 'testid=trust-checkbox'`, `control-openhands service stop agent-server`, `control-openhands browser click 'testid=start-conversation-button'`, `control-openhands browser wait 'testid=launch-error' --timeout 60000` and `control-openhands browser text 'testid=launch-error'`: `Unable to Launch`, `Failed to Start Conversation`, `Go Home`, `Try Again` (`browser screenshot --feature F19.launch-creation-failed --name error`). `control-openhands browser click 'testid=try-again-button'` reloads the page; with the server still down the app shows **Manage backends** with the Local backend `Disconnected`. Restore with `control-openhands restart --timeout 240` and `control-openhands browser reload`: the review modal is back and `control-openhands browser eval \"document.querySelector('[data-testid=trust-checkbox]').checked\"` is `false`. Run `control-openhands doctor` before continuing.",
          "ids": [
            "F19.launch-creation-failed"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Non-ASCII deep link (`F19.launch-unicode`).",
          "body": "Run `control-openhands browser goto '/launch?plugins=W3sic291cmNlIjoiZ2l0aHViOnFhL3LDqXBvIn1d'` (UTF-8 base64 of `[{\"source\":\"github:qa/répo\"}]`) and `control-openhands browser text 'testid=plugin-launch-modal >> h2'`. Expected `Launch qa/répo`; today `Launch qa/rÃ©po`.",
          "ids": [
            "F19.launch-unicode"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Phone (`F19.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /plugins`, `control-openhands browser bbox 'testid=skills-plugins-screen'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser eval \"(()=>{const s=document.querySelector('[data-testid=plugins-search-input]').getBoundingClientRect(), f=document.querySelector('[data-testid=plugins-status-filter]').getBoundingClientRect();return {searchBottom:Math.round(s.bottom), filterTop:Math.round(f.top), filterWidth:Math.round(f.width)}})()\"`: `filterTop` is below `searchBottom` (stacked) and `filterWidth` is `348`. `control-openhands browser screenshot --feature F19.phone --name list` shows one column of cards. Open `testid=plugin-card-city-weather` and `control-openhands browser bbox 'testid=plugin-detail-modal'` (`insideViewport` `true`, `browser screenshot --feature F19.phone --name detail`), Escape; `control-openhands browser click 'testid=plugins-add-plugin-button'` and `control-openhands browser bbox 'testid=add-plugin-modal'` (`insideViewport` `true`), Escape. The parameters link from above gives `control-openhands browser bbox 'testid=plugin-launch-modal'` `insideViewport` `true` (`browser screenshot --feature F19.phone --name launch`). Hub entry: `control-openhands browser goto /customize`, `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/plugins' --expect-url '/plugins(\\?|$)'` and `control-openhands browser click 'testid=sidebar-mobile-back-button' --expect-url '/customize(\\?|$)'`. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F19.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Errors sweep (`F19.page`).",
          "body": "`control-openhands browser errors --app-only` shows no page errors; the only app HTTP errors are the deliberate ones (refresh 500, install 400, 502s while the Agent Server was stopped).",
          "ids": [
            "F19.page"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F20",
      "title": "Canvas apps",
      "file": "F20-canvas-apps.md",
      "page": "F20-canvas-apps.html",
      "sha256": "51b238415c03df0b519aca9ae8f4be3caabf30ff8300170d7ad60e5971a107d9",
      "behaviors": [
        {
          "id": "F20.entry-points",
          "description": "the Apps page is reached from the sidebar **Customize** link (lands on `/mcp`) then **Apps** in the Customize navigation, from the command menu's **Customize** command plus **Apps**, from the phone Customize hub, and by direct URL `/apps`; there is no bare `/extensions` page and no \"Apps\" command."
        },
        {
          "id": "F20.page",
          "description": "`/apps` shows the title **Apps for Agent Canvas**, a description, a **Build an app** docs link (new tab), an enabled **Add app** button, the amber trust notice, the **Installed apps** heading and, with nothing installed, `No apps are installed on this backend.`"
        },
        {
          "id": "F20.add-modal",
          "description": "**Add app** opens a form with **App source** (required), **Ref** and **Path**; **Install** stays disabled while the source is empty or blank; **Close**, the X, Escape and a backdrop click close it."
        },
        {
          "id": "F20.install",
          "description": "installing from a local path shows **Installing…**, toasts `App installed. Review it here, then enable it when you are ready.`, closes the form and lists the app disabled (after reload too) with no rail entry; a local **App source** plus **Path** is joined into one path."
        },
        {
          "id": "F20.install-error",
          "description": "a failed install toasts the server's reason (`Could not read canvas extension source: …`) and keeps the form open with the input, for local paths and for unreachable Git sources alike."
        },
        {
          "id": "F20.git-tree-url",
          "description": "a Git host folder URL (`…/<owner>/<repo>/tree/<ref>/<path>`) pasted into **App source** is split into source, ref and path; the card shows the repo URL as source, **Ref** (resolved commit) and **Path**."
        },
        {
          "id": "F20.git-ref-path",
          "description": "the **Ref** and **Path** fields apply to a Git source, including the placeholder's `github:owner/repo` shorthand; the card keeps the source as typed and shows the resolved commit and the path. Enter in a field submits the form."
        },
        {
          "id": "F20.install-duplicate",
          "description": "installing an app whose name is already installed is refused (`409`): the form stays open with its input, the toast says the app is already installed and points to **Update** or **Uninstall** on its card, and the existing card is unchanged."
        },
        {
          "id": "F20.card",
          "description": "each card shows the display name, source, a `role=switch` (**Enable**/**Disable**), the description, `Disabled`/`Enabled`, `v<version>` and `Pages: N` pills, each page title, and **Update** and **Uninstall** buttons."
        },
        {
          "id": "F20.enable-confirm",
          "description": "switching a disabled app on (click or Space) opens a trust confirmation (`confirmation-modal`) with **Cancel** and **Enable trusted app**; Cancel, Escape and a backdrop click leave it disabled, confirming enables it (persists after reload)."
        },
        {
          "id": "F20.rail-entry",
          "description": "an enabled app's page appears in the main sidebar rail as `sidebar-canvas-extension-<name>-<pageId>` linking to `/extensions/<name>/<path>`."
        },
        {
          "id": "F20.rail-active",
          "description": "on the app's page its rail entry is marked current (`aria-current=\"page\"`, highlighted); with the sidebar collapsed the entry is an icon whose tooltip and `aria-label` carry the label. App pages are not listed in the command menu."
        },
        {
          "id": "F20.rail-label",
          "description": "the rail entry is labelled with the page's `nav_label` (`Extension demo` for the demo fixture), falling back to its title."
        },
        {
          "id": "F20.page-render",
          "description": "the rail entry and the direct URL mount the app into `main` named after the page title (demo: h1 `Hello from a Canvas Extension`); a deeper URL passes the rest of the path to the app (`Nested extension path: nested`)."
        },
        {
          "id": "F20.app-backend-view",
          "description": "an app page learns from the host whether it has a backend view; the host offers one only when the Agent Server advertises the `canvas_app_backend_bridge_v1` capability with an app-backend ingress URL, so on the pinned server the demo's status line reads `App backend view unavailable` on every path of its page."
        },
        {
          "id": "F20.page-unavailable",
          "description": "an unknown app, an unknown page of a known app, or a disabled or uninstalled app shows **Not available** / `This app is disabled, missing, or does not provide this page.`"
        },
        {
          "id": "F20.phone",
          "description": "at 390 px the Customize hub lists Apps, the Apps page, the Add form and an app page fit without horizontal overflow, and the app's entry is in the sidebar drawer."
        },
        {
          "id": "F20.refresh",
          "description": "**Update** re-installs from the recorded source, toasts `App updated.` and keeps the enabled state; a failed update toasts the error and leaves the card unchanged."
        },
        {
          "id": "F20.persist-restart",
          "description": "installed apps, their enabled state and rail entries survive a stack restart."
        },
        {
          "id": "F20.disable",
          "description": "switching an enabled app off happens immediately without confirmation, removes its rail entry and makes its page unavailable."
        },
        {
          "id": "F20.uninstall",
          "description": "**Uninstall** asks `Uninstall <display name> from this backend?`; Escape or Cancel keep the app, **Confirm** toasts `App uninstalled.` and removes the card and the rail entry."
        },
        {
          "id": "F20.busy-lock",
          "description": "while Update, Uninstall or Enable is pending, every card control is inert, the switch included."
        },
        {
          "id": "F20.load-error",
          "description": "when listing apps fails, the Installed section shows the error and a **Retry** button."
        },
        {
          "id": "F20.unsupported",
          "description": "on a Cloud backend, with no backend, or on an Agent Server without the apps API, the Installed section shows **Not available** with the reason and **Add app** is disabled; Cloud hides Apps from the Customize navigation."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Sidebar entry and empty page (`F20.entry-points`, `F20.page`).",
          "body": "From `/` run `control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/mcp(\\?|$)'`, `control-openhands browser click 'testid=sidebar-extensions-/apps' --expect-url '/apps(\\?|$)'`, then `control-openhands browser snapshot 'testid=canvas-extensions-screen >> main'`. The snapshot shows heading `Apps for Agent Canvas`, the description `Apps are built with the Canvas Extensions API`, link `Build an app`, button `Add app`, the trust notice, heading `Installed apps` and paragraph `No apps are installed on this backend.` `control-openhands browser enabled 'testid=canvas-extensions-add-button'` is `true` and `control-openhands browser attr 'role=link[name=\"Build an app\"]' target` is `_blank`. `control-openhands browser screenshot --feature F20.page --name empty`.",
          "ids": [
            "F20.entry-points",
            "F20.page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command-menu entry (`F20.entry-points`).",
          "body": "From `/` run `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' Apps` and `control-openhands browser snapshot 'testid=command-menu'`: it reads `No commands found`. Then `control-openhands browser fill 'testid=command-menu >> role=combobox' Customize`, `control-openhands browser press Enter`, `control-openhands browser wait-url '/mcp(\\?|$)'` and `control-openhands browser click 'testid=sidebar-extensions-/apps' --expect-url '/apps(\\?|$)'`. `control-openhands browser goto /extensions` and `control-openhands browser snapshot 'testid=not-found-screen'` show the app's not-found page, like any unknown path: heading `Page not found`, `This address does not match any page. Check the URL, or go back to the home page.` and link `Home`.",
          "ids": [
            "F20.entry-points"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Add form and its closers (`F20.add-modal`).",
          "body": "On `/apps` (`control-openhands browser goto /apps`; the previous bullet ends on the `/extensions` not-found page) run `control-openhands browser click 'testid=canvas-extensions-add-button'` and `control-openhands browser snapshot 'testid=add-canvas-extension-modal'`: heading `Add app`, intro `Install from a Git source or a path on the active Agent Server. The app will be installed disabled.`, textboxes `App source`, `Ref Optional`, `Path Optional`, buttons `Close` and `Install [disabled]`. After `control-openhands browser fill 'testid=add-canvas-extension-source-input' '   '`, `control-openhands browser enabled 'testid=add-canvas-extension-submit'` is still `false`. Each of `control-openhands browser click 'testid=add-canvas-extension-dismiss'`, `control-openhands browser click 'testid=add-canvas-extension-modal-close'`, `control-openhands browser press Escape` and `control-openhands browser mouse-click 20 500` (backdrop) closes it: `control-openhands browser count 'testid=add-canvas-extension-modal'` is `0` (reopen with `canvas-extensions-add-button` between them).",
          "ids": [
            "F20.add-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Install error, local path (`F20.install-error`).",
          "body": "Open the form, run `control-openhands browser fill 'testid=add-canvas-extension-source-input' /nonexistent/qa-app`, `control-openhands browser click 'testid=add-canvas-extension-submit'`, then `control-openhands browser toasts`: `Could not read canvas extension source: Local extension path does not exist: /nonexistent/qa-app`. `browser count 'testid=add-canvas-extension-modal'` stays `1` and the input keeps its value. `control-openhands browser errors --app-only` lists the expected `400` on `POST /api/canvas-extensions/install`.",
          "ids": [
            "F20.install-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Install error, Git source (`F20.install-error`).",
          "body": "In the same form run `control-openhands browser toasts --clear`, `control-openhands browser fill 'testid=add-canvas-extension-source-input' 'http://127.0.0.1:9/qa-owner/qa-missing'`, `control-openhands browser click 'testid=add-canvas-extension-submit'`, `control-openhands browser wait 'testid=add-canvas-extension-submit >> text=Install' --timeout 60000`, then `control-openhands browser toasts --history` and `control-openhands browser screenshot --feature F20.install-error --name git-server-reason`. The history holds one toast with the server's 400 detail, `Could not read canvas extension source: Failed to fetch extension from http://127.0.0.1:9/qa-owner/qa-missing` (`browser toasts` may still list the local-path toast below it). The form stays open with the URL.",
          "ids": [
            "F20.install-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Install the demo (`F20.install`).",
          "body": "In the open form run `control-openhands browser fill 'testid=add-canvas-extension-source-input' \"$PWD/src/fixtures/canvas-extensions/demo-page\"` and `control-openhands browser click 'testid=add-canvas-extension-submit' --observe 'testid=add-canvas-extension-submit'`: the observed states are `Install`, `Installing… [disabled]`, `<absent>`. `control-openhands browser toasts` shows `App installed. Review it here, then enable it when you are ready.` Then `control-openhands browser reload`, `control-openhands browser attr 'testid=canvas-extension-card-demo-page >> role=switch' aria-checked` (`false`) and `control-openhands browser count 'testid=sidebar-canvas-extension-demo-page-hello'` (`0`).",
          "ids": [
            "F20.install"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Card (`F20.card`).",
          "body": "Run `control-openhands browser snapshot 'testid=canvas-extension-card-demo-page'`: heading `Demo page`, the source path, switch `Enable`, `A dependency-free fixture for the Canvas Extension page ABI.`, text `Disabled v0.1.0 Pages: 1`, list item `Hello from an extension`, buttons `Update` and `Uninstall`. `control-openhands browser screenshot --feature F20.card --name disabled`.",
          "ids": [
            "F20.card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Trust confirmation (`F20.enable-confirm`).",
          "body": "Run `control-openhands browser click 'testid=canvas-extension-card-demo-page >> role=switch'` and `control-openhands browser snapshot 'testid=confirmation-modal'`: the paragraph `This app runs trusted JavaScript inside Agent Canvas and can make authenticated requests to the active Agent Server. Review its source and revision before enabling it.`, buttons `Cancel` and `Enable trusted app`. `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'` closes it and `aria-checked` stays `false`. Keyboard: `control-openhands browser focus 'testid=canvas-extension-card-demo-page >> role=switch'` and `control-openhands browser press Space` open it again (`browser count 'testid=confirmation-modal'` is `1`); `control-openhands browser press Escape` closes it and `api GET /api/canvas-extensions/installed` still says `\"enabled\": false`. Backdrop: click the switch again, `control-openhands browser mouse-click 20 500`, then `browser count 'testid=confirmation-modal'` is `0` and the API still says `\"enabled\": false`. Reopen with the click, then `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`, `control-openhands browser reload` and read `aria-checked`: `true`; `control-openhands browser text 'testid=canvas-extension-card-demo-page'` contains `Enabled`.",
          "ids": [
            "F20.enable-confirm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Rail entry (`F20.rail-entry`, `F20.rail-label`).",
          "body": "After enabling, run `control-openhands browser wait 'testid=sidebar-canvas-extension-demo-page-hello' --timeout 10000`, `control-openhands browser attr 'testid=sidebar-canvas-extension-demo-page-hello' href` (`/extensions/demo-page/hello`) and `control-openhands browser text 'testid=sidebar-canvas-extension-demo-page-hello'`. Expected `Extension demo` (the fixture's `nav_label`); today it reads `Hello from an extension` (see Gotchas).",
          "ids": [
            "F20.rail-entry",
            "F20.rail-label"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Render the page (`F20.page-render`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-canvas-extension-demo-page-hello' --expect-url '/extensions/demo-page/hello(\\?|$)'` and `control-openhands browser snapshot 'role=main[name=\"Hello from an extension\"]'`: heading `Hello from a Canvas Extension` and paragraph `Host API 1 on backend default-local`. Direct URL: `control-openhands browser goto /extensions/demo-page/hello/nested` and the same snapshot show the paragraph `Nested extension path: nested`. `control-openhands browser screenshot --feature F20.page-render --name rail`.",
          "ids": [
            "F20.page-render"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "App backend view (`F20.app-backend-view`).",
          "body": "Still on `/extensions/demo-page/hello/nested` (where Render the page ends) run `control-openhands browser text 'testid=demo-extension-app-backend-status'`, `control-openhands api GET /server_info --pick capabilities` and `control-openhands api GET /server_info --pick app_backend_ingress_url`. The host hands a page `appBackendView` only when the capabilities list `canvas_app_backend_bridge_v1` and the ingress URL is not `null`: with the pinned server (1.53.0: neither) the line reads `App backend view unavailable`; a server advertising both would make the demo read `App backend view available` (not driven here). `control-openhands browser goto /extensions/demo-page/hello` shows the same line, `control-openhands browser snapshot 'role=main[name=\"Hello from an extension\"]'` lists it as the last paragraph, and `control-openhands browser screenshot --feature F20.app-backend-view --name unavailable`.",
          "ids": [
            "F20.app-backend-view"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Active and collapsed rail entry (`F20.rail-active`).",
          "body": "With the demo enabled, run `control-openhands browser goto /apps`; `control-openhands browser attr 'testid=sidebar-canvas-extension-demo-page-hello' aria-current` is `null`; after `control-openhands browser click 'testid=sidebar-canvas-extension-demo-page-hello' --expect-url '/extensions/demo-page/hello(\\?|$)'` it is `page`. Run `control-openhands browser click 'testid=sidebar-collapse-toggle'`: `browser visible 'testid=sidebar-canvas-extension-demo-page-hello'` stays `true` (about 40 px wide), `browser attr ... aria-label` and `control-openhands browser tooltip 'testid=sidebar-canvas-extension-demo-page-hello'` read `Hello from an extension`; `control-openhands browser screenshot --feature F20.rail-active --name collapsed` shows the highlighted icon. Expand again with `browser click 'testid=sidebar-collapse-toggle'` (its `aria-label` is `Collapse sidebar` again). Command menu: `browser goto /`, `browser press Control+k`, `browser type 'testid=command-menu >> role=combobox' Hello` and `browser snapshot 'testid=command-menu'` read `No commands found`; `browser press Escape`.",
          "ids": [
            "F20.rail-active"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Unavailable page (`F20.page-unavailable`).",
          "body": "Run `control-openhands browser goto /extensions/qa-missing/x` and `control-openhands browser snapshot 'role=main'`: heading `Not available` and `This app is disabled, missing, or does not provide this page.` `control-openhands browser goto /extensions/demo-page/nope` shows the same card while the demo is enabled.",
          "ids": [
            "F20.page-unavailable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Phone (`F20.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /customize` (the URL stays `/customize`), `control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/apps' --expect-url '/apps(\\?|$)'`, `control-openhands browser bbox 'testid=canvas-extensions-screen'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F20.phone --name apps`. Open the form with `canvas-extensions-add-button`; `control-openhands browser bbox 'testid=add-canvas-extension-modal'` is inside the viewport with no page overflow; close it with `add-canvas-extension-dismiss`. Then `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'`, `control-openhands browser visible 'testid=sidebar-mobile-drawer >> testid=sidebar-canvas-extension-demo-page-hello'` (`true`), `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=sidebar-canvas-extension-demo-page-hello' --expect-url '/extensions/demo-page/hello(\\?|$)'` and `control-openhands browser bbox 'role=main[name=\"Hello from an extension\"]'` (inside, no overflow). Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F20.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Update (`F20.refresh`).",
          "body": "Note `installed_at` from `control-openhands api GET /api/canvas-extensions/installed`, then on `/apps` (`control-openhands browser goto /apps`; Phone ends on the extension page) run `control-openhands browser click 'testid=canvas-extension-refresh-demo-page'` and `control-openhands browser toasts` (`App updated.`). The API shows a newer `installed_at` and keeps `\"enabled\": true`; `browser count 'testid=sidebar-canvas-extension-demo-page-hello'` stays `1`.",
          "ids": [
            "F20.refresh"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Survives a restart (`F20.persist-restart`).",
          "body": "Run `control-openhands restart`, `control-openhands doctor`, `control-openhands browser goto /`, `control-openhands browser wait 'testid=sidebar-canvas-extension-demo-page-hello' --timeout 15000`, then `control-openhands browser goto /apps` and read the switch's `aria-checked`: `true`.",
          "ids": [
            "F20.persist-restart"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Update with the backend down (`F20.refresh`).",
          "body": "On `/apps` run `control-openhands service stop agent-server`, `control-openhands browser click 'testid=canvas-extension-refresh-demo-page'` and `control-openhands browser toasts`: one toast, `An error occurred` (the ingress answers the Update's `POST /api/canvas-extensions/install` with a `502` that carries no server reason; see Gotchas), and the card still shows `Enabled` (`browser screenshot --feature F20.refresh --name backend-down`). Bring the stack back with `control-openhands restart` and `control-openhands doctor` before going on.",
          "ids": [
            "F20.refresh"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Disable (`F20.disable`).",
          "body": "On `/apps` run `control-openhands browser click 'testid=canvas-extension-card-demo-page >> role=switch'`; `control-openhands browser count 'testid=confirmation-modal'` is `0` and `control-openhands browser wait 'testid=sidebar-canvas-extension-demo-page-hello' --state detached --timeout 10000` succeeds. After `control-openhands browser reload` the switch's `aria-checked` is `false`; `control-openhands browser goto /extensions/demo-page/hello` and `browser snapshot 'role=main'` show `Not available`.",
          "ids": [
            "F20.disable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Uninstall (`F20.uninstall`).",
          "body": "Re-enable the demo on `/apps` (`control-openhands browser goto /apps`, since Disable ends on the extension page; the switch, then `testid=confirmation-modal >> testid=confirm-button`, then `browser wait 'testid=sidebar-canvas-extension-demo-page-hello'`). Run `control-openhands browser click 'testid=canvas-extension-uninstall-demo-page'` and `control-openhands browser snapshot 'testid=confirmation-modal'`: `Uninstall Demo page from this backend?`, buttons `Cancel` and `Confirm`. `control-openhands browser press Escape` leaves `browser count 'testid=canvas-extension-card-demo-page'` at `1`; so does clicking Uninstall again and `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'`. Click Uninstall again, `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'` and `control-openhands browser toasts` (`App uninstalled.`); `control-openhands browser wait 'testid=sidebar-canvas-extension-demo-page-hello' --state detached --timeout 10000` succeeds. After `control-openhands browser reload` the card count is `0`, the page shows `No apps are installed on this backend.`, and `/extensions/demo-page/hello` shows `Not available`.",
          "ids": [
            "F20.uninstall"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Local source plus Path (`F20.install`).",
          "body": "Run `control-openhands browser goto /apps` (the Uninstall check ends on `/extensions/demo-page/hello`), `control-openhands browser click 'testid=canvas-extensions-add-button'`, `control-openhands browser fill 'testid=add-canvas-extension-source-input' \"$PWD/src/fixtures/canvas-extensions\"`, `control-openhands browser fill 'testid=add-canvas-extension-repo-path-input' demo-page`, `control-openhands browser click 'testid=add-canvas-extension-submit'` and `control-openhands browser wait 'testid=add-canvas-extension-modal' --state detached --timeout 30000`. The toast is `App installed. …` and `api GET /api/canvas-extensions/installed` shows `\"source\"` ending in `/src/fixtures/canvas-extensions/demo-page` with `\"repo_path\": null`. Uninstall it as above (switch untouched, so no rail check).",
          "ids": [
            "F20.install"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Git tree URL (`F20.git-tree-url`).",
          "body": "Needs the Git precondition (upstream reachable, fixture identical). Run `control-openhands browser click 'testid=canvas-extensions-add-button'`, `control-openhands browser fill 'testid=add-canvas-extension-source-input' 'https://github.com/OpenHands/OpenHands/tree/main/src/fixtures/canvas-extensions/demo-page'` (Ref and Path empty), `control-openhands browser click 'testid=add-canvas-extension-submit' --observe 'testid=add-canvas-extension-submit'` (`Installing… [disabled]` for about 0.4 to 2 s, then `<absent>`) and `control-openhands browser wait 'testid=add-canvas-extension-modal' --state detached --timeout 90000`; the toast is `App installed. …`. After `control-openhands browser reload`, `control-openhands browser snapshot 'testid=canvas-extension-card-demo-page'` shows source `https://github.com/OpenHands/OpenHands`, switch `Enable`, term `Ref` with the 40-character commit from the precondition and term `Path` with `src/fixtures/canvas-extensions/demo-page`; `api GET /api/canvas-extensions/installed` has the same `source`, `resolved_ref` and `repo_path`. `control-openhands browser screenshot --feature F20.git-tree-url --name card`.",
          "ids": [
            "F20.git-tree-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Duplicate install (`F20.install-duplicate`).",
          "body": "With the Git-installed demo present, run `control-openhands browser errors --clear`, `control-openhands browser click 'testid=canvas-extensions-add-button'`, `control-openhands browser fill 'testid=add-canvas-extension-source-input' \"$PWD/src/fixtures/canvas-extensions/demo-page\"`, `control-openhands browser click 'testid=add-canvas-extension-submit'` and `control-openhands browser wait 'testid=add-canvas-extension-submit >> text=Install' --timeout 30000`. `control-openhands browser toasts` reads `This app is already installed. Use Update or Uninstall on its card.` (`browser screenshot --feature F20.install-duplicate --name already-installed`), `browser count 'testid=add-canvas-extension-modal'` stays `1`, `browser count '[data-testid^=\"canvas-extension-card-\"]'` stays `1`, `api GET /api/canvas-extensions/installed` still shows the GitHub `source`, and `browser errors --app-only` lists the expected `409` on `POST /api/canvas-extensions/install`. Close the form with `control-openhands browser press Escape`.",
          "ids": [
            "F20.install-duplicate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Busy lock (`F20.busy-lock`).",
          "body": "Needs the Git-installed demo from the Git tree URL bullet: its Update re-clones in about 0.4 to 2 s, so issue the next commands in the same shell line as the click. Disabled app: run `control-openhands browser click 'testid=canvas-extension-refresh-demo-page'`, then at once `control-openhands browser press Space --selector 'testid=canvas-extension-card-demo-page >> role=switch'` (focuses the switch, then presses Space) and `control-openhands browser eval \"[document.querySelector('[data-testid=canvas-extension-uninstall-demo-page]').disabled, document.querySelector('[data-testid=canvas-extension-card-demo-page] [role=switch]').disabled, document.activeElement.tagName].join(' ')\"`, which reads `true true BODY` (Uninstall and the switch are both disabled, and the busy switch takes no focus), then `control-openhands browser count 'testid=confirmation-modal'` (`0`: Space did nothing). Read the three values in that one `eval`: each `browser enabled` call takes about 150 ms and can land after a short Update. If the `eval` reads `false` for either control, the Update ended mid-line and Space may have reached an idle switch: close any trust dialog with `control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'` and run the line again. Once `control-openhands browser toasts --history` shows `App updated.`, the keyboard works again: `browser enabled` on the switch is `true`, and `browser focus` on it, `browser press Space` and `browser count 'testid=confirmation-modal'` give `1`. Close the dialog with `testid=confirmation-modal >> testid=cancel-button`; `api GET /api/canvas-extensions/installed --pick canvas_extensions.0.enabled` is still `false`. Enabled app (only when the card's Ref equals the precondition's commit): enable it through the trust dialog and wait for `sidebar-canvas-extension-demo-page-hello`, then repeat the same line (click Update, Space on the switch, the `eval` reads `true true BODY`, no dialog). If the `eval` reads `false` for either control there and Space reached the idle switch, it turned the app off with no dialog (`api GET /api/canvas-extensions/installed --pick canvas_extensions.0.enabled` is `false`): enable it again through the trust dialog, wait for `sidebar-canvas-extension-demo-page-hello` and repeat the line. A few seconds later `api GET /api/canvas-extensions/installed --pick canvas_extensions.0.enabled` is still `true`, and after `browser reload` the switch's `aria-checked` is `true` and `browser count 'testid=sidebar-canvas-extension-demo-page-hello'` is `1`. Clean up: uninstall as above and confirm `api GET /api/canvas-extensions/installed --pick canvas_extensions` is `[]`.",
          "ids": [
            "F20.busy-lock"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Ref and Path with the `github:` shorthand (`F20.git-ref-path`).",
          "body": "Needs the Git precondition. Run `control-openhands browser click 'testid=canvas-extensions-add-button'`, `control-openhands browser fill 'testid=add-canvas-extension-source-input' 'github:OpenHands/OpenHands'`, `control-openhands browser fill 'testid=add-canvas-extension-ref-input' main`, `control-openhands browser fill 'testid=add-canvas-extension-repo-path-input' src/fixtures/canvas-extensions/demo-page`, `control-openhands browser focus 'testid=add-canvas-extension-repo-path-input'`, `control-openhands browser press Enter` (Enter submits) and `control-openhands browser wait 'testid=add-canvas-extension-modal' --state detached --timeout 90000`; the toast is `App installed. …`. After `control-openhands browser reload`, `browser snapshot 'testid=canvas-extension-card-demo-page'` shows source `github:OpenHands/OpenHands`, `Ref` with the precondition's commit and `Path` `src/fixtures/canvas-extensions/demo-page`. Uninstall it as above.",
          "ids": [
            "F20.git-ref-path"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "List error with Retry (`F20.load-error`).",
          "body": "Blocked: it needs an Agent Server whose `GET /api/canvas-extensions/installed` fails with a non-404 while its health checks pass. `control-openhands service stop agent-server` then `control-openhands browser goto /apps` shows the app-wide **Manage backends** gate (`Disconnected`) instead of the page, and on an already loaded page the cached list stays on screen.",
          "ids": [
            "F20.load-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Unsupported backends (`F20.unsupported`).",
          "body": "Blocked: needs a Cloud backend account (`Apps are not available on Cloud backends yet.`, Apps hidden from Customize), a state with no backend (`Add an Agent Server backend to use Apps.`), or an Agent Server that passes Canvas's version gate (1.47.0 or newer) but has no `/api/canvas-extensions` routes (`This Agent Server does not support Apps yet. Upgrade the backend and try again.`). In each case **Add app** should be disabled. Released servers cannot produce the last state: the routes ship from 1.44.1, and an older server such as `control-openhands launch --new --sdk-ref v1.43.1` (whose `GET /api/canvas-extensions/installed` is `404`) never reaches `/apps`, because every page shows the **Manage backends** gate with `Agent Canvas requires agent-server 1.47.0 or newer; this backend is running 1.43.1.` (`doctor` still reports `ok`).",
          "ids": [
            "F20.unsupported"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F21",
      "title": "Automations dashboard and actions",
      "file": "F21-automations-dashboard.md",
      "page": "F21-automations-dashboard.html",
      "sha256": "d5d8144073fd12ad7deb39b21260a64d51083506fe6c1c6feb026ef812dafad0",
      "behaviors": [
        {
          "id": "F21.sidebar-entry",
          "description": "the sidebar **Automate** item opens `/automations` with the heading **Dashboard** and the Automate sub-page aside."
        },
        {
          "id": "F21.command-menu",
          "description": "the command menu offers **Automations** (\"Review scheduled and webhook automations.\"), also found by the keywords automate, cron, schedule, webhook and jobs; choosing it opens `/automations`."
        },
        {
          "id": "F21.pin-home",
          "description": "the pin on the sidebar Automate item makes `/` redirect to `/automations`; unpinning restores the normal home."
        },
        {
          "id": "F21.subpage-nav",
          "description": "Dashboard and Templates sub-pages appear in a left aside from 1024 px up and in a horizontal strip below that (phones and tablets); the current page has `aria-current=\"page\"`."
        },
        {
          "id": "F21.manifest-404",
          "description": "without an admitted automation interface manifest, `/automations` is a 404 from the layout error boundary and the Automate entries are hidden."
        },
        {
          "id": "F21.health-loading",
          "description": "while the health check runs, the page shows the title and three card skeletons."
        },
        {
          "id": "F21.list-loading",
          "description": "once the backend is healthy, the header, tiles and controls render at once while three card skeletons stand in for the list until the first page arrives."
        },
        {
          "id": "F21.backend-unavailable",
          "description": "when the health check fails, the page shows **Automations Unavailable** with a Retry button."
        },
        {
          "id": "F21.list-error",
          "description": "when the list request fails with nothing cached, the page shows **Failed to load automations** with Retry."
        },
        {
          "id": "F21.add-menu",
          "description": "the header **Add Automation** menu offers **Create Automation** and **Import automation**; Create Automation opens the **How to create an automation** dialog (instructions, docs link, Create Automation), which X and Escape close."
        },
        {
          "id": "F21.empty-state",
          "description": "with zero automations the page shows **No automations configured**, the \"Create an automation\" instructions, a docs link, **Create Automation** and the Recommended automations rail; the view toggle is disabled."
        },
        {
          "id": "F21.empty-rail",
          "description": "a Recommended automations card on the empty dashboard opens its template setup at `/automations/new/<template-id>` (F22 owns the setup flow)."
        },
        {
          "id": "F21.overview-tiles",
          "description": "four tiles summarize the loaded org list: Automations (`N active`), Needs attention (`Latest run failed` or `No latest-run failures`; an enabled automation counts when its latest run is Failed or its task outcome is `failed` or `blocked`, while Partial and Needs review do not count), Total runs and Average duration."
        },
        {
          "id": "F21.search",
          "description": "the search box narrows the visible automations client-side by name, prompt, repository or model; the tiles do not change."
        },
        {
          "id": "F21.filters",
          "description": "the **Filters** popover filters by status (All / Active / Needs attention / Disabled) and trigger (All / Scheduled / Event-driven), counts non-default choices in a badge and offers **Reset all**; it stays open while the user changes several choices."
        },
        {
          "id": "F21.sort",
          "description": "Sort (Latest run / Most runs / Name) orders automations inside each group and counts as a non-default choice."
        },
        {
          "id": "F21.filtered-empty",
          "description": "when nothing matches, **No automations match these filters** and **Clear filters** appear; Clear filters resets search, status and trigger but keeps the sort."
        },
        {
          "id": "F21.view-toggle",
          "description": "the view-mode menu switches Grid and List; the choice survives a reload."
        },
        {
          "id": "F21.card-grid",
          "description": "a grid card shows name, prompt preview, trigger pills, sparkline, a status strip (latest run status, summary, relative time, or **No activity yet**) and Runs / Recent success / Avg. duration stats."
        },
        {
          "id": "F21.list-row",
          "description": "a list row shows a health dot, name, schedule or event, relative last run, run badge, sparkline, Play and kebab; its link is labelled `<name> <status>`."
        },
        {
          "id": "F21.row-tooltip",
          "description": "hovering a list row shows a preview card with the name, Trigger, Status and, once it has run, Last run and Task (the run summary); the Play button's tooltip reads **Run now**."
        },
        {
          "id": "F21.summary-hovercard",
          "description": "hovering a grid card's truncated run summary shows the full summary in a hovercard."
        },
        {
          "id": "F21.sparkline-deeplink",
          "description": "a sparkline bar links to `/automations/<id>?run=<runId>`, which opens the detail page with that run highlighted."
        },
        {
          "id": "F21.open-detail",
          "description": "kebab **View**, the row link, a card click and Enter on a focused card all open `/automations/<id>`."
        },
        {
          "id": "F21.load-more",
          "description": "past 50 automations a **Load more** button fetches the next page and disappears when everything is loaded."
        },
        {
          "id": "F21.git-sync-button",
          "description": "the header **Git Sync** button (manage permission; always on local) opens `/automations/git-sync` (see F24)."
        },
        {
          "id": "F21.kebab-menu",
          "description": "the **Automation actions** menu lists Run now, View, Export, Edit, Turn on/Turn off and Delete, closes on Escape or an outside click, and flips above the trigger near the viewport bottom."
        },
        {
          "id": "F21.run-now",
          "description": "Play or kebab **Run now** on an enabled automation dispatches a run with the toast **Automation dispatched**; the card moves through Pending/Running to the result. Both are disabled on a disabled automation."
        },
        {
          "id": "F21.run-now-error",
          "description": "a failed dispatch shows exactly one error toast with the API message; when the automation is gone (`Automation not found`), its card then drops out of the dashboard without a reload."
        },
        {
          "id": "F21.toggle",
          "description": "kebab **Turn on** / **Turn off** flips the enabled flag immediately (no confirmation on the dashboard) and moves the automation between the Active and Inactive groups."
        },
        {
          "id": "F21.edit",
          "description": "kebab **Edit** opens the edit modal prefilled from the automation; Save persists (field-level editing is F23)."
        },
        {
          "id": "F21.export",
          "description": "kebab **Export** downloads `<name-slug>.automation.json` with `version`, `kind: \"automation\"` and the spec."
        },
        {
          "id": "F21.delete",
          "description": "kebab **Delete** asks `Delete \"<name>\"? This cannot be undone.`; Cancel and the X keep it, Delete removes it for good, and Escape dismisses the dialog."
        },
        {
          "id": "F21.phone",
          "description": "at 390 px the dashboard has no horizontal overflow, tiles wrap 2×2 and the kebab menu stays inside the viewport."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Sidebar entry (`F21.sidebar-entry`).",
          "body": "From `/` run `control-openhands browser click 'testid=sidebar-automations-link'`, `control-openhands browser url` and `control-openhands browser testids --filter automations-navigation`. The URL is `/automations`, the `main` heading is **Dashboard**, and `automations-navigation-list` and `automations-navigation-templates` are listed.",
          "ids": [
            "F21.sidebar-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Command menu (`F21.command-menu`).",
          "body": "Run `control-openhands browser goto /` (Sidebar entry ends on `/automations`, where the URL check would pass at once), `control-openhands browser press Control+k`, `control-openhands browser type 'testid=command-menu >> role=combobox' cron`, `control-openhands browser snapshot 'testid=command-menu'`, `control-openhands browser press Enter`, then `control-openhands browser url`. The snapshot lists exactly one option, `Automations Review scheduled and webhook automations. Go` (`[selected]`, under **Navigation**), and the URL ends in `/automations`.",
          "ids": [
            "F21.command-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Pin as home (`F21.pin-home`).",
          "body": "On any page run `control-openhands browser hover 'testid=sidebar-automations-link'`, `control-openhands browser click 'testid=sidebar-pin-home-toggle-automations'`, `control-openhands browser attr 'testid=sidebar-pin-home-toggle-automations' aria-pressed` (`true`; `aria-label` becomes `Unpin as home page`), then `control-openhands browser goto /` and `control-openhands browser url`: the URL is `/automations`. Unpin with the same hover and click (`aria-pressed` `false`); `control-openhands browser goto /` then stays on `/`.",
          "ids": [
            "F21.pin-home"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Sub-page navigation (`F21.subpage-nav`).",
          "body": "On `/automations` (`control-openhands browser goto /automations`; Pin as home ends on `/`) run `control-openhands browser click 'testid=automations-navbar-desktop >> testid=automations-navigation-templates'` (URL `/automations/templates`, h1 **Templates**), `control-openhands browser click 'testid=automations-navbar-desktop >> testid=automations-navigation-list'` and `control-openhands browser attr 'testid=automations-navbar-desktop >> testid=automations-navigation-list' aria-current` (`page`). Below 1024 px the same links live under `testid=automations-navbar-mobile` (see Phone), tablets included: run `control-openhands browser viewport tablet` (820 px), `control-openhands browser visible 'testid=automations-navbar-mobile'` (`true`) and `control-openhands browser visible 'testid=automations-navbar-desktop'` (`false`), then `control-openhands browser viewport desktop`.",
          "ids": [
            "F21.subpage-nav"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Backend unavailable (`F21.backend-unavailable`).",
          "body": "Run `control-openhands service stop automation`, `control-openhands browser goto /automations`, `control-openhands browser wait-text 'Automations Unavailable'`, `control-openhands browser text 'role=main'` and `control-openhands browser screenshot --feature F21.backend-unavailable --name unavailable`. The main pane reads `Dashboard`, the subtitle, `Automations Unavailable`, `The automations backend is not available right now. ...` and `Retry`; the sidebar Automate item stays. Then `control-openhands restart` (the page stays put), `control-openhands browser click 'role=button[name=\"Retry\"]'` and `control-openhands browser wait 'testid=automations-empty'`: the dashboard returns without a reload.",
          "ids": [
            "F21.backend-unavailable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "List error (`F21.list-error`).",
          "body": "The health result is cached for 30 s, and every page's sidebar checklist fetches it but not the 50-row list. Run `control-openhands browser goto /settings/secrets`, then within 30 s `control-openhands service stop automation`, `control-openhands browser click 'testid=sidebar-automations-link' --expect-url '/automations'` and `control-openhands browser wait-text 'Failed to load automations'`. The header, tiles (zeros) and Filters render above `Failed to load automations` and `Retry`; screenshot with `--feature F21.list-error --name list-error`. Then `control-openhands restart`, click `role=button[name=\"Retry\"]` and wait for `testid=automations-empty`. Starting from `/` does not work: home loads the same list, so the cached copy is shown instead of the error.",
          "ids": [
            "F21.list-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "List loading (`F21.list-loading`).",
          "body": "Run `control-openhands browser goto /settings/secrets`, then `control-openhands browser click 'testid=sidebar-automations-link' --observe '[data-testid=\"automation-card-skeleton\"], [data-testid=\"automations-add-automation\"]' --observe-ms 2000`. One observed state is `Add Automation` plus three empty skeleton entries (about 50 ms), followed by `Add Automation` alone.",
          "ids": [
            "F21.list-loading"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Health loading and missing manifest (`F21.health-loading`, `F21.manifest-404`).",
          "body": "Blocked. The health-only skeleton (title and three `automation-card-skeleton`, no header buttons) shows only on the first health fetch, which the sidebar checklist always makes before a user can reach the dashboard, and stopping the service makes the fetch fail at once rather than hang. The 404 needs a build without the automation interface manifest.",
          "ids": [
            "F21.health-loading",
            "F21.manifest-404"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Empty state (`F21.empty-state`).",
          "body": "With no automations run `control-openhands browser goto /automations`, `control-openhands browser text 'testid=automations-empty'`, `control-openhands browser enabled 'testid=automations-view-toggle'` and `control-openhands browser screenshot --feature F21.empty-state --name empty`. The text starts `No automations configured`, contains `Create an automation`, `Learn more in the documentation` and `Create Automation`, and lists the Recommended automations; `enabled` is `false`; the tiles read `Automations 0 / 0 active` and `Needs attention 0 / No latest-run failures`. **Create Automation** (`automations-create-automation`) starts a model-backed chat: see F22.",
          "ids": [
            "F21.empty-state"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Add Automation menu (`F21.add-menu`).",
          "body": "Run `control-openhands browser click 'testid=automations-add-automation'` and `control-openhands browser snapshot 'testid=automations-add-automation-menu'`: buttons `Create Automation` and `Import automation`. `control-openhands browser press Escape` (or a click on `role=heading[name=\"Dashboard\"]`) makes `browser count 'testid=automations-add-automation-menu'` `0`. Reopen it, `control-openhands browser click 'testid=automations-add-automation-create'` and `control-openhands browser snapshot 'role=dialog'`: `dialog \"How to create an automation\"` with Close, the instructions (`Create an automation` as code), `Learn more in the documentation` (`https://docs.openhands.dev/openhands/usage/automations/overview`) and `Create Automation` (`automations-create-automation`, model-backed, F22). `control-openhands browser click 'testid=add-automation-modal-close'` and `browser press Escape` each make `browser count 'testid=add-automation-modal'` `0`.",
          "ids": [
            "F21.add-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Recommended rail on the empty dashboard (`F21.empty-rail`).",
          "body": "With no automations, `control-openhands browser click 'testid=automations-empty >> testid=recommended-automation-rail-card-custom-automation' --expect-url 'automations/new'` lands on `/automations/new/custom-automation` with the **Before you start** prerequisites dialog (F22). `control-openhands browser back` returns to `/automations`; there `browser attr 'testid=recommended-automations-rail-fade-left' data-visible` is `false` and `...-fade-right` is `true` (scrolling is F03.recommended-automations-rail).",
          "ids": [
            "F21.empty-rail"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Arrange two automations (import, F22).",
          "body": "Run `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-import-automation'`, `control-openhands browser upload 'testid=automations-import-file' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f21-alpha.automation.json\"`, `control-openhands browser click 'testid=import-automation-confirm'`, `control-openhands browser wait 'testid=import-automation-modal' --state detached`. Repeat with `qa-f21-beta.automation.json`. Both arrive disabled, under **Inactive 2**.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Grid cards (`F21.card-grid`).",
          "body": "Run `control-openhands browser reload`, `control-openhands browser snapshot 'role=main'` and `control-openhands browser screenshot --feature F21.card-grid --name grid`. Each card shows its name, prompt, pills (`pull_request.opened` + GitHub logo for Beta, `cron` for Alpha), **No activity yet**, and `Runs 0 / Recent success — / Avg. duration —`; both Play buttons are `[disabled]`.",
          "ids": [
            "F21.card-grid"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Kebab menu (`F21.kebab-menu`).",
          "body": "Run `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Alpha >> role=button[name=\"Automation actions\"]'` and `control-openhands browser snapshot 'role=list >> has-text=Export'`: buttons `Run now [disabled]`, `View`, `Export`, `Edit`, `Turn on`, `Delete`. `control-openhands browser press Escape`, then `control-openhands browser count 'role=list >> has-text=Export'` is `0`. Reopen it and click `role=heading[name=\"Dashboard\"]`; the count is `0` again.",
          "ids": [
            "F21.kebab-menu"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Turn on (`F21.toggle`).",
          "body": "Open Alpha's kebab as above, run `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Turn on\"]'`, then `control-openhands browser reload` and `control-openhands browser snapshot 'role=main'`. No dialog appears; an **Active 1** group holds QA_F21 Alpha, the Automations tile reads `2 / 1 active`, and Alpha's Play is enabled (`control-openhands browser enabled '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Alpha >> role=button[name=\"Run now\"]'` is `true`).",
          "ids": [
            "F21.toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Run now from Play (`F21.run-now`).",
          "body": "Run `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Alpha >> role=button[name=\"Run now\"]'`, `control-openhands browser wait-text 'Automation dispatched'` and `control-openhands browser screenshot --feature F21.run-now --name toast`. The URL stays `/automations`, the status strip reads **Pending**, then **Running**, and Total runs becomes `1`. About 40 s later, after a reload, the strip reads **Successful** with the agent's summary and the sidebar lists a conversation titled `QA_F21 Alpha — <UTC timestamp>` (`control-openhands conversation list`).",
          "ids": [
            "F21.run-now"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "List view (`F21.view-toggle`, `F21.list-row`).",
          "body": "Run `control-openhands browser click 'testid=automations-view-toggle'`, `control-openhands browser click 'testid=automations-view-toggle-list'`, `control-openhands browser reload`, `control-openhands browser count '[data-testid^=\"automation-list-row-\"]'` (`2`) and `control-openhands browser eval \"localStorage.getItem('openhands-automations-view')\"` (`\"list\"`). Then `control-openhands browser text '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Alpha'` reads `QA_F21 Alpha / 0 0 1 1 * / <relative time> / <status>`, and `control-openhands browser attr '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Alpha >> role=link >> nth=0' aria-label` is `QA_F21 Alpha <status>` (for example `QA_F21 Alpha Running`). Screenshot with `control-openhands browser screenshot --feature F21.list-row --name list`. Escape closes the open view menu (`control-openhands browser count 'testid=automations-view-toggle-list'` is `0`).",
          "ids": [
            "F21.view-toggle",
            "F21.list-row"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Hover previews (`F21.row-tooltip`, `F21.summary-hovercard`).",
          "body": "After Alpha's run has succeeded, in list view run `control-openhands browser tooltip '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Alpha >> role=link >> nth=0'`: `QA_F21 Alpha / Trigger / 0 0 1 1 * / Status / Successful / Last run / <relative time> / Task / <the run summary>` (a row without runs shows only Trigger and `Status / No activity yet`). `control-openhands browser tooltip '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Alpha >> role=button[name=\"Run now\"]'` is `Run now`. In grid view (`control-openhands browser click 'testid=automations-view-toggle'`, `control-openhands browser click 'testid=automations-view-toggle-grid'`), `control-openhands browser tooltip '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Alpha >> text=Replied'` (the start of the truncated summary) returns the full summary, for example `Replied with the single word \"pong\" as requested, without running any tools.` Switch back with `testid=automations-view-toggle` and `testid=automations-view-toggle-list`: the next bullets count list rows.",
          "ids": [
            "F21.row-tooltip",
            "F21.summary-hovercard"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Sparkline deep link (`F21.sparkline-deeplink`).",
          "body": "After Alpha has a run, run `control-openhands browser attr '[data-testid^=\"automation-activity-\"] >> role=link >> nth=0' href`, `control-openhands browser click '[data-testid^=\"automation-activity-\"] >> role=link >> nth=0'`, `control-openhands browser url` and `control-openhands browser testids --filter run`. The href and URL are `/automations/<id>?run=<runId>`, and `automation-run-highlight-<runId>` is visible. Return with `control-openhands browser goto /automations`.",
          "ids": [
            "F21.sparkline-deeplink"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Search (`F21.search`).",
          "body": "Run `control-openhands browser fill 'role=textbox[name=\"Search automations...\"]' pong` and `control-openhands browser count '[data-testid^=\"automation-list-row-\"]'`. The count is `1` (Alpha matches by prompt only). Then fill `zzqa-nomatch`; `control-openhands browser text 'testid=overview-tile-automations'` still reads `Automations 2 / 1 active`.",
          "ids": [
            "F21.search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Filtered empty and Clear filters (`F21.filtered-empty`).",
          "body": "With `zzqa-nomatch` in the search box run `control-openhands browser text 'testid=automations-filtered-empty'` (`No automations match these filters` / `Clear filters`), `control-openhands browser click 'testid=automations-clear-filters'`, `control-openhands browser value 'role=textbox[name=\"Search automations...\"]'` (empty) and the row count (`2`).",
          "ids": [
            "F21.filtered-empty"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Filters (`F21.filters`).",
          "body": "Run `control-openhands browser click 'testid=automations-filters >> testid=dropdown-trigger >> nth=0'`, `control-openhands browser snapshot 'testid=automations-filters-menu'` (Filter by status `All statuses`, Filter by trigger `All triggers`, Sort automations `Latest run`), `control-openhands browser click 'testid=automations-filter-status >> testid=dropdown-trigger'`, `control-openhands browser click 'testid=automations-filter-status-disabled'`, then `control-openhands browser text 'testid=automations-filters >> testid=dropdown-trigger >> nth=0'` (`Filters 1`) and the row text (only QA_F21 Beta). The popover stays open: `control-openhands browser count 'testid=automations-filters-menu'` is `1` and `control-openhands browser attr 'testid=automations-filters >> testid=dropdown-trigger >> nth=0' aria-expanded` is `true`. In the same popover run `control-openhands browser click 'testid=automations-filter-trigger >> testid=dropdown-trigger'` and `control-openhands browser click 'testid=automations-filter-trigger-schedule'`: the badge is `Filters 2`, `testid=automations-filtered-empty` appears and the menu count is still `1` (`control-openhands browser screenshot --feature F21.filters --name two-filters`). `control-openhands browser click 'testid=automations-filters-reset'`: both rows return, the button reads `Filters` and the popover stays open. Trigger **Event-driven** (`automations-filter-trigger-event`, chosen in the same open popover) alone leaves only QA_F21 Beta. An outside click closes the popover: `control-openhands browser click 'role=heading[name=\"Dashboard\"]'`, then the menu count is `0` and `aria-expanded` is `false`. Run `control-openhands browser reload` to clear the filter (filters are page state): the next bullets need Alpha visible.",
          "ids": [
            "F21.filters"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Turn off (`F21.toggle`).",
          "body": "In list view run `control-openhands browser click '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Alpha >> role=button[name=\"Automation actions\"]'`, `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Turn off\"]'`, `control-openhands browser count 'testid=confirmation-modal'` (`0`, no confirmation), `control-openhands browser reload`. The page shows **Inactive 2** with the tile at `0 active`, and Play reports `enabled` `false`.",
          "ids": [
            "F21.toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Edit from the dashboard (`F21.edit`).",
          "body": "Turn off (above) left both automations in the Inactive group, as Sort needs. Open Alpha's kebab, `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Edit\"]'`, `control-openhands browser value 'testid=edit-automation-name'` (`QA_F21 Alpha`; `edit-automation-cron` is `0 0 1 1 *`, `edit-automation-model` is `Active profile`), `control-openhands browser fill 'testid=edit-automation-name' 'QA_F21 Zulu'`, `control-openhands browser click 'testid=edit-automation-save'`, `control-openhands browser wait 'testid=edit-automation-save' --state detached`, `control-openhands browser reload`. `control-openhands api GET /api/automation/v1` shows `QA_F21 Zulu` with the schedule unchanged.",
          "ids": [
            "F21.edit"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Sort (`F21.sort`).",
          "body": "With QA_F21 Zulu (1 run) and QA_F21 Beta (no runs) both inactive, open Filters, `control-openhands browser click 'testid=automations-sort >> testid=dropdown-trigger'`, `control-openhands browser click 'testid=automations-sort-name'`, then `control-openhands browser text '[data-testid^=\"automation-list-row-\"] >> nth=0'`. Beta comes first, the badge reads `Filters 1` and the popover stays open. Fill the search with `zzqa-nomatch` and click `testid=automations-clear-filters` (a click outside the popover, which closes it): the badge stays `Filters 1` and Beta stays first (the sort is kept). Open Filters and the sort field again and choose `automations-sort-runs`; Zulu comes first. Close the popover with `control-openhands browser click 'role=heading[name=\"Dashboard\"]'` (`browser count 'testid=automations-filters-menu'` is `0`).",
          "ids": [
            "F21.sort"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Export (`F21.export`).",
          "body": "Open Zulu's kebab, `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Export\"]'`, then `control-openhands browser downloads --last 1 --inspect`. The download path ends in `qa-f21-zulu.automation.json` (under `<run>/private/downloads/`); the inspected head holds `\"version\": 1`, `\"kind\": \"automation\"` and a `spec` with name, trigger, `enabled`, prompt, `model` (`null` for Active profile), timeout and timezone.",
          "ids": [
            "F21.export"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Open the detail page (`F21.open-detail`).",
          "body": "Kebab `role=list >> has-text=Export >> role=button[name=\"View\"]`, `control-openhands browser click '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F21 Zulu >> role=link >> nth=0'`, `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Zulu >> role=heading'` (grid: switch first with `control-openhands browser click 'testid=automations-view-toggle'` and `control-openhands browser click 'testid=automations-view-toggle-grid'`; the later bullets use cards), and `control-openhands browser focus '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Beta'` + `control-openhands browser press Enter` each make `control-openhands browser url` read `/automations/<that automation's id>`. Go back with `control-openhands browser back`.",
          "ids": [
            "F21.open-detail"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Git Sync button (`F21.git-sync-button`).",
          "body": "`control-openhands browser click 'testid=automations-git-sync'`, then `control-openhands browser url`: `/automations/git-sync` (F24).",
          "ids": [
            "F21.git-sync-button"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Needs attention, kebab Run now (`F21.overview-tiles`, `F21.run-now`, `F21.filters`).",
          "body": "Run `control-openhands browser goto /automations` (the Git Sync button left `/automations/git-sync`), import `qa-f21-fail.automation.json` as above, turn it on from its kebab, then `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Fail >> role=button[name=\"Automation actions\"]'`, `control-openhands browser enabled 'role=list >> has-text=Export >> role=button[name=\"Run now\"]'` (`true`), click it, `control-openhands browser wait-text 'Automation dispatched'`. The 1 s timeout run ends `FAILED` after about 3-4 minutes (check with `control-openhands api GET /api/automation/v1/<id>/runs`; the id is in `api GET /api/automation/v1`). Then `control-openhands browser reload`, `control-openhands browser text 'testid=overview-tile-needs-attention'` (`Needs attention 1 / Latest run failed`), and `control-openhands browser screenshot --feature F21.overview-tiles --name needs-attention`. The Fail card shows **Failed** with `Timed out: command timed out or was killed`, a red sparkline bar and `Recent success 0%`; Total runs is `2`. Open Filters and choose status `automations-filter-status-failing` (`testid=automations-filter-status >> testid=dropdown-trigger`, then `testid=automations-filter-status-failing`): only QA_F21 Fail is left. In the still-open popover click `testid=automations-filters-reset` (all three automations return), then close it with `control-openhands browser click 'role=heading[name=\"Dashboard\"]'`: the next bullets need QA_F21 Fail and QA_F21 Beta visible.",
          "ids": [
            "F21.overview-tiles",
            "F21.run-now",
            "F21.filters"
          ],
          "children": []
        },
        {
          "anchor": "recipe-030",
          "label": "Run now error (`F21.run-now-error`).",
          "body": "Arrange a stale card: with the dashboard open, run `control-openhands api DELETE /api/automation/v1/<QA_F21 Fail id> --write` and `control-openhands browser toasts --history --clear` (empties the toast history), then click `'[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Fail >> role=button[name=\"Run now\"]'`, then `control-openhands browser toasts --history` and `control-openhands browser screenshot --feature F21.run-now-error --name error-toast`. The history holds exactly one toast, `Automation not found`, and no `Request failed with status code 404`. `browser errors --app-only` then lists the induced 404 on `/dispatch`. Expected: the card then drops out without a reload: `control-openhands browser wait '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Fail' --state detached --timeout 10000` succeeds. Known failure: the wait times out and the card stays until `control-openhands browser reload` (#18062, see Gotchas).",
          "ids": [
            "F21.run-now-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-031",
          "label": "Delete with confirmation (`F21.delete`).",
          "body": "Open Beta's kebab and click `role=list >> has-text=Export >> role=button[name=\"Delete\"]`. `control-openhands browser snapshot 'role=dialog[name=\"Delete automation\"]'` shows dialog `Delete automation` with `Close`, the heading, `Delete \"QA_F21 Beta\"? This cannot be undone.`, `Cancel` and `Delete`; focus starts on Cancel (`control-openhands browser eval \"document.activeElement.textContent\"` is `Cancel`). Take `control-openhands browser screenshot --feature F21.delete --name dialog`. `control-openhands browser click 'role=dialog[name=\"Delete automation\"] >> role=button[name=\"Cancel\"]'` closes it (`control-openhands browser count 'role=dialog[name=\"Delete automation\"]'` is `0`) with the card kept. Reopen and close it with the X (`control-openhands browser click 'role=dialog[name=\"Delete automation\"] >> role=button[name=\"Close\"]'`), then reopen and `control-openhands browser press Escape`: the dialog count is `0` and the card count `1` each time. Reopen, then `control-openhands browser click 'role=dialog[name=\"Delete automation\"] >> role=button[name=\"Delete\"]'`, `control-openhands browser reload` and `control-openhands browser count '[data-testid^=\"automation-card-\"] >> has-text=QA_F21 Beta'`. The count is `0`. Delete the remaining fixtures the same way (rows work too) until `testid=automations-empty` returns.",
          "ids": [
            "F21.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-032",
          "label": "Load more (`F21.load-more`).",
          "body": "Arrange 51 inert automations (event trigger that never fires): `for i in $(seq -w 1 51); do control-openhands api POST /api/automation/v1/preset/prompt --write --data \"{\\\"name\\\":\\\"QA_F21 Page $i\\\",\\\"prompt\\\":\\\"QA paging fixture; never runs.\\\",\\\"trigger\\\":{\\\"type\\\":\\\"event\\\",\\\"source\\\":\\\"qa-f21\\\",\\\"on\\\":\\\"qa.f21.never\\\"}}\"; done`. Then `control-openhands browser goto /automations`, `control-openhands browser wait 'role=button[name=\"Load more\"]'`, row/card count `50`, `control-openhands browser click 'role=button[name=\"Load more\"]'`, `control-openhands browser wait 'role=button[name=\"Load more\"]' --state detached`, count `51`. The preset fixtures arrive enabled (`51 active`) but never fire. Keep them for Phone (it needs at least one automation) and clean up after it.",
          "ids": [
            "F21.load-more"
          ],
          "children": []
        },
        {
          "anchor": "recipe-033",
          "label": "Phone (`F21.phone`, `F21.subpage-nav`).",
          "body": "With at least one automation, run `control-openhands browser viewport phone`, `control-openhands browser bbox 'role=main'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`), `control-openhands browser screenshot --feature F21.phone --name dashboard`, `control-openhands browser click 'testid=automations-navbar-mobile >> testid=automations-navigation-templates'` (URL `/automations/templates`) and back through `automations-navigation-list`. Open the first card's kebab and run `control-openhands browser bbox 'role=list >> has-text=Export'`: the menu is `insideViewport` `true` and sits above the kebab (flipped). Take `control-openhands browser screenshot --feature F21.phone --name kebab`, then `control-openhands browser press Escape` and `control-openhands browser viewport desktop`. Now clean up the paging fixtures with `control-openhands api DELETE /api/automation/v1/<id> --write` for each `QA_F21 Page` id from `control-openhands api GET '/api/automation/v1?limit=100'` (UI deletion is covered above) until `total` is `0`.",
          "ids": [
            "F21.phone",
            "F21.subpage-nav"
          ],
          "children": []
        },
        {
          "anchor": "recipe-034",
          "label": "Errors.",
          "body": "After each group, `control-openhands browser errors --app-only` shows no page errors; the only app errors are the induced 404s for the deleted QA_F21 Fail: `/dispatch` and the stale card's `/runs?limit=20&offset=0`. The backend-down recipes add 502s on `/api/automation/health`, `/sdk-version` and the list while the service is stopped; clear them with `browser errors --clear` after `restart`.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F22",
      "title": "Creating automations: templates, setup and import",
      "file": "F22-automation-creation.md",
      "page": "F22-automation-creation.html",
      "sha256": "8dcb82de77c969c1cbb45434b3b177cebec4ee71d35683974046c5a59de9b9a1",
      "behaviors": [
        {
          "id": "F22.templates-page",
          "description": "`/automations/templates` shows the h1 **Templates**, a description, a search box, **Start from a proven workflow** with a count badge (6) and a **Beta** section (17)."
        },
        {
          "id": "F22.template-card",
          "description": "each card shows an icon or logo stack, name, category, two-line description, a plus badge and integration pills (`N MCPs to connect before launch`, `Needs external setup` for Jira/Bitbucket; `Connected` once installed, which needs a working integration and was not reached here); a `+N` overflow button opens a popover without launching the card."
        },
        {
          "id": "F22.templates-search",
          "description": "the search box narrows cards by name, category, description, launch prompt and integration names; a query that matches nothing should say so."
        },
        {
          "id": "F22.template-launch-setup",
          "description": "a card whose entry ships a setup form (custom-automation, news-digest, github-pr-reviewer, qa-changes, …) navigates to `/automations/new/<id>`."
        },
        {
          "id": "F22.template-launch-conversation",
          "description": "a card without a setup form and with no installable integration missing creates a conversation and pre-fills its composer with the entry's launch command (for example `/ticket-to-code-change:setup`); nothing is sent until the user presses send."
        },
        {
          "id": "F22.template-mcp-install-queue",
          "description": "a card with missing required integrations opens each one's install dialog in turn; Cancel abandons the launch without creating anything."
        },
        {
          "id": "F22.responder-deployment-choice",
          "description": "on a local backend a GitHub/Slack-only responder card first asks **Choose how your responder should run**; **Continue with local setup** creates the `OPENHANDS_URL` secret and continues, **Open OpenHands Cloud integrations** opens Cloud in a new tab, X and Escape cancel."
        },
        {
          "id": "F22.responder-local-launch",
          "description": "with the responder's integration already installed (Slack for **Slack standup digest**), **Continue with local setup** skips the install dialog and opens a new conversation whose composer is pre-filled with the entry's setup command (`/standup-digest:setup`). Nothing is sent until the user presses send; Send delivers it as the user message."
        },
        {
          "id": "F22.setup-prerequisites",
          "description": "**Before you start** lists the entry's integrations with the reason each is needed and **Manage integrations** (`/mcp`); Continue is disabled while a required integration is missing; an entry with nothing to check skips the step."
        },
        {
          "id": "F22.setup-action-kind",
          "description": "the **Action** combobox (custom-automation: Prompt / Plugin / Upload tarball) swaps the action fields and keeps shared values."
        },
        {
          "id": "F22.setup-form-validation",
          "description": "Continue checks fields locally (`This field is required.`, `Must be at least N characters.`, `Must be at most N.`) and a field blur sends the draft to the service, whose errors appear under the field (`Value error, Invalid cron expression: …`)."
        },
        {
          "id": "F22.setup-agent-profile",
          "description": "the **Agent profile** combobox appears only for actions that create through `/v1` (Upload tarball and bundle entries), not for Prompt or Plugin; it lists `Default` and every saved agent profile and shows `Default` while no profile is chosen, picking a profile hides the **LLM profile** field, switching to Prompt or Plugin clears the choice, and the automation is created with that `agent_profile_id`."
        },
        {
          "id": "F22.setup-plugin-create",
          "description": "the **Plugin** action takes plugin sources and a prompt, and a picked **Timezone** carries through Review into the created automation (`preset_metadata.plugins`, `trigger.timezone`)."
        },
        {
          "id": "F22.setup-tarball-create",
          "description": "the **Upload tarball** action requires a `.tar`/`.tar.gz` file, shows its file name in Review and creates an automation with the uploaded tarball and the entrypoint."
        },
        {
          "id": "F22.setup-repository-list",
          "description": "a repository list field has **Add** (disabled while the input is empty) and a **Remove** per row; an empty required list is refused."
        },
        {
          "id": "F22.setup-review-confirm",
          "description": "**Review** lists every value (`Not set` for blanks); **Back** keeps the values; **Confirm and create** creates the automation (enabled) and replaces the URL with its detail page."
        },
        {
          "id": "F22.setup-bundle-create",
          "description": "a bundle entry (Daily news digest) uploads its script tarball and creates an automation with the bundle's entrypoint, without any account."
        },
        {
          "id": "F22.setup-unsupported",
          "description": "an entry this deployment cannot run (qa-changes needs event triggers) shows **Not available**, the unmet requirements, **Close** and **Set up in a conversation**."
        },
        {
          "id": "F22.setup-fallback-conversation",
          "description": "**Set up in a conversation** creates a conversation seeded with the entry's setup message."
        },
        {
          "id": "F22.setup-close",
          "description": "X or Escape goes back in history; on a cold deep link it replaces the location with `/automations`."
        },
        {
          "id": "F22.setup-unknown-id",
          "description": "`/automations/new/<id>` for an id no catalog entry claims is a 404."
        },
        {
          "id": "F22.create-helper-conversation",
          "description": "**Add Automation → Create Automation** opens **How to create an automation**; its **Create Automation** closes the dialog, opens `/conversations` and pre-fills the composer with `Create an automation`; an agent asked to create an automation creates it."
        },
        {
          "id": "F22.import-picker",
          "description": "**Add Automation → Import automation** opens a dialog that explains the format (docs link), says the import is disabled, and offers a dropzone and **Choose file**."
        },
        {
          "id": "F22.import-validation",
          "description": "a file that is not JSON shows the toast `The selected file is not valid JSON.`; a JSON file that breaks the format shows one toast listing every issue; the dialog stays on the picker."
        },
        {
          "id": "F22.import-preview",
          "description": "a valid file switches the dialog to a preview (Name, Trigger, Prompt, Plugins) with the disabled notice, **Cancel** and **Import automation**."
        },
        {
          "id": "F22.import-confirm",
          "description": "**Import automation** reads `Importing...` while pending, closes the dialog, shows `Imported \"<name>\" as disabled. View automation`, and the automation is listed disabled; the toast link opens its detail page."
        },
        {
          "id": "F22.import-choose-file",
          "description": "**Choose file**, and a click anywhere on the dropzone, open the browser's file chooser; the chosen file is previewed."
        },
        {
          "id": "F22.import-dropzone",
          "description": "dragging a file over the dropzone highlights it (`data-active=\"true\"`); dropping a `.json` file previews it like **Choose file**."
        },
        {
          "id": "F22.phone",
          "description": "the Templates grid, the setup dialog and the import dialog fit a 390 px viewport without horizontal overflow."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Templates page (`F22.templates-page`).",
          "body": "From `/` run `control-openhands browser click 'testid=sidebar-automations-link' --expect-url '/automations$'`, `control-openhands browser click 'testid=automations-navbar-desktop >> testid=automations-navigation-templates' --expect-url '/automations/templates'`, `control-openhands browser text 'role=main >> role=heading[level=1]'` (`Templates`), `control-openhands browser count '[data-testid^=\"recommended-automation-card-\"]'` (`23`) and `control-openhands browser count 'testid=recommended-automations-beta-section >> [data-testid^=\"recommended-automation-card-\"]'` (`17`); `browser text 'testid=recommended-automations-beta-heading'` is `Beta` and `17`. `control-openhands browser snapshot 'role=main' --max-lines 60` shows `heading \"Start from a proven workflow\"` with the count `6`. Take `control-openhands browser screenshot --feature F22.templates-page --name grid`. `control-openhands browser goto /automations/templates` renders the same page.",
          "ids": [
            "F22.templates-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Cards and pills (`F22.template-card`).",
          "body": "Run `control-openhands browser testids 'testid=recommended-automation-card-research-brief-writer' --hidden`: `recommended-automation-icon-…`, `recommended-automation-plus-…`, `recommended-automation-pills-…` and `recommended-automation-pills-research-brief-writer-overflow` (`Show 1 more`). `control-openhands browser testids 'role=main' --hidden --filter integration` lists `automation-integration-external-jira` and `automation-integration-external-bitbucket` (`Needs external setup`). `control-openhands browser click 'testid=recommended-automation-pills-research-brief-writer-overflow'` opens a popover (`browser snapshot 'role=dialog'`: `dialog: 2 MCPs to connect before launch`) and the URL stays `/automations/templates`; `control-openhands browser press Escape` closes it (`browser count 'role=dialog'` `0`).",
          "ids": [
            "F22.template-card"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Search (`F22.templates-search`).",
          "body": "Run `control-openhands browser fill 'role=main >> placeholder=Search automations...' tavily` and `control-openhands browser testids 'role=main' --filter recommended-automation-card`: only `recommended-automation-card-research-brief-writer` (matched through its Tavily integration). Then `control-openhands browser fill 'role=main >> placeholder=Search automations...' qa-no-such-template`, `control-openhands browser count 'testid=recommended-automations-section'` and `control-openhands browser screenshot --feature F22.templates-search --name no-match`. Expected: a \"no templates match\" message. Actual: the count is `0` and `browser text 'role=main'` is only the title and description; the area under the search box is blank (recorded as `fail`). Clear with `control-openhands browser fill 'role=main >> placeholder=Search automations...' ''` (23 cards again).",
          "ids": [
            "F22.templates-search"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Card opens setup, prerequisites (`F22.template-launch-setup`, `F22.setup-prerequisites`).",
          "body": "Run `control-openhands browser click 'testid=recommended-automation-card-custom-automation' --expect-url '/automations/new/custom-automation'` and `control-openhands browser snapshot 'testid=setup-dialog'`: heading `Before you start`, `GitHub Optional. Used when you choose a GitHub repository…`, link `Manage integrations` (`/mcp`) and `Continue`; `control-openhands browser enabled 'testid=setup-continue-button'` is `true` (an optional integration only warns). Run `control-openhands browser click 'testid=setup-continue-button'`; the heading becomes `Custom automation` and `browser testids 'testid=setup-dialog'` lists `setup-action-kind`, `setup-field-schedule` (`0 9 * * *`), `setup-field-timezone` (`UTC`), `setup-field-name`, `setup-field-model`, `setup-field-timeout`, `setup-field-prompt`, `setup-field-repository` and `setup-field-ref`. There is no Trigger selector here because this deployment only supports `cron`, and no `automation-agent-profile` because the default Prompt action cannot take one (`F22.setup-agent-profile`).",
          "ids": [
            "F22.template-launch-setup",
            "F22.setup-prerequisites"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Validation (`F22.setup-form-validation`).",
          "body": "Click `testid=setup-continue-button` with the form untouched: `control-openhands browser text 'testid=setup-field-name-error'` and `... 'testid=setup-field-prompt-error'` are `This field is required.` Fill `testid=setup-field-timeout` with `5000`, `testid=setup-field-name` with `QA_F22 Custom`, `testid=setup-field-prompt` with `Reply with the single word: pong. Do not run any tools.`, click Continue: `browser text 'testid=setup-field-timeout-error'` is `Must be at most 1800.` Fill the timeout with `60`. Service preflight: `control-openhands browser fill 'testid=setup-field-schedule' 'every day'`, `control-openhands browser press Tab`, `control-openhands browser wait 'testid=setup-field-schedule-error' --timeout 5000`, then `browser text 'testid=setup-field-schedule-error'`: `Value error, Invalid cron expression: every day` (from `POST /api/automation/v1/validate`, see `browser network --last 5`). Fill the schedule with `0 0 1 1 *`, press Tab, and `control-openhands browser wait 'testid=setup-field-schedule-error' --state detached --timeout 5000` succeeds.",
          "ids": [
            "F22.setup-form-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Action kinds (`F22.setup-action-kind`).",
          "body": "Run `control-openhands browser click 'testid=setup-action-kind'`, `control-openhands browser snapshot 'role=listbox'` (`Prompt` selected, `Plugin`, `Upload tarball`); the snapshot closes the listbox, so click `testid=setup-action-kind` again and then `control-openhands browser click 'role=listbox >> role=option[name=\"Plugin\"]'`: `browser testids 'testid=setup-dialog'` now includes `setup-field-plugins`. Choose `Upload tarball` the same way: `setup-field-tarball`, `setup-field-entrypoint` and `setup-field-setupScript` replace prompt, repository and ref. Choose `Prompt` again; `control-openhands browser value 'testid=setup-field-name'` is still `QA_F22 Custom` and `setup-field-prompt` still holds the prompt.",
          "ids": [
            "F22.setup-action-kind"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Agent profile (`F22.setup-agent-profile`).",
          "body": "In the same form (Prompt action, name `QA_F22 Custom`, prompt and schedule `0 0 1 1 *` filled), `control-openhands browser count 'testid=automation-agent-profile'` is `0`; after `control-openhands browser choose 'testid=setup-action-kind' 'Plugin'` it is still `0`, and after `control-openhands browser choose 'testid=setup-action-kind' 'Upload tarball'` it is `1` (only actions that create through `/v1` can carry a profile; the preset endpoints behind Prompt and Plugin reject `agent_profile_id`). Run `control-openhands browser value 'testid=automation-agent-profile'`, `control-openhands browser click 'testid=automation-agent-profile'` and `control-openhands browser snapshot 'role=listbox'`. Expected: the value is `Default` and the listbox has `option \"Default\" [selected]` and `option \"default\"` (the seeded agent profile from `api GET /api/agent-profiles`), the same as on a fresh Upload tarball form (`F22.setup-tarball-create`). Known failure: the value is `\"\"` and `option \"Default\"` has no `[selected]`: switching to Prompt or Plugin stores an empty profile id, and the selector shows that as a blank field instead of `Default` (#18061). `control-openhands browser press Escape` closes the listbox and leaves the dialog open (`browser count 'testid=setup-dialog'` is `1`). Run `control-openhands browser choose 'testid=automation-agent-profile' 'default'`: `browser count 'testid=setup-field-model'` is `0` (LLM profile hidden); `browser choose 'testid=automation-agent-profile' 'Default'` brings it back (`1`). Pick `default` again, then `control-openhands browser choose 'testid=setup-action-kind' 'Prompt'`: the selector is gone and `setup-field-model` is back (`1`). Choose `Upload tarball` once more and run `control-openhands browser value 'testid=automation-agent-profile'`. Expected: `Default` (the choice was cleared, shown as on a fresh form). Known failure: `\"\"`, the same blank field (choosing `Default` from the list blanks it too) (#18061). Choose `Prompt` again before continuing. Creating an automation with a profile is in the Upload tarball bullet below.",
          "ids": [
            "F22.setup-agent-profile"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Review and create (`F22.setup-review-confirm`).",
          "body": "Run `control-openhands browser click 'testid=setup-field-model'`, `control-openhands browser click 'role=listbox >> role=option[name=\"deepseek-flash\"]'`, `control-openhands browser click 'testid=setup-continue-button'`, `control-openhands browser wait 'testid=setup-review' --timeout 10000` and `control-openhands browser snapshot 'testid=setup-dialog'`: heading `Review`, terms Action `Prompt`, Schedule `0 0 1 1 *`, Timezone `UTC`, Automation name, LLM profile `deepseek-flash`, Timeout `60`, Prompt, Repository `Not set`, Branch or ref `Not set`, and buttons `Back` and `Confirm and create`. Take `browser screenshot --feature F22.setup-review-confirm --name review`. `control-openhands browser click 'testid=setup-back-button'` returns to the form with `setup-field-name` still `QA_F22 Custom`; click Continue, wait for `setup-review` again, then `control-openhands browser click 'testid=setup-continue-button' --expect-url '/automations/[0-9a-f-]{8,}'`. `browser url` is `/automations/<id>` (the id of the new automation); after `control-openhands browser reload`, `control-openhands browser text 'role=heading[level=1]'` is `QA_F22 Custom` and `browser text 'testid=active-status-badge-active'` is `Active` (the detail page has no `main` landmark: `'role=main >> …'` times out there), and `api GET '/api/automation/v1?limit=100'` shows it `enabled: true`, `trigger.schedule` `0 0 1 1 *`, `timeout` `60`, `model` `deepseek-flash`. `control-openhands browser back` lands on `/automations/templates` (the setup URL was replaced).",
          "ids": [
            "F22.setup-review-confirm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Plugin action and timezone (`F22.setup-plugin-create`).",
          "body": "Run `control-openhands browser goto /automations/new/custom-automation`, click `testid=setup-continue-button`, then `control-openhands browser choose 'testid=setup-action-kind' 'Plugin'` and `control-openhands browser choose 'testid=setup-field-timezone' 'Europe/Berlin'`. Fill `testid=setup-field-schedule` with `0 0 1 1 *`, `testid=setup-field-name` with `QA_F22 Plugin`, `testid=setup-field-plugins` with `github:OpenHands/extensions/plugins/qa-none` and `testid=setup-field-prompt` with `Reply with the single word: pong. Do not run any tools.`; click Continue and `browser wait 'testid=setup-review' --timeout 10000`. `browser snapshot 'testid=setup-dialog'`: Action `Plugin`, Timezone `Europe/Berlin`, LLM profile `Not set`, Timeout `Not set`, Plugins `github:OpenHands/extensions/plugins/qa-none`. `control-openhands browser click 'testid=setup-continue-button' --expect-url '/automations/[0-9a-f-]{8,}' --timeout 60000`; `api GET '/api/automation/v1?limit=100'` shows `QA_F22 Plugin` enabled with `trigger.timezone` `Europe/Berlin` and `preset_metadata` `{preset_type: plugin, plugins: [{source: github:OpenHands/extensions/plugins/qa-none}]}`.",
          "ids": [
            "F22.setup-plugin-create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Upload tarball action, with an agent profile (`F22.setup-tarball-create`, `F22.setup-agent-profile`).",
          "body": "Run `control-openhands browser goto /automations/new/custom-automation`, click `testid=setup-continue-button`, `control-openhands browser choose 'testid=setup-action-kind' 'Upload tarball'` (Entrypoint defaults to `python3 main.py`, and on this fresh form `control-openhands browser value 'testid=automation-agent-profile'` is `Default`), fill `testid=setup-field-name` with `QA_F22 Tarball` and `testid=setup-field-schedule` with `0 0 1 1 *`, click Continue: `browser text 'testid=setup-field-tarball-error'` is `This field is required.` Run `control-openhands browser upload 'testid=setup-field-tarball' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-tarball.tar.gz\"` and `control-openhands browser choose 'testid=automation-agent-profile' 'default'` (`browser count 'testid=setup-field-model'` is `0`); take `control-openhands browser screenshot 'testid=setup-dialog' --feature F22.setup-agent-profile --name upload-profile`. Click Continue, `browser wait 'testid=setup-review' --timeout 10000`: Review shows Action `Upload tarball`, LLM profile `Not set`, Tarball `qa-f22-tarball.tar.gz`, Entrypoint `python3 main.py`, Setup script `Not set` (Review does not list the agent profile). `control-openhands browser click 'testid=setup-continue-button' --expect-url '/automations/[0-9a-f-]{8,}' --timeout 60000`; `browser text 'role=heading[level=1]'` is `QA_F22 Tarball`, and `control-openhands api GET /api/automation/v1/<id>` (the id in the URL) shows it enabled with `entrypoint` `python3 main.py`, a `tarball_path` `oh-internal://uploads/…`, `model` `null` and `agent_profile_id` equal to the `default` profile's `id` from `control-openhands api GET /api/agent-profiles`.",
          "ids": [
            "F22.setup-tarball-create",
            "F22.setup-agent-profile"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Close (`F22.setup-close`).",
          "body": "From `/automations/templates` (`control-openhands browser goto /automations/templates`; the previous bullet ends on the new automation's page), `control-openhands browser click 'testid=recommended-automation-card-news-digest' --expect-url '/automations/new/news-digest'` then `control-openhands browser click 'testid=setup-dialog-close' --expect-url '/automations/templates'`; the same with `control-openhands browser press Escape` instead of X also returns to `/automations/templates`. Cold deep link: `control-openhands browser goto /automations/new/news-digest`, `control-openhands browser click 'testid=setup-dialog-close' --expect-url '/automations$'` (replaced, not back).",
          "ids": [
            "F22.setup-close"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Bundle entry, min length (`F22.setup-bundle-create`, `F22.setup-form-validation`).",
          "body": "From `/automations/templates` (`control-openhands browser goto /automations/templates`; Close ends on `/automations`) click `testid=recommended-automation-card-news-digest` (`--expect-url '/automations/new/news-digest'`): there is no prerequisites step and the form shows `automation-agent-profile` (a bundle creates through `/v1`, so it offers a profile; leave it on `Default`), `setup-field-schedule` (`0 8 * * *`), `setup-field-timezone`, `setup-field-feeds` (three default feeds) and `setup-field-topics`. Fill `testid=setup-field-feeds` with `short` and click Continue: `browser text 'testid=setup-field-feeds-error'` is `Must be at least 8 characters.` Close (`testid=setup-dialog-close`), click the card again (the draft is gone), fill `testid=setup-field-schedule` with `0 0 1 1 *`, click Continue, `browser wait 'testid=setup-review' --timeout 10000`, then `control-openhands browser click 'testid=setup-continue-button' --expect-url '/automations/[0-9a-f-]{8,}' --timeout 60000`. The detail h1 is `Daily news digest`; `api GET '/api/automation/v1?limit=100'` shows it enabled with `entrypoint` `python3 main.py`, a `tarball_path` `oh-internal://uploads/…` and `timeout` `900`.",
          "ids": [
            "F22.setup-bundle-create",
            "F22.setup-form-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Repository list (`F22.setup-repository-list`).",
          "body": "Run `control-openhands browser goto /automations/new/github-pr-reviewer` (no prerequisites step locally). `control-openhands browser enabled 'testid=setup-list-repositories-add'` is `false`; fill `testid=setup-field-repositories` with `qa-org/qa-repo` (`enabled` turns `true`), click `testid=setup-list-repositories-add`: `browser text 'testid=setup-list-repositories'` starts `qa-org/qa-repo` / `Remove` and the input is empty again. `control-openhands browser click 'testid=setup-list-repositories-remove-qa-org/qa-repo'` removes it (count `0`); Continue then shows `setup-field-repositories-error` `This field is required.` Close the dialog.",
          "ids": [
            "F22.setup-repository-list"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Blocking prerequisite (`F22.setup-prerequisites`).",
          "body": "Run `control-openhands browser goto /automations/new/incident-retrospective-drafter`, `control-openhands browser snapshot 'testid=setup-dialog'` (Slack, Linear and Notion with reasons) and `control-openhands browser enabled 'testid=setup-continue-button'`: `false` while Slack and Linear are not installed.",
          "ids": [
            "F22.setup-prerequisites"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Not available and fallback (`F22.setup-unsupported`, `F22.setup-fallback-conversation`).",
          "body": "Run `control-openhands browser goto /automations/new/qa-changes` and `control-openhands browser snapshot 'testid=setup-dialog'`: heading `Not available`, `This deployment does not support everything this automation needs.`, `setup-unmet-requirements` `webhookDelivery, event`, buttons `Close` and `Set up in a conversation`. Run `control-openhands browser click 'testid=setup-fallback-conversation' --expect-url '/conversations/[0-9a-f]' --timeout 60000` and `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"`: `/qa-changes`, a blank line and `This deployment cannot run the webhook-driven QA automation directly. Set it up in this conversation instead: …`. The message is pre-filled, not sent (no model cost until the user presses send).",
          "ids": [
            "F22.setup-unsupported",
            "F22.setup-fallback-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Unknown id (`F22.setup-unknown-id`).",
          "body": "`control-openhands browser goto /automations/new/qa-no-such-template` then `control-openhands browser snapshot`: `heading \"404\"` and `Not Found`.",
          "ids": [
            "F22.setup-unknown-id"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Responder choice (`F22.responder-deployment-choice`).",
          "body": "On `/automations/templates` (`control-openhands browser goto /automations/templates`; the previous bullet ends on a 404 page) run `control-openhands browser click 'testid=recommended-automation-card-slack-channel-monitor'` and `control-openhands browser snapshot 'testid=responder-deployment-modal'`: `Choose how your responder should run`, `Poll locally on your laptop` / `Continue with local setup`, `Use OpenHands Cloud` / `Open OpenHands Cloud integrations`. `control-openhands browser press Escape` and, after reopening, `control-openhands browser click 'testid=responder-deployment-modal-close'` each make `browser count 'testid=responder-deployment-modal'` `0`. Reopen and `control-openhands browser click 'testid=responder-deployment-open-openhands-cloud'`: the modal closes and `control-openhands browser tabs` lists tab 1 at `https://app.all-hands.dev/settings/integrations` (it can take a second to appear; rerun `browser tabs`). Close it with `control-openhands browser close-tab 1`. Reopen and `control-openhands browser click 'testid=responder-deployment-continue-local'`, `control-openhands browser wait 'testid=responder-deployment-modal' --state detached --timeout 10000`: the Slack install dialog (`testid=mcp-install-modal`, `dialog \"Slack\"`) opens next and `api GET /api/settings/secrets` now lists `OPENHANDS_URL`. Close it with `control-openhands browser click 'testid=mcp-install-cancel'`.",
          "ids": [
            "F22.responder-deployment-choice"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Responder launch with Slack installed (`F22.responder-local-launch`).",
          "body": "Arrange Slack as installed with its catalog command, a dummy token and offline npm, so nothing is downloaded or contacted (arrange, not proof): write `qa-slack.json` (outside the workspace) containing `{\"agent_settings_diff\":{\"mcp_config\":{\"slack\":{\"command\":\"npx\",\"args\":[\"-y\",\"@zencoderai/slack-mcp-server\"],\"env\":{\"SLACK_BOT_TOKEN\":\"xoxb-qa-dummy\",\"SLACK_TEAM_ID\":\"T0000000000\",\"npm_config_offline\":\"true\"}}}}}` and run `control-openhands api PATCH /api/settings --data @qa-slack.json --write`. On `/automations/templates` run `control-openhands browser click 'testid=recommended-automation-card-slack-standup-digest'`, `control-openhands browser wait 'testid=responder-deployment-modal'`, `control-openhands browser click 'testid=responder-deployment-continue-local'` and `control-openhands browser wait-url '/conversations/[0-9a-f-]+' --timeout 60000` (note `<slack-id>`). `control-openhands browser count 'testid=mcp-install-modal'` is `0`. After `sleep 5`, `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` is `/standup-digest:setup` and `control-openhands browser count 'testid=user-message'` is `0` (`control-openhands browser screenshot --feature F22.responder-local-launch --name slack-prefilled`). Run `control-openhands browser click 'testid=submit-button'` and `control-openhands browser wait 'testid=user-message >> has-text=/standup-digest:setup' --timeout 15000`. Then run `control-openhands conversation pause <slack-id>` at once. The setup skill is open-ended. On 2026-10-08, deepseek-flash spent 25 tool calls exploring the host, including files outside the run, before asking a setup question. Restore with `control-openhands api DELETE /api/settings/mcp/slack --write`; `control-openhands api GET /api/settings --pick agent_settings.mcp_config` is `{}`. Run this bullet after Responder choice, which needs Slack not installed.",
          "ids": [
            "F22.responder-local-launch"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Install queue (`F22.template-mcp-install-queue`).",
          "body": "Run `control-openhands browser click 'testid=recommended-automation-card-linear-triage-assistant'`: `dialog \"Linear\"` (URL `https://mcp.linear.app/mcp`, optional API key). `control-openhands browser click 'testid=mcp-install-cancel'`; `browser count 'role=dialog'` is `0`, the URL stays `/automations/templates`, `control-openhands conversation list` has no new conversation and `api GET /api/settings` still has an empty `mcp_config`. Completing the queue is blocked: **Install** first tests the connection (Linear without a key: `Connection failed: HTTPStatusError: Client error '401 Unauthorized'…`; Tavily `research-brief-writer` with a dummy key: `Connection timed out. Check the URL and try again.`), so it never launches without a working credential.",
          "ids": [
            "F22.template-mcp-install-queue"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Prompt launch (`F22.template-launch-conversation`).",
          "body": "Use a card whose required integrations all need external setup, so no install dialog queues: on `/automations/templates` run `control-openhands browser click 'testid=recommended-automation-pills-jira-issue-to-bitbucket-pr-overflow'` (`dialog: Bitbucket Needs external setup`), `control-openhands browser press Escape`, then `control-openhands browser click 'testid=recommended-automation-card-jira-issue-to-bitbucket-pr' --expect-url '/conversations/[0-9a-f]' --timeout 60000`. `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` is `/ticket-to-code-change:setup`, and `control-openhands conversation events <id> --kinds MessageEvent --from-start` (id from the URL) has `total` `0` even 20 s later: the launch command is pre-filled, not sent (the featured copy promises \"a pre-filled conversation\"). Do not press send (no model cost). Take `browser screenshot --feature F22.template-launch-conversation --name prefilled`. Cards with an installable missing integration (`research-brief-writer`, Tavily) go through the install queue first and are blocked here (see Preconditions).",
          "ids": [
            "F22.template-launch-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Create Automation helper (`F22.create-helper-conversation`).",
          "body": "On `/automations` (`control-openhands browser goto /automations`; Prompt launch ends on a conversation) run `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create'`, `control-openhands browser text 'testid=add-automation-modal >> testid=automations-create-instructions-example'` (`Create an automation`), then `control-openhands browser click 'testid=add-automation-modal >> testid=automations-create-automation' --expect-url '/conversations'` and `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"`: the composer holds `Create an automation`, it has focus (`control-openhands browser eval \"document.activeElement.getAttribute('data-testid')\"` is `chat-input`), and the dialog is gone (`control-openhands browser count 'testid=add-automation-modal'` is `0`). Take `control-openhands browser screenshot --feature F22.create-helper-conversation --name prefilled`. Nothing is sent until the user presses send. The agent half (`conversation start` reloads `/` and replaces the composer text): `control-openhands conversation start --prompt \"Create an automation now, without asking questions: name QA_F22 Agent, cron schedule 0 0 1 1 * in UTC, prompt: Reply with the single word pong. Then reply with only its id.\" --wait --timeout 400` finishes, and `control-openhands browser goto /automations` then `control-openhands browser count '[data-testid^=\"automation-card-\"] >> has-text=QA_F22 Agent'` is `1`.",
          "ids": [
            "F22.create-helper-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Import picker (`F22.import-picker`).",
          "body": "On `/automations` run `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-import-automation'`, `control-openhands browser attr 'testid=import-automation-modal' data-view` (`picker`) and `control-openhands browser snapshot 'testid=import-automation-modal'`: heading `Import automation`, the explanation with link `File format documentation` (`https://docs.openhands.dev/openhands/usage/agent-canvas/managing-automations#exported-file-format`), `The imported automation will be disabled until you review and enable it.`, `Drop a .json file here`, `or`, `Choose file`.",
          "ids": [
            "F22.import-picker"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Bad files (`F22.import-validation`).",
          "body": "In the open picker run `control-openhands browser upload 'testid=automations-import-file' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-broken.json\"` and `control-openhands browser toasts`: `The selected file is not valid JSON.` Then upload `qa-f22-schema.json` the same way: one toast reads `version: expected 1`, `spec.name: expected a non-empty string`, `spec.trigger.source: required for an event trigger`, `spec.trigger.on: required for an event trigger`, `spec.enabled: expected a boolean` (one per line). `data-view` stays `picker`.",
          "ids": [
            "F22.import-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Preview and cancel (`F22.import-preview`).",
          "body": "Upload `qa-f22-import.automation.json`: `data-view` is `preview` and `browser snapshot 'testid=import-automation-modal'` shows `Review the automation details before importing.`, Name `QA_F22 Imported`, Trigger `0 0 1 1 * · UTC`, the Prompt, Plugins `github:OpenHands/extensions/plugins/qa-none`, the disabled notice, `Cancel` and `Import automation`. `control-openhands browser click 'testid=import-automation-cancel'` closes it (`count` `0`); reopening shows `data-view` `picker` again.",
          "ids": [
            "F22.import-preview"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Import (`F22.import-confirm`).",
          "body": "Reopen the dialog, upload `qa-f22-import.automation.json`, then `control-openhands browser click 'testid=import-automation-confirm' --observe 'testid=import-automation-confirm' --observe-ms 3000` (observed `Importing...` for about 50 ms, then `<absent>`) and `control-openhands browser toasts`: `Imported \"QA_F22 Imported\" as disabled. View automation`. The card `[data-testid^=\"automation-card-\"] >> has-text=QA_F22 Imported` is under **Inactive**, and `api GET /api/automation/v1/<id>` has `enabled: false`, `state` `INACTIVE` and the plugin in `preset_metadata.plugins`. The toast lasts about 5 s, so follow its link in the very next command: import `qa-f22-view.automation.json` the same way, then immediately `control-openhands browser click 'role=status >> role=link[name=\"View automation\"]' --expect-url '/automations/[0-9a-f-]{8,}'`; `browser text 'role=heading[level=1]'` is `QA_F22 View`.",
          "ids": [
            "F22.import-confirm"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Choose file (`F22.import-choose-file`).",
          "body": "On `/automations` (`control-openhands browser goto /automations`; Import ends on QA_F22 View's page) open Add Automation → Import automation and run `control-openhands browser upload-via 'testid=import-automation-choose-file' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-import.automation.json\"` (answers the real file chooser): `browser attr 'testid=import-automation-modal' data-view` is `preview`. Close with `testid=import-automation-modal-close`, reopen, and `control-openhands browser upload-via 'testid=import-automation-dropzone >> text=Drop a .json file here' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-import.automation.json\"`: `preview` again. `control-openhands browser press Escape` closes the dialog (`browser count 'testid=import-automation-modal'` `0`).",
          "ids": [
            "F22.import-choose-file"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Drop a file (`F22.import-dropzone`).",
          "body": "Open the import dialog again and run `control-openhands browser drop-files 'testid=import-automation-dropzone' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-import.automation.json\" --stage over`: `browser attr 'testid=import-automation-dropzone' data-active` is `true` (focus border). Close and reopen the dialog, then `control-openhands browser drop-files 'testid=import-automation-dropzone' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-import.automation.json\"`: `data-view` is `preview` with Name `QA_F22 Imported` (`browser text 'testid=import-automation-modal'`). Close with `testid=import-automation-modal-close`.",
          "ids": [
            "F22.import-dropzone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Phone (`F22.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser goto /automations/templates`, `control-openhands browser bbox 'testid=recommended-automations-section'` (`pageHorizontalOverflow` `false`) and `browser screenshot --feature F22.phone --name templates`. Click `testid=recommended-automation-card-custom-automation`, then `testid=setup-continue-button`; `browser bbox 'testid=setup-dialog'` is `insideViewport` `true` with no page overflow (`--name setup`). Close it, `control-openhands browser goto /automations`, open Add Automation → Import automation, upload `qa-f22-import.automation.json`; `browser bbox 'testid=import-automation-modal'` is `insideViewport` `true` (`--name import-preview`). Close with `testid=import-automation-modal-close`, then `control-openhands browser viewport desktop`.",
          "ids": [
            "F22.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Cleanup.",
          "body": "Deleting an automation is F21's flow; here remove the fixtures with `control-openhands api DELETE /api/automation/v1/<id> --write` for each `QA_F22 …` (Custom, Plugin, Tarball, Imported, View, Agent) and `Daily news digest` id from `control-openhands api GET '/api/automation/v1?limit=100'`. Delete `OPENHANDS_URL` in Settings → Secrets (`control-openhands browser click 'testid=secret-item >> has-text=OPENHANDS_URL >> testid=delete-secret-button'`, then `testid=confirmation-modal >> testid=confirm-button`, as in F14.delete) unless the next recipe wants it. The fallback and agent conversations stay in the conversation list (F04 deletes them). With zero automations, `control-openhands browser goto /automations` and `control-openhands browser click 'testid=automations-empty >> testid=automations-create-automation' --expect-url '/conversations'` pre-fill the composer from the empty-state entry point too (`browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` is `Create an automation`).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F23",
      "title": "Automation detail, runs and editing",
      "file": "F23-automation-detail.md",
      "page": "F23-automation-detail.html",
      "sha256": "2c0bd436671c4538dea907c3881d3e6ccc2dbf5f61e3ebb0a153c748fd886d75",
      "behaviors": [
        {
          "id": "F23.open-detail",
          "description": "a dashboard card, a list row, the kebab **View** and a direct URL open `/automations/<id>`; browser Back from a run's conversation returns to it."
        },
        {
          "id": "F23.back-link",
          "description": "**Back to Automations** (on the page and on the not-found state) returns to `/automations`."
        },
        {
          "id": "F23.loading",
          "description": "`detail-skeleton` stands in while the automation loads."
        },
        {
          "id": "F23.not-found",
          "description": "an unknown id shows **Automation not found** with \"The automation you're looking for doesn't exist or has been deleted.\" and the back link."
        },
        {
          "id": "F23.backend-unavailable",
          "description": "with the automation service down, the page shows **Automations Unavailable** and **Retry**; Retry recovers once the service is back."
        },
        {
          "id": "F23.load-error",
          "description": "when the health check is still good but the automation request fails, the page shows **Failed to load automations** and **Retry**."
        },
        {
          "id": "F23.header",
          "description": "the header shows the name, the Active/Inactive badge, **Run now** (disabled while inactive), the switch (`Turn on`/`Turn off`) and the **Automation actions** kebab with Export, Tarball, Edit, Turn on/Turn off and Delete."
        },
        {
          "id": "F23.toggle",
          "description": "the header switch flips the enabled flag at once (no confirmation); the badge, switch and Run now follow and survive a reload."
        },
        {
          "id": "F23.disabled-reason",
          "description": "after **Turn off**, the banner `automation-disabled-reason-banner` reads **Why this is paused** / **Turned off manually** / **Paused <time>**; it is gone while active."
        },
        {
          "id": "F23.run-now",
          "description": "**Run now** dispatches a run with the toast **Automation dispatched** and a Pending row appears in the log (the button reads **Starting…** only for the few ms of the request)."
        },
        {
          "id": "F23.run-now-error",
          "description": "a failed dispatch shows exactly one error toast with the API message; when the automation is gone (`Automation not found`), the page then shows the **Automation not found** state."
        },
        {
          "id": "F23.run-status-polling",
          "description": "while a run is Pending or Running the log refetches every 3 s, so Pending → Running → Successful/Failed, the summary, the cost and the conversation link appear without a reload; the Activity card's **Last run** updates too."
        },
        {
          "id": "F23.activity-log",
          "description": "**Activity Log** shows **No activity yet** with Export disabled before the first run, then one row per run (newest first): start time, summary, cost (`$0.0000` format, `run-cost`), a logs icon, status badge (`run-status-icon-<status>`)."
        },
        {
          "id": "F23.run-task-outcome",
          "description": "a Completed run whose agent finished with a task outcome other than success shows that outcome instead of **Successful**: `blocked` and `partial_success` read **Blocked** / **Partial** with `run-status-icon-warning`, `unknown` and any unrecognised status string read **Needs review** (`run-status-icon-needs-review`), and a finish response without a string `status` (summary only, custom fields only, a non-string status, or not an object) is expected to read **Successful**, and an `outcome_summary`, when it has one, is still shown (#17682); the row summary is the agent's `outcome_summary`, and the Logs dialog's Task section repeats it."
        },
        {
          "id": "F23.activity-log-more",
          "description": "with more than 20 runs a **Load more** button grows the page by 20."
        },
        {
          "id": "F23.run-no-conversation",
          "description": "a finished run without a conversation reads **No Conversation**; only a script run (no agent) gets \"No conversation — this run executed a script. Use View logs for its output.\""
        },
        {
          "id": "F23.run-linked-conversation",
          "description": "a run row with a conversation is a link labelled `View conversation for run at <time>` to `/conversations/<conversation_id>`, whose title is `<automation name> — <UTC timestamp>`."
        },
        {
          "id": "F23.run-logs-modal",
          "description": "the row's terminal icon (**View logs**) opens the **Logs** dialog with Run, Task (status, summary, `automation-task-metadata`) and System sections and Output/Error tabs showing the run command's stdout/stderr; Escape and the X close it without navigating."
        },
        {
          "id": "F23.debug-with-openhands",
          "description": "in the Logs dialog of a **Failed** run, **Debug with OpenHands** starts a conversation seeded with the automation, the error and the run id, and opens it."
        },
        {
          "id": "F23.activity-log-export",
          "description": "**Export JSON** / **Export CSV** download `<name-slug>.activity-log.json|csv` with every run, including `conversation_url`."
        },
        {
          "id": "F23.run-deeplink",
          "description": "`?run=<runId>` highlights that run (`automation-run-highlight-<runId>`, class `bg-focus/10`) and scrolls to it; an unknown run id highlights nothing."
        },
        {
          "id": "F23.prompt-collapse",
          "description": "a prompt taller than about 10 lines is clipped to 240 px with a fade (`automation-prompt-fade`) and **View More**; **View Less** collapses it again."
        },
        {
          "id": "F23.configuration",
          "description": "a cron automation shows Trigger **Schedule**, the Schedule and **LLM profile** (`Active profile` or the profile name)."
        },
        {
          "id": "F23.configuration-event",
          "description": "an event automation shows Trigger **Event**, **Event Source**, **Event Type** and **Filter**, truncated at 60 characters with **Show more**/**Show less**."
        },
        {
          "id": "F23.plugins-repo",
          "description": "an automation created with repositories and plugins shows **Repositories** (one `owner/repo` row each, with a branch badge for its ref) and a **Plugins** card with one chip per plugin."
        },
        {
          "id": "F23.script-section",
          "description": "a script (tarball) automation shows **Script** with the entrypoint and the bundle's files in place of the Prompt."
        },
        {
          "id": "F23.export",
          "description": "kebab **Export** downloads `<name-slug>.automation.json` (`version`, `kind: \"automation\"`, `spec`); an automation's first repository and its plugins go into `spec` as `repository`, `branch` and `plugins`."
        },
        {
          "id": "F23.tarball",
          "description": "kebab **Tarball** downloads the automation's code bundle."
        },
        {
          "id": "F23.delete",
          "description": "kebab **Delete** asks `Delete \"<name>\"? This cannot be undone.`; Cancel keeps it, Delete removes it and returns to `/automations`."
        },
        {
          "id": "F23.edit-open",
          "description": "kebab **Edit** opens **Edit automation** prefilled with Name, Prompt (\"Edits apply to future runs only.\"), Agent profile, LLM profile, Timeout (seconds) with its hint, and the schedule."
        },
        {
          "id": "F23.edit-close",
          "description": "X, **Cancel**, a backdrop click and Escape close the dialog without saving; reopening shows the stored values."
        },
        {
          "id": "F23.edit-validation",
          "description": "an empty name (**Name is required**), a bad timeout (**Timeout must be a valid number** / **must be positive** / **cannot exceed 1800 seconds**) and a bad cron (**Enter a valid cron expression** / **This cron expression will never run**) are refused inline."
        },
        {
          "id": "F23.edit-save",
          "description": "Save with no change just closes (no request); a change sends one PATCH, shows **Saving...**, the toast **Automation updated**, and the detail page and API reflect it."
        },
        {
          "id": "F23.edit-schedule",
          "description": "Frequency offers Daily, Weekdays (Mon–Fri) and Weekly; Weekly adds **Day of week**; with **Time of day** they are saved as a cron expression and read back on reopen."
        },
        {
          "id": "F23.edit-time-cleared",
          "description": "clearing **Time of day** on a preset schedule and saving is refused with a message instead of being dropped."
        },
        {
          "id": "F23.edit-cron",
          "description": "a custom cron schedule shows Frequency **Custom** and Time of day disabled, and an editable **Cron expression**."
        },
        {
          "id": "F23.edit-event-readonly",
          "description": "for an event automation the dialog shows Trigger Event, Event Source, Event Type and Filter as read-only text and no schedule fields."
        },
        {
          "id": "F23.edit-save-error",
          "description": "a failed save shows exactly one error toast with the API message and keeps the dialog open."
        },
        {
          "id": "F23.phone",
          "description": "at 390×844 the detail page has no horizontal overflow, and the Edit dialog fits with its title, X and Save reachable."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open from the dashboard (`F23.open-detail`, `F23.header`).",
          "body": "Open QA_F23 Pong, then `control-openhands browser url` (`/automations/<id>`), `control-openhands browser snapshot` and `control-openhands browser screenshot --feature F23.header --name inactive`. The snapshot shows link `Back to Automations`, heading `QA_F23 Pong` (level 1), `Inactive`, `button \"Run now\" [disabled]`, `switch \"Turn on\"` and `button \"Automation actions\"`, then the Prompt, Configuration, Activity (`Last run Never`) and Activity Log (`No activity yet`, `Export JSON [disabled]`, `Export CSV [disabled]`) sections. Run `control-openhands browser click 'role=button[name=\"Automation actions\"]'` and `control-openhands browser snapshot 'role=list >> has-text=Tarball'`: buttons `Export`, `Tarball`, `Edit`, `Turn on`, `Delete`. `control-openhands browser press Escape` closes it (`browser count 'role=list >> has-text=Tarball'` is `0`).",
          "ids": [
            "F23.open-detail",
            "F23.header"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Other ways in (`F23.open-detail`, `F23.back-link`).",
          "body": "`control-openhands browser click 'role=link[name=\"Back to Automations\"]' --expect-url '/automations(\\?|$)'`; then `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F23 Pong >> role=button[name=\"Automation actions\"]'` and `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"View\"]' --expect-url '/automations/[0-9a-f-]+'` land on the same `/automations/<id>`. List view too: `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-view-toggle'`, `control-openhands browser click 'testid=automations-view-toggle-list'`, `control-openhands browser click '[data-testid^=\"automation-list-row-\"] >> has-text=QA_F23 Pong >> role=link >> nth=0' --expect-url '/automations/[0-9a-f-]+'`; then restore grid (`goto /automations`, the toggle, `testid=automations-view-toggle-grid`; `browser eval \"localStorage.getItem('openhands-automations-view')\"` is `\"grid\"`). `control-openhands browser goto /automations/<id>` (direct URL) shows the same page.",
          "ids": [
            "F23.open-detail",
            "F23.back-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "States: loading and not found (`F23.loading`, `F23.not-found`).",
          "body": "`control-openhands browser goto /automations/00000000-0000-0000-0000-000000000000` and at once `control-openhands browser count 'testid=detail-skeleton'` (`1`). `control-openhands browser wait-text 'Automation not found'` succeeds after about 7 s, `detail-skeleton` is then `0`, and `control-openhands browser snapshot 'role=paragraph >> text=Automation not found >> xpath=..'` lists both paragraphs and link `Back to Automations`. Screenshot with `--feature F23.not-found --name not-found`; `control-openhands browser click 'role=link[name=\"Back to Automations\"]' --expect-url '/automations(\\?|$)'`. `browser errors --app-only` lists the four induced `404`s on `/api/automation/v1/00000000-...`.",
          "ids": [
            "F23.loading",
            "F23.not-found"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Backend unavailable (`F23.backend-unavailable`).",
          "body": "On QA_F23 Pong: `control-openhands service stop automation`, `control-openhands browser goto /automations/<id>`, `control-openhands browser wait-text 'Automations Unavailable'` and `control-openhands browser text 'role=button[name=\"Retry\"] >> xpath=..'`: `Automations Unavailable`, `The automations backend is not available right now. Please try again later or check that the automation service is running.`, `Retry`. Then `control-openhands restart`, `control-openhands browser click 'role=button[name=\"Retry\"]'` and `control-openhands browser wait 'role=heading[name=\"QA_F23 Pong\"]'`: the page recovers without a reload.",
          "ids": [
            "F23.backend-unavailable"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Load error (`F23.load-error`).",
          "body": "`control-openhands browser goto /automations` (a fresh load, so the detail is not cached), wait for the card, `control-openhands service stop automation`, then within 30 s `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F23 Pong >> role=heading' --expect-url '/automations/[0-9a-f-]+'` and `control-openhands browser wait-text 'Failed to load automations'` (after the retries, about 7 s). `control-openhands browser text 'role=button[name=\"Retry\"] >> xpath=..'` is `Failed to load automations` / `Retry`. `control-openhands restart`, click Retry, and the detail page returns.",
          "ids": [
            "F23.load-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Turn on with the switch (`F23.toggle`).",
          "body": "On Pong run `control-openhands browser click 'role=switch[name=\"Turn on\"]'`, `control-openhands browser wait 'testid=active-status-badge-active' --timeout 5000` and `control-openhands browser count 'testid=confirmation-modal'` (`0`: no confirmation). After `control-openhands browser reload`, `control-openhands browser text 'testid=active-status-badge-active'` is `Active`, `control-openhands browser attr 'role=switch[name=\"Turn off\"]' aria-checked` is `true` and `control-openhands browser enabled 'role=button[name=\"Run now\"]'` is `true`.",
          "ids": [
            "F23.toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Run now and live status (`F23.run-now`, `F23.run-status-polling`, `F23.activity-log`).",
          "body": "Run `control-openhands browser click 'role=button[name=\"Run now\"]'`, then at once `control-openhands browser toasts` (`Automation dispatched`) and `control-openhands browser testids 'testid=automation-activity-log'` (`run-status-icon-pending`). Without reloading, repeat `control-openhands browser text 'testid=automation-activity-log'` every few seconds: `Pending`, then `Running` (`run-status-icon-running`) after about 5-20 s, then about 30-60 s after dispatch `<weekday, month day, year at h:mm AM>`, the agent's summary (for example `Replied with the single word \"pong\" as requested, without running any tools.`), a cost such as `$0.0018` and `Successful` (`run-status-icon-completed`, `run-cost` with title `Cost`). `control-openhands browser text 'role=heading[name=\"Activity\"][exact] >> xpath=ancestor::div[2]'` (without `[exact]` it also matches **Activity Log** and returns two texts) reads `Last run` / `Just now`. Screenshot with `control-openhands browser screenshot --feature F23.run-status-polling --name completed`.",
          "ids": [
            "F23.run-now",
            "F23.run-status-polling",
            "F23.activity-log"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Linked conversation (`F23.run-linked-conversation`).",
          "body": "Run `control-openhands browser attr 'testid=automation-activity-log >> role=link' href` (`/conversations/<conversation_id>`) and `... aria-label` (`View conversation for run at <time>`), then `control-openhands browser click 'testid=automation-activity-log >> role=link' --expect-url '/conversations/'` and `control-openhands browser text 'testid=conversation-name'`: `QA_F23 Pong — <YYYY-MM-DD HH:MM:SS> UTC`. `control-openhands browser back` returns to the detail page (`browser wait 'testid=automation-activity-log'`).",
          "ids": [
            "F23.run-linked-conversation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Run deep link (`F23.run-deeplink`).",
          "body": "Get `<runId>` from `control-openhands api GET /api/automation/v1/<id>/runs` (`body.runs[0].id`). `control-openhands browser goto '/automations/<id>?run=<runId>'`, `control-openhands browser wait 'testid=automation-run-highlight-<runId>'` and `control-openhands browser attr 'testid=automation-run-highlight-<runId>' class` (`bg-focus/10`). With `?run=00000000-0000-0000-0000-000000000000`, `control-openhands browser testids --filter highlight` lists nothing.",
          "ids": [
            "F23.run-deeplink"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Logs dialog (`F23.run-logs-modal`).",
          "body": "Run `control-openhands browser click 'testid=automation-activity-log >> title=View logs'`, `control-openhands browser url` (unchanged: the click does not follow the row link) and `control-openhands browser snapshot 'role=dialog'`: `dialog \"Logs\"` with `Run` `COMPLETED`, `Task` `Successful` plus the summary and `Task metadata` (`automation-task-metadata`: the finish tool's extra fields, for example `{ \"message\": \"pong\", \"summary\": \"Replied with the single word pong as requested\" }`; the keys vary with what the agent sent), `System` `No system issues reported.`, and tabs `Output [selected]` / `Error`. `control-openhands browser text 'testid=run-logs-output-stdout'` is the run command's stdout: it starts `[setup] Fetching SDK version from automation service` and contains `=== EXECUTION MODE ===` and `mode: LOCAL` (the same text `control-openhands api GET '/api/bash/bash_events/search?kind__eq=BashOutput&command_id__eq=<bash_command_id>'` returns; `bash_command_id` from the runs API). Screenshot with `--feature F23.run-logs-modal --name output`. `control-openhands browser click 'role=dialog >> role=tab[name=\"Error\"]'` selects Error (`aria-selected` `true`); `control-openhands browser press Escape` makes `browser count 'role=dialog'` `0`. Request scope: `control-openhands browser reload` (a reopened dialog reuses its cached logs), `control-openhands browser network --clear`, click `testid=automation-activity-log >> title=View logs` again and run `control-openhands browser network --filter bash`: only `GET /api/bash/bash_events/search` (server level, `200`), no `/api/conversations/<id>/bash/...` although this run has a conversation. Escape.",
          "ids": [
            "F23.run-logs-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Export the log (`F23.activity-log-export`).",
          "body": "`control-openhands browser click 'testid=activity-log-export-json'` then `control-openhands browser downloads --last 1 --inspect`: `qa-f23-pong.activity-log.json` with `runs[]` holding `run_id`, `automation_name`, `trigger`, `start_time`, `end_time`, `duration_seconds`, `status` `COMPLETED`, `conversation_id` and `conversation_url` (`http://127.0.0.1:<port>/conversations/<id>`). `control-openhands browser click 'testid=activity-log-export-csv'` and the same `downloads` call: `qa-f23-pong.activity-log.csv` with header `run_id,automation_id,automation_name,trigger,start_time,end_time,duration_seconds,status,conversation_id,conversation_url,error,cost,phase`.",
          "ids": [
            "F23.activity-log-export"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Export and Tarball (`F23.export`, `F23.tarball`).",
          "body": "`control-openhands browser click 'role=button[name=\"Automation actions\"]'`, `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Export\"]'`, `control-openhands browser downloads --last 1 --inspect`: `qa-f23-pong.automation.json` with `\"version\": 1`, `\"kind\": \"automation\"` and `spec` (name, trigger, `enabled`, prompt, `model` `null`, `timeout` `600`, `timezone`). Reopen the kebab and click `role=list >> has-text=Tarball >> role=button[name=\"Tarball\"]`; `control-openhands browser downloads --last 1` lists `QA_F23_Pong.tar` (about 9 KB) and no toast appears. The file is gzip data despite the name: `tar -tzf <path>` lists `main.py`, `finish_tool_hook.py`, `prompt.txt`, `setup.sh`.",
          "ids": [
            "F23.export",
            "F23.tarball"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Turn off and the paused banner (`F23.disabled-reason`, `F23.toggle`).",
          "body": "While Pong is active `control-openhands browser count 'testid=automation-disabled-reason-banner'` is `0`. Open the kebab, `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Turn off\"]'`, `control-openhands browser count 'testid=confirmation-modal'` (`0`), `control-openhands browser reload`, `control-openhands browser text 'testid=automation-disabled-reason-banner'`: `Why this is paused` / `Turned off manually` / `Paused Just now` (`automation-disabled-reason-text`, `automation-disabled-reason-timestamp`). The badge reads `Inactive` and Run now is disabled. Screenshot with `--feature F23.disabled-reason --name manual`.",
          "ids": [
            "F23.disabled-reason",
            "F23.toggle"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Edit fields and closing (`F23.edit-open`, `F23.edit-close`).",
          "body": "On Pong run `control-openhands browser click 'role=button[name=\"Automation actions\"]'`, `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Edit\"]'` and `control-openhands browser snapshot 'role=form'`: form `Edit automation` with textbox `Name` (`QA_F23 Pong`), textbox `Prompt` and `Edits apply to future runs only.`, combobox `Agent profile` (`Default`), combobox `LLM profile` (`Active profile`), spinbutton `Timeout (seconds) Optional` (`600`) with `edit-automation-timeout-hint` (`Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).`), combobox `Frequency` (`Daily`), textbox `Time of day` (`09:00`), `Cancel` and `Save`. Each of these closes it (`browser count 'testid=edit-automation-save'` is `0`): `control-openhands browser click 'testid=edit-automation-cancel'`, `control-openhands browser click 'role=form[name=\"Edit automation\"] >> xpath=.. >> role=button[name=\"Cancel\"] >> nth=0'` (the X) and `control-openhands browser mouse-click 1400 950` (backdrop). Expected for Escape too, but today `control-openhands browser press Escape` leaves the count at `1` (see Gotchas). After changing the timeout and cancelling, reopening shows `600` again.",
          "ids": [
            "F23.edit-open",
            "F23.edit-close"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Validation (`F23.edit-validation`).",
          "body": "In the dialog run `control-openhands browser fill 'testid=edit-automation-name' ''` and `control-openhands browser click 'testid=edit-automation-save'`: `control-openhands browser snapshot 'role=form' --max-lines 6` shows `textbox \"Name Name is required\" [invalid]` and `alert: Name is required`. Refill the name, then fill `testid=edit-automation-timeout` with `1.5`, `0` and `1801` in turn, clicking Save after each; `control-openhands browser text 'testid=edit-automation-timeout >> xpath=ancestor::label[1]/..'` contains `Timeout must be a valid number`, `Timeout must be positive` and `Timeout cannot exceed 1800 seconds` respectively, and the dialog stays open. Cancel.",
          "ids": [
            "F23.edit-validation"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "No-change Save (`F23.edit-save`).",
          "body": "Reopen Edit, `control-openhands browser network --clear`, click `testid=edit-automation-save`, then `control-openhands browser network --last 20`: the dialog is closed and no PATCH was sent; `browser toasts` is empty.",
          "ids": [
            "F23.edit-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Weekly schedule, model and timeout (`F23.edit-schedule`, `F23.edit-save`).",
          "body": "Reopen Edit and run `control-openhands browser click 'testid=edit-automation-frequency'`, `control-openhands browser snapshot 'role=listbox'` (`Daily [selected]`, `Weekdays (Mon–Fri)`, `Weekly`), `control-openhands browser click 'role=option[name=\"Weekly\"]'`, `control-openhands browser click 'testid=edit-automation-weekday'`, `control-openhands browser click 'role=option[name=\"Wednesday\"]'`, `control-openhands browser fill 'testid=edit-automation-time' 10:30`, `control-openhands browser click 'testid=edit-automation-model'`, `control-openhands browser click 'role=option[name=\"deepseek-pro\"]'`, `control-openhands browser fill 'testid=edit-automation-timeout' 300` and `control-openhands browser screenshot --feature F23.edit-schedule --name weekly-form`. Then `control-openhands browser network --clear`, `control-openhands browser click 'testid=edit-automation-save' --observe 'testid=edit-automation-save' --observe-ms 1500` (`Save` → `Saving...` → absent) and `control-openhands browser wait-text 'Automation updated' --timeout 5000`. `browser network --last 20` shows one `PATCH /api/automation/v1/<id>`. After `control-openhands browser reload`, `control-openhands browser text 'role=heading[name=\"Configuration\"] >> xpath=ancestor::div[2]'` reads `Schedule` `30 10 * * 3` and `LLM profile` `deepseek-pro`; `control-openhands api GET /api/automation/v1/<id>` has `timeout` `300` and `enabled` `false`. Reopening Edit shows `Weekly`, `Wednesday`, `10:30` (`browser value 'testid=edit-automation-weekday'` etc.).",
          "ids": [
            "F23.edit-schedule",
            "F23.edit-save"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Cleared time (`F23.edit-time-cleared`).",
          "body": "Reopen Edit, `control-openhands browser fill 'testid=edit-automation-time' ''`, `control-openhands browser network --clear`, click Save. Expected: an inline message and the dialog stays open. Today the dialog closes as if saved, `browser network --last 20` shows no PATCH and the schedule stays `30 10 * * 3`.",
          "ids": [
            "F23.edit-time-cleared"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Custom cron (`F23.edit-cron`).",
          "body": "Open QA_F23 Fail and Edit. `control-openhands browser value 'testid=edit-automation-frequency'` is `Custom` and `control-openhands browser enabled 'testid=edit-automation-frequency'` and `... 'testid=edit-automation-time'` are `false`; `testid=edit-automation-cron` holds `0 0 1 1 *` (placeholder `*/10 * * * *`). Fill the cron with `not a cron`, click Save, and `control-openhands browser text 'testid=edit-automation-cron >> xpath=ancestor::label[1]'` adds `Enter a valid cron expression`; with `0 0 31 2 *` it reads `This cron expression will never run`. Fill `0 0 2 1 *` and `testid=edit-automation-timeout` with `1` (this makes the next run fail), Save, `browser wait-text 'Automation updated'`; after a reload the Configuration shows `0 0 2 1 *`.",
          "ids": [
            "F23.edit-cron"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Failed run, its row and Debug with OpenHands (`F23.run-status-polling`, `F23.run-no-conversation`, `F23.run-logs-modal`, `F23.debug-with-openhands`).",
          "body": "On QA_F23 Fail (timeout `1` from the previous bullet) click `role=button[name=\"Run now\"]` and `control-openhands browser wait-text 'Automation dispatched'`. The run stays Running for about 3-4 minutes and ends `FAILED` (poll `control-openhands api GET /api/automation/v1/<id>/runs`; drive the event automation bullets meanwhile). Then `control-openhands browser goto /automations`, open QA_F23 Fail, `control-openhands browser wait 'testid=automation-activity-log >> testid=run-status-icon-failed'` and `control-openhands browser text 'testid=automation-activity-log'`: the newest row reads `<time>`, `Timed out: command timed out or was killed`, `Failed`. Expected beside the time: `No Conversation` (this is a prompt automation); today it reads `No conversation — this run executed a script. Use View logs for its output.` Run `control-openhands browser click 'testid=automation-activity-log >> title=View logs >> nth=0'` and `control-openhands browser snapshot 'role=dialog'`: `Run` `FAILED`, `Task` `No task outcome reported.`, `System` `Error: Timed out: command timed out or was killed` and `Status detail: ...`, the Output tab with the `[setup] Fetching SDK version ...` lines, and button `Debug with OpenHands`. Screenshot with `--feature F23.debug-with-openhands --name failed-logs`. `control-openhands browser click 'testid=debug-automation-button' --expect-url '/conversations/'` opens a new conversation; `control-openhands conversation events <conversation id from browser url> --kinds MessageEvent` starts `The scheduled automation \"QA_F23 Fail\" failed during a run. Please investigate the error and fix the root cause.`, and `control-openhands browser eval \"document.body.innerText.includes('Timed out: command timed out or was killed')\"` is `true` (the page also shows the run id). The agent then works on that prompt with the active profile.",
          "ids": [
            "F23.run-status-polling",
            "F23.run-no-conversation",
            "F23.run-logs-modal",
            "F23.debug-with-openhands"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Task outcome badge (`F23.run-task-outcome`).",
          "body": "Arrange (API, arrange only) `control-openhands api POST /api/automation/v1/preset/prompt --write --data '{\"name\":\"QA_F23 Blocked\",\"prompt\":\"Do not run any tools. Call the finish tool right away with status \\\"blocked\\\" and outcome_summary \\\"QA blocked on purpose\\\".\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":true}'` (a good thing to do while the Fail run is still running). Open QA_F23 Blocked, click `role=button[name=\"Run now\"]`, and poll `control-openhands browser text 'testid=automation-activity-log'` until it leaves Running (about 35 s): `<time>`, `QA blocked on purpose`, a cost such as `$0.0016`, `Blocked`; `control-openhands browser testids 'testid=automation-activity-log'` lists `run-status-icon-warning` (not `-completed`) although `api GET /api/automation/v1/<id>/runs` has `status` `COMPLETED` and `run_metadata.finish_tool_response.status` `blocked`. `control-openhands browser click 'testid=automation-activity-log >> title=View logs'` and `browser snapshot 'role=dialog'`: `Run` `COMPLETED`, `Task` `Blocked` / `QA blocked on purpose` / `Task metadata` `{ \"message\": ... }`. Screenshot with `--feature F23.run-task-outcome --name blocked`, Escape. `partial_success` (**Partial**, `run-status-icon-warning`) and `unknown` (**Needs review**, `run-status-icon-needs-review`) follow the same path with the same prompt shape. The normal automation 1.19.0 prompt/plugin presets configure SDK 1.53.0 FinishTool with the required `TaskOutcome.status`, limited to `success`, `partial_success`, `blocked`, `failed` and `unknown`; SDK custom response schemas are a separate producer boundary. Its normal prompt path therefore does not establish the new fallback for a finish response without a string status. That positive boundary needs a genuine completed run from a legacy or custom-schema producer whose real finish arguments have that shape; a run with no finish metadata checks only the ordinary Completed fallback. Arbitrary unrecognised strings and summary retention on a failed run need the corresponding genuine producer too. Until available, record those checks as blocked and do not treat injected run metadata or mocked LLM output as live proof (#17682).",
          "ids": [
            "F23.run-task-outcome"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Event automation (`F23.prompt-collapse`, `F23.configuration-event`, `F23.plugins-repo`, `F23.export`).",
          "body": "Open QA_F23 Event. `control-openhands browser bbox 'testid=automation-prompt-content'` is 240 px high, `testid=automation-prompt-fade` is present and `control-openhands browser text 'testid=automation-prompt-toggle'` is `View More`; screenshot with `--feature F23.prompt-collapse --name collapsed`. Click the toggle: height 360, no fade, text `View Less`; click again to collapse. `control-openhands browser text 'role=heading[name=\"Configuration\"] >> xpath=ancestor::div[2]'` reads `Repositories qa-example/qa-repo main`, `Trigger Event`, `Event Source github`, `Event Type pull_request.opened`, `Filter pull_request.draft == `false` && pull_request.base.ref == re…`, `Show more`, `LLM profile Active profile`. `control-openhands browser click 'role=button[name=\"Show more\"]'` shows the full filter and `control-openhands browser attr 'role=button[name=\"Show less\"]' aria-expanded` is `true`. `control-openhands browser text 'testid=automation-repository'` is `qa-example/qa-repo` / `main` (the fixture's `https://github.com/qa-example/qa-repo` as `owner/repo`, with its branch badge) and `control-openhands browser text 'role=heading[name=\"Plugins\"] >> xpath=ancestor::div[2]'` is `Plugins` / `github:qa-example/qa-plugin`; screenshot with `--feature F23.plugins-repo --name event-repo-plugins`. Export keeps them: `control-openhands browser click 'role=button[name=\"Automation actions\"]'`, `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Export\"]'` and `control-openhands browser downloads --last 1 --contains '\"branch\": \"main\"'` (`qa-f23-event.automation.json`, `contains` `true`); `--contains '\"repository\": \"https://github.com/qa-example/qa-repo\"'` and `--contains 'github:qa-example/qa-plugin'` are `true` too (the `--inspect` head ends inside the long prompt).",
          "ids": [
            "F23.prompt-collapse",
            "F23.configuration-event",
            "F23.plugins-repo",
            "F23.export"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Event trigger in Edit (`F23.edit-event-readonly`).",
          "body": "On QA_F23 Event open Edit: `control-openhands browser count 'testid=edit-automation-frequency'` is `0` and `control-openhands browser text 'role=form[name=\"Edit automation\"] >> text=Event Source >> xpath=ancestor::div[2]'` reads `Trigger Event / Event Source github / Event Type pull_request.opened / Filter <full filter>`. Screenshot with `--feature F23.edit-event-readonly --name event`, then Cancel.",
          "ids": [
            "F23.edit-event-readonly"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Phone (`F23.phone`).",
          "body": "Run `control-openhands browser viewport phone`, open QA_F23 Pong, `control-openhands browser bbox 'role=heading[name=\"QA_F23 Pong\"] >> xpath=ancestor::div[3]'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F23.phone --name detail` (the name and Run now wrap). Open Edit, then `control-openhands browser bbox 'role=form[name=\"Edit automation\"] >> xpath=..'` and `control-openhands browser bbox 'testid=edit-automation-save'`. Expected: both `insideViewport` `true`. Today the dialog is about 940 px high with `y` about `-48`, Save sits at `y` 831 (`insideViewport` `false`), the X at a negative `y` (about -30), and `control-openhands browser scroll 'testid=edit-automation-save'` cannot move it; screenshot with `--feature F23.phone --name edit-weekly`. Cancel and `control-openhands browser viewport desktop`.",
          "ids": [
            "F23.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Run now and save errors (`F23.run-now-error`, `F23.edit-save-error`).",
          "body": "Arrange a stale page: open QA_F23 Fail, then `control-openhands api DELETE /api/automation/v1/<Fail id> --write`. Run `control-openhands browser toasts --history --clear` (empties the toast history), click `role=button[name=\"Run now\"]`, then `control-openhands browser toasts --history` and `control-openhands browser screenshot --feature F23.run-now-error --name toasts`: exactly one toast, `Automation not found`, and no `Request failed with status code 404`. Expected: the page then shows **Automation not found**: `control-openhands browser wait 'role=heading[name=\"QA_F23 Fail\"]' --state detached --timeout 15000` succeeds (the not-found state takes about 7 s, as in States) and `control-openhands browser snapshot 'role=paragraph >> text=Automation not found >> xpath=..'` lists link `Back to Automations` (do not use `wait-text 'Automation not found'` here: the toast has the same text). Once the heading is gone, run `control-openhands browser toasts --history` again: it still holds only `Automation not found` (a late second toast, `Request failed with status code 404`, would come from the page's own refetch of the deleted automation). Known failure: the wait times out and the page keeps heading `QA_F23 Fail`, `Active` and an enabled **Run now** (`control-openhands browser enabled 'role=button[name=\"Run now\"]'` is `true`) until a reload (#18062). The save error does not reuse that page (it is gone once #18062 is fixed): arrange a second fixture, `control-openhands api POST /api/automation/v1/preset/prompt --write --data '{\"name\":\"QA_F23 Stale\",\"prompt\":\"Reply with the single word: pong.\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":false}'` (`body.id` is `<stale-id>`), then open QA_F23 Stale (`control-openhands browser goto /automations` and the card heading, as in Preconditions), `control-openhands browser click 'role=button[name=\"Automation actions\"]'` and `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Edit\"]'`. With the dialog open, `control-openhands api DELETE /api/automation/v1/<stale-id> --write` and `control-openhands browser toasts --history --clear`. Fill `testid=edit-automation-name` with `QA_F23 Stale renamed` and click `testid=edit-automation-save`: the dialog stays open (`browser count 'testid=edit-automation-save'` `1`, `browser value 'testid=edit-automation-name'` still `QA_F23 Stale renamed`), and `control-openhands browser toasts --history` holds exactly one toast, `Automation not found`; screenshot with `--feature F23.edit-save-error --name toast`. Close it with `control-openhands browser click 'testid=edit-automation-cancel'`. `browser errors --app-only` lists the induced `POST /api/automation/v1/<Fail id>/dispatch` and `PATCH /api/automation/v1/<stale-id>` 404s; both fixtures are already deleted.",
          "ids": [
            "F23.run-now-error",
            "F23.edit-save-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Load more runs (`F23.activity-log-more`).",
          "body": "Not run: it needs more than 20 runs of one automation (about 35 s and one model call each). With them, `control-openhands browser click 'testid=automation-activity-log >> role=button[name=\"Load more\"]'` grows the list from 20 to 40 rows.",
          "ids": [
            "F23.activity-log-more"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Script automation (`F23.script-section`).",
          "body": "Arrange a script automation through F22's bundle entry (UI arrange; F22 owns the creation proof): `control-openhands browser goto /automations/templates`, `control-openhands browser click 'testid=recommended-automation-card-news-digest' --expect-url '/automations/new/news-digest'`, `control-openhands browser fill 'testid=setup-field-schedule' '0 0 1 1 *'`, `control-openhands browser click 'testid=setup-continue-button'`, `control-openhands browser wait 'testid=setup-review' --timeout 10000`, `control-openhands browser click 'testid=setup-continue-button' --expect-url '/automations/[0-9a-f-]{8,}' --timeout 60000`. The detail page of **Daily news digest** shows `role=heading[name=\"Script\"]` and no Prompt (`browser count 'role=heading[name=\"Prompt\"]'` is `0`); `control-openhands browser text 'testid=automation-script-entrypoint'` is `python3 main.py`, and `browser eval \"Array.from(document.querySelectorAll('[data-testid=automation-script-file]')).map(e=>e.innerText.split('\\n').slice(0,2).join(' | '))\"` lists `main.py | Entrypoint` first, then `config.json` (both with line-numbered source). Screenshot with `--feature F23.script-section --name news-digest`. `automation-script-unavailable` (bundle unreadable) was not reached. Do not Run now: the script fetches public feeds and starts a model-backed conversation. Delete it in the Delete bullet.",
          "ids": [
            "F23.script-section"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "Delete (`F23.delete`).",
          "body": "Open QA_F23 Event, click the kebab and `role=list >> has-text=Tarball >> role=button[name=\"Delete\"]`. `control-openhands browser snapshot 'role=dialog[name=\"Delete automation\"]'` shows `Delete \"QA_F23 Event\"? This cannot be undone.`, `Cancel` and `Delete` (the same dialog as F21.delete). `control-openhands browser click 'role=dialog[name=\"Delete automation\"] >> role=button[name=\"Cancel\"]'` keeps the page (`control-openhands browser count 'role=dialog[name=\"Delete automation\"]'` is `0`). Reopen and `control-openhands browser click 'role=dialog[name=\"Delete automation\"] >> role=button[name=\"Delete\"]' --expect-url '/automations(\\?|$)'`; `control-openhands api GET /api/automation/v1` no longer lists it. Delete QA_F23 Pong, QA_F23 Blocked and Daily news digest the same way (and QA_F23 Fail if the error bullet was skipped); `api GET /api/automation/v1` shows `\"total\": 0`.",
          "ids": [
            "F23.delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-029",
          "label": "Errors.",
          "body": "After each group `control-openhands browser errors --app-only` shows no page errors; the only app errors are the induced ones (404s for the unknown id and the deleted fixture, 502s while the service is stopped).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F24",
      "title": "Automation Git Sync",
      "file": "F24-git-sync.md",
      "page": "F24-git-sync.html",
      "sha256": "1b293bcde633c4d05e09cdaceaa890477748a936479c35071ec22bc9ec7e08c8",
      "behaviors": [
        {
          "id": "F24.open",
          "description": "the **Git Sync** button on the Automate dashboard and the direct URL open the page (h1 `Git Sync`); **Back to Automations** returns to `/automations`."
        },
        {
          "id": "F24.loading",
          "description": "a skeleton (`git-sync-skeleton`) shows while health, permissions and status load."
        },
        {
          "id": "F24.overview-unconfigured",
          "description": "with nothing configured the card reads `Disabled`, `Not encrypted`, Repository `Not configured`, Branch `main`, Path `automations`, `Never synced` twice, `Manual only`, Pending `0`, and **Sync now** is disabled."
        },
        {
          "id": "F24.form-dirty",
          "description": "**Save Changes** and **Save and sync now** stay disabled until a field differs from the stored value (reverting disables them again); **Save and sync now** also needs **Enable Git Sync** on."
        },
        {
          "id": "F24.check-failure",
          "description": "a change to repository URL, branch or token is checked first (`Checking repository...`); an unreachable repo shows `Could not reach the repository with these settings` with git's output and the hint `Fix the settings above, or press Save again to store them anyway.`, saves nothing, and a second Save stores the values anyway."
        },
        {
          "id": "F24.save-and-sync",
          "description": "**Save and sync now** with a reachable repo saves (toast `Git Sync settings saved.`), then runs a cycle: the activity row goes `Syncing... started Ns ago` → `Sync complete`, and the card shows the short commit and `Last synced Ns ago`."
        },
        {
          "id": "F24.sync-now",
          "description": "a changed automation raises Pending changes (amber); **Sync now** shows `Syncing...` with `1 pending`, ends `Sync complete`, drops Pending to `0` and pushes `<path>/<slug>/automation.yaml` to the branch."
        },
        {
          "id": "F24.sync-failure",
          "description": "a failing cycle ends `Sync failed` and shows the **Last sync error** banner with git's message and its age; the banner survives a reload and disappears after the next successful cycle."
        },
        {
          "id": "F24.encryption",
          "description": "saving an encryption key turns the pill to `Encrypted` and the placeholder to `An encryption key is currently set`; automations exported afterwards are ciphertext; **Clear existing encryption key** disables the input and saving it returns to `Not encrypted`."
        },
        {
          "id": "F24.token",
          "description": "the access token is a password field that is never shown again; changing it runs the reachability check; **Clear existing token** disables the input and saves a cleared token."
        },
        {
          "id": "F24.author",
          "description": "commit author name and email are optional; an invalid email is refused by the browser before any request; saved values are used as the author of the next sync commit (the fields reload blank)."
        },
        {
          "id": "F24.interval",
          "description": "**Sync every (seconds)** saves `N` as `Every Ns`, blank or `0` as `Manual only`, and refuses negative numbers."
        },
        {
          "id": "F24.pause",
          "description": "turning **Enable Git Sync** off and saving (no reachability check) shows `Disabled`, disables **Sync now** and keeps the configuration; turning it back on restores `Enabled`."
        },
        {
          "id": "F24.sync-disabled-error",
          "description": "**Sync now** on a page that still shows `Enabled` after sync was turned off elsewhere shows the toast `Enable Git Sync before triggering a sync.`; the pill catches up on the next idle poll (≤ 15 s)."
        },
        {
          "id": "F24.background-sync",
          "description": "with an interval set, the backend runs cycles on its own and the open page picks up the new commit on its idle poll, without a reload."
        },
        {
          "id": "F24.repo-link",
          "description": "an http(s) repository is shown as a link (new tab) to the browsable URL without `.git` and without embedded credentials, and the link text drops the credentials too; a local path stays plain text."
        },
        {
          "id": "F24.repo-link-ssh",
          "description": "an scp-style ssh remote (`git@host:org/repo.git`) is also shown as a link, rebuilt over https (`https://host/org/repo`); the label keeps the configured value."
        },
        {
          "id": "F24.sync-delete",
          "description": "deleting a synced automation raises Pending changes; the next cycle commits the removal of its `<path>/<slug>/` folder."
        },
        {
          "id": "F24.field-defaults",
          "description": "emptying **Branch** or **Path** and saving clears the override instead of storing an empty value: the card and inputs fall back to `main` and `automations`."
        },
        {
          "id": "F24.clear-repo",
          "description": "emptying the repository URL is checked (`No repository URL is configured.`), and a second Save clears it: the card returns to `Not configured` and `Disabled`."
        },
        {
          "id": "F24.backend-down",
          "description": "with the automation service down the page shows **Automations Unavailable** with **Retry**; Retry after the service is back shows the page with the stored configuration."
        },
        {
          "id": "F24.phone",
          "description": "the page fits a 390 px viewport without horizontal overflow; the card's fields stay in two columns."
        },
        {
          "id": "F24.unsupported",
          "description": "an automation backend without the Git Sync API (status 404) shows `Git Sync is not available on this backend` with **Back to Automations**; the dashboard still shows the **Git Sync** button there."
        },
        {
          "id": "F24.no-access",
          "description": "a user without `manage_automations` (Cloud member) sees `Git Sync is managed by organization admins`, and the dashboard hides the **Git Sync** button."
        },
        {
          "id": "F24.conflict",
          "description": "saving a repository, branch and path that another organization already syncs is refused (409) with a toast naming the conflict."
        },
        {
          "id": "F24.error-state",
          "description": "any other status failure shows the generic automation error panel with **Retry**."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open from the dashboard (`F24.open`, `F24.loading`).",
          "body": "Run `control-openhands browser goto /automations`, then `control-openhands browser click 'testid=automations-git-sync' --expect-url '/automations/git-sync(\\?|$)' --observe 'testid=git-sync-skeleton' --observe-ms 2000`. The observation lists the skeleton (state `\"\"`) for a few tens of ms, then `<absent>`. `control-openhands browser text 'h1'` is `Git Sync`. Run `control-openhands browser click 'role=link[name=\"Back to Automations\"]' --expect-url '/automations(\\?|$)'`; the URL is `/automations`. Then `control-openhands browser goto /automations/git-sync` (direct URL) and `control-openhands browser screenshot --feature F24.open --name page-desktop --full-page`.",
          "ids": [
            "F24.open",
            "F24.loading"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Unconfigured card (`F24.overview-unconfigured`).",
          "body": "Run `control-openhands browser text 'role=heading[name=\"Sync Status\"] >> xpath=ancestor::div[2]'`. It reads `Sync Status / Disabled / Not encrypted / Sync now / Repository / Not configured / Branch / main / Path / automations / Last synced commit / Never synced / Last synced / Never synced / Sync every (seconds) / Manual only / Pending changes / 0` (newline-separated). `control-openhands browser enabled 'testid=git-sync-now-button'` is `false`.",
          "ids": [
            "F24.overview-unconfigured"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Dirty tracking (`F24.form-dirty`).",
          "body": "`control-openhands browser enabled 'testid=git-sync-save-button'` and `enabled 'testid=git-sync-save-and-sync-button'` are both `false`. Run `control-openhands browser fill 'testid=git-sync-interval-input' 300`: Save is `true`, Save and sync stays `false` (sync is off). `control-openhands browser fill 'testid=git-sync-interval-input' 0`: Save is `false` again. Run `control-openhands browser click 'testid=git-sync-enabled-switch >> xpath=ancestor::label'`; `control-openhands browser eval \"document.querySelector('[data-testid=git-sync-enabled-switch]').checked\"` is `true` and both buttons are `true`. Click the label again; Save is `false`.",
          "ids": [
            "F24.form-dirty"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Unreachable repository (`F24.check-failure`).",
          "body": "Run `control-openhands browser fill 'testid=git-sync-repo-url-input' /nonexistent/qa-missing.git` and `control-openhands browser click 'testid=git-sync-save-button' --observe 'testid=git-sync-save-button' --observe-ms 3000`; the observation includes `Checking repository...`. Run `control-openhands browser wait 'testid=git-sync-check-failure' --timeout 25000` and `control-openhands browser text 'testid=git-sync-check-failure'`: `Could not reach the repository with these settings`, git's `fatal: '/nonexistent/qa-missing.git' does not appear to be a git repository`, then `Fix the settings above, or press Save again to store them anyway.` Take `control-openhands browser screenshot 'testid=git-sync-check-failure' --feature F24.check-failure --name failure-block`. `control-openhands api GET /api/automation/v1/git-sync/status` still has `\"repo_url\": \"\"`. Click `testid=git-sync-save-button` again, then `control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000`; `browser count 'testid=git-sync-check-failure'` is `0`. After `control-openhands browser reload`, `control-openhands browser text 'testid=git-sync-enabled-pill'` is `Enabled` (configuring a repo turns sync on) and the card shows `/nonexistent/qa-missing.git` as plain text (`browser count 'testid=git-sync-repo-link'` is `0`).",
          "ids": [
            "F24.check-failure"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Failing cycle (`F24.sync-failure`).",
          "body": "Run `control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 8000`. The observation goes `Syncing...` / `started 0s ago` (counting up each second) and ends `Sync failed`; `control-openhands browser attr 'testid=git-sync-activity-row' data-state` is `failed`. Run `control-openhands browser wait 'testid=git-sync-error-banner' --timeout 20000` and `control-openhands browser text 'testid=git-sync-error-banner'`: `Last sync error`, `git command failed (128): git clone --origin origin /nonexistent/qa-missing.git .`, `fatal: repository '/nonexistent/qa-missing.git' does not exist`, `Ns ago`. Screenshot with `control-openhands browser screenshot --feature F24.sync-failure --name failed`. After `control-openhands browser reload` the banner count is `1` and the activity row count is `0` (the row is page-local).",
          "ids": [
            "F24.sync-failure"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Configure and Save and sync (`F24.save-and-sync`).",
          "body": "Run `control-openhands browser fill 'testid=git-sync-repo-url-input' \"$OH_VERIFY_RUN/workspace/qa-sync-remote\"`, `control-openhands browser fill 'testid=git-sync-branch-input' qa-sync`, `control-openhands browser fill 'testid=git-sync-path-input' qa-automations`, then `control-openhands browser click 'testid=git-sync-save-and-sync-button' --observe 'testid=git-sync-activity-row' --observe-ms 10000`. The observation goes `Syncing... started 0s ago` → `Sync complete` within a few seconds; `browser count 'testid=git-sync-check-failure'` and `browser count 'testid=git-sync-error-banner'` are `0` (a successful cycle clears the last error). The toast `Git Sync settings saved.` also fires, but it is gone before a 10 s observation ends; to see it, run `control-openhands browser wait-text 'Git Sync settings saved.' --timeout 5000` right after the click instead of `--observe`. The card text (as in Unconfigured card) shows `<repo-path>`, `qa-sync`, `qa-automations`, a 7-character commit and `Last synced / Ns ago`. `git -C <repo-path> log --oneline qa-sync` shows the fixture's `Initial fixture commit` (the cycle created the branch).",
          "ids": [
            "F24.save-and-sync"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Sync a changed automation (`F24.sync-now`).",
          "body": "Create `QA_F24 Sync` (Preconditions), `control-openhands browser reload`, then `control-openhands browser text 'text=Pending changes >> xpath=ancestor::div[1]/..'` (`Pending changes / 1`) and `control-openhands browser attr 'text=Pending changes >> xpath=ancestor::div[1]/.. >> span.text-warning' class` (`text-warning`). Screenshot with `control-openhands browser screenshot --feature F24.sync-now --name pending`. Run `control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 8000`: `Syncing... / started 0s ago / 1 pending`, then `control-openhands browser text 'testid=git-sync-activity-row'` is `Sync complete` and Pending changes reads `0`. `git -C <repo-path> log --oneline qa-sync` has a new `Sync automations from agent server` commit whose short hash matches the card's Last synced commit, and `git -C <repo-path> ls-tree -r --name-only qa-sync` lists `qa-automations/qa-f24-sync/automation.yaml` and `qa-automations/qa-f24-sync/tarball/...`.",
          "ids": [
            "F24.sync-now"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Commit author (`F24.author`).",
          "body": "Run `control-openhands browser fill 'testid=git-sync-author-email-input' not-an-email`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=git-sync-save-button'`, then `control-openhands browser eval \"document.querySelector('[data-testid=git-sync-author-email-input]').validationMessage\"` (`Please include an '@' in the email address. 'not-an-email' is missing an '@'.`); `control-openhands browser network` lists no `git-sync/config` request. Fill `testid=git-sync-author-name-input` with `'QA F24 Bot'` and `testid=git-sync-author-email-input` with `qa-f24@example.com`, click Save, `control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000`, `control-openhands browser reload`; `control-openhands browser value 'testid=git-sync-author-name-input'` is `\"\"` (the fields never show the stored author). Mark the automation changed (PATCH `{\"enabled\":false}`), `control-openhands browser reload`, click `testid=git-sync-now-button`, `control-openhands browser wait '[data-testid=git-sync-activity-row]:not([data-state=running])' --timeout 30000`; then `git -C <repo-path> log -1 --format='%an <%ae>' qa-sync` is `QA F24 Bot <qa-f24@example.com>`.",
          "ids": [
            "F24.author"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Encryption key (`F24.encryption`).",
          "body": "`control-openhands browser attr 'testid=git-sync-encryption-key-input' placeholder` is `No encryption key set`. Fill it with `qa-f24-dummy-key`, click Save (no `Checking repository...`: the key is not checked), wait for `Git Sync settings saved.`, `control-openhands browser reload`: `browser text 'testid=git-sync-encryption-pill'` is `Encrypted`, the placeholder is `An encryption key is currently set` and the input value is `\"\"`. Before changing the automation, run `control-openhands api GET /api/automation/v1/git-sync/status`, record `git -C <repo-path> rev-parse qa-sync`, click **Sync now** and wait as in Commit author, then read the head again and `git -C <repo-path> show qa-sync:qa-automations/qa-f24-sync/automation.yaml | head -c 20`. Expected: the key save alone causes a new commit and ciphertext (`gAAAAA`); known failure #551: `dirty_count` is `0`, the head is unchanged and the export stays plaintext. Record this outcome before any PATCH. Continue the separate dirty-export check: mark the automation changed (PATCH `{\"enabled\":true}` if the Commit author bullet left it disabled, otherwise `{\"enabled\":false}`; `api GET /api/automation/v1/git-sync/status` must show `dirty_count` ≥ 1), reload, click **Sync now** and wait as in Commit author; the same git read starts with `gAAAAA`. This PATCH arranges a dirty automation; its passing export does not prove the key-save re-export contract. Record the encrypted head, fill the key input with a different dummy key, Save, wait for the toast, reload and **Sync now** without changing the automation. Expected: the head and ciphertext change for the new key; known failure #551: `dirty_count` stays `0` and the head and ciphertext remain unchanged. If that failure occurs, arrange ciphertext under the new key with a separate enabled-state PATCH and sync before the clear test; do not count that repair as a key-rotation pass. Then fill the key input with any text, run `control-openhands browser click 'testid=git-sync-clear-encryption-key-switch >> xpath=ancestor::label'`: `control-openhands browser enabled 'testid=git-sync-encryption-key-input'` is `false` and its value is `\"\"`. Click Save, wait for the toast, reload: the pill is `Not encrypted`, the placeholder `No encryption key set`, and the clear switch is unchecked again. Record the encrypted head, read `git-sync/status`, click **Sync now** and wait without changing the automation, then compare the head and exported file. Expected: a new commit contains plaintext; known failure #551: `dirty_count` is `0`, the head is unchanged and ciphertext remains despite the `Not encrypted` pill. Repeat the non-dirty set, rotate and clear checks at `browser viewport phone`, arranging plaintext before set and ciphertext before rotate/clear; record desktop and phone separately with the actual selected backend in each evidence entry.",
          "ids": [
            "F24.encryption"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Access token (`F24.token`).",
          "body": "`control-openhands browser attr 'testid=git-sync-token-input' type` is `password` and the placeholder `Leave blank to keep the current token`. Fill it with `qa-dummy-token`, run `control-openhands browser network --clear`, click Save with `--observe 'testid=git-sync-save-button' --observe-ms 2000` (shows `Checking repository...`), wait for the toast; `control-openhands browser network --last 6` lists `/api/automation/v1/git-sync/check` then `/api/automation/v1/git-sync/config`. After reload the token input is `\"\"`. Run `control-openhands browser click 'testid=git-sync-clear-token-switch >> xpath=ancestor::label'`; `browser enabled 'testid=git-sync-token-input'` is `false`. Click Save, wait for the toast, reload; `browser eval \"document.querySelector('[data-testid=git-sync-clear-token-switch]').checked\"` is `false`.",
          "ids": [
            "F24.token"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Interval (`F24.interval`).",
          "body": "Fill `testid=git-sync-interval-input` with `300`, Save, wait for the toast, reload: the card's `Sync every (seconds)` field reads `Every 300s` (`control-openhands browser text 'role=heading[name=\"Sync Status\"] >> xpath=ancestor::div[2]'`) and the input value is `300`. Fill it with `''`, Save, reload: `Manual only` and input `0`. Fill `-5` and click Save: `browser eval \"document.querySelector('[data-testid=git-sync-interval-input]').validationMessage\"` is `Value must be greater than or equal to 0.`; reload to drop the draft.",
          "ids": [
            "F24.interval"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Pause and resume (`F24.pause`).",
          "body": "Click `testid=git-sync-enabled-switch >> xpath=ancestor::label`; `browser enabled 'testid=git-sync-save-and-sync-button'` is `false`. Run `control-openhands browser network --clear`, click Save, wait for the toast; `control-openhands browser network` lists no `git-sync/check`. After reload the pill is `Disabled`, `enabled 'testid=git-sync-now-button'` is `false` and the card still shows `<repo-path>`; screenshot `--feature F24.pause --name disabled`. The switch's help text (`control-openhands browser text 'testid=git-sync-enabled-switch >> xpath=ancestor::label/following-sibling::p'`) is `Sync is on as soon as a repository is configured. Turn this off to pause syncing without losing the configuration.` Click the switch label again, Save, wait for the toast: the pill is `Enabled`.",
          "ids": [
            "F24.pause"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Trigger while disabled elsewhere (`F24.sync-disabled-error`).",
          "body": "With the page showing `Enabled`, arrange `control-openhands api PUT /api/automation/v1/git-sync/config --write --data '{\"enabled\":false}'` (another admin pausing), then immediately `control-openhands browser click 'testid=git-sync-now-button'` and `control-openhands browser wait-text 'Enable Git Sync before triggering a sync.' --timeout 5000`. The pill still says `Enabled` at first; `control-openhands browser wait 'testid=git-sync-enabled-pill >> has-text=Disabled' --timeout 20000` succeeds within about 15 s. `browser errors --app-only` lists the induced `503` on `/api/automation/v1/git-sync/sync` (expected). Turn sync back on through the form (switch label, Save).",
          "ids": [
            "F24.sync-disabled-error"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Repository link (`F24.repo-link`).",
          "body": "Fill `testid=git-sync-repo-url-input` with `https://qa-user:qa-pass@git.example.invalid/qa-org/qa-repo.git`, click Save, `browser wait 'testid=git-sync-check-failure' --timeout 30000` (git's output redacts the URL as `https://***@git.example.invalid/...`; the failure itself is `CONNECT tunnel failed, response 502` behind the sandbox proxy, a resolve error elsewhere), click Save again, wait for the toast, reload. `control-openhands browser attr 'testid=git-sync-repo-link' href` is `https://git.example.invalid/qa-org/qa-repo` and `attr ... target` is `_blank`. `control-openhands browser text 'testid=git-sync-repo-link'` is `https://git.example.invalid/qa-org/qa-repo.git`: the link text drops `qa-user:qa-pass@` and keeps `.git`. Screenshot with `control-openhands browser screenshot 'role=heading[name=\"Sync Status\"] >> xpath=ancestor::div[2]' --feature F24.repo-link --name https-link`. Restore: fill the URL with `\"$OH_VERIFY_RUN/workspace/qa-sync-remote\"`, Save, wait for the toast; `browser count 'testid=git-sync-repo-link'` is `0` (a local path is plain text).",
          "ids": [
            "F24.repo-link"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Interval cycle (`F24.background-sync`).",
          "body": "Read the current short commit from the card (`control-openhands browser text 'text=Last synced commit >> xpath=ancestor::div[1]/..'`, `<old-hash>`). Fill `testid=git-sync-interval-input` with `20`, Save, wait for the toast; the card reads `Every 20s`. Mark the automation changed (PATCH `{\"enabled\":false}`), do not reload, and run `control-openhands browser wait 'text=\"<old-hash>\"' --state detached --timeout 60000`. It succeeds within about 35 s (interval plus the page's 15 s idle poll); the card shows a new hash and `Last synced / Ns ago`, matching `git -C <repo-path> log --oneline -1 qa-sync`. Set the interval back to `0` (fill, Save, toast) so later bullets are not raced by background cycles.",
          "ids": [
            "F24.background-sync"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Sync a deletion (`F24.sync-delete`).",
          "body": "Arrange the deletion (owned by F21): `control-openhands api DELETE /api/automation/v1/<automation-id> --write` (status `204`). `control-openhands browser reload`; `browser text 'text=Pending changes >> xpath=ancestor::div[1]/..'` is `Pending changes / 1`. Run `control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 6000` (`Syncing... / started 0s ago / 1 pending` → `Sync complete`); Pending changes reads `0`. `git -C <repo-path> log --oneline -1 qa-sync` is a new `Sync automations from agent server` commit matching the card's Last synced commit, and `git -C <repo-path> ls-tree -r --name-only qa-sync` lists only the fixture's `README.md` and `src/...` files: `qa-automations/qa-f24-sync/` is gone.",
          "ids": [
            "F24.sync-delete"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Empty branch and path (`F24.field-defaults`).",
          "body": "Run `control-openhands browser fill 'testid=git-sync-branch-input' ''` and `browser fill 'testid=git-sync-path-input' ''`, then `browser click 'testid=git-sync-save-button' --observe 'testid=git-sync-save-button' --observe-ms 2000` (`Checking repository...`: the branch changed; `main` exists in the fixture, so the check passes) and `browser wait-text 'Git Sync settings saved.' --timeout 10000`. After `browser reload` the card reads Branch `main` and Path `automations`, `browser value 'testid=git-sync-branch-input'` is `main` and `browser value 'testid=git-sync-path-input'` is `automations`; `control-openhands api GET /api/automation/v1/git-sync/status` has `\"branch\": \"main\"` and `\"path\": \"automations\"`. Do not press **Sync now** here: `main` is the fixture's checked-out branch.",
          "ids": [
            "F24.field-defaults"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "ssh remote link (`F24.repo-link-ssh`).",
          "body": "Fill `testid=git-sync-repo-url-input` with `git@github.com:qa-org/qa-repo.git`, click Save, `browser wait 'testid=git-sync-check-failure' --timeout 30000` (here git reports `ssh: not found`; with ssh installed it is an auth or host-key failure), click Save again, wait for the toast, reload. `control-openhands browser attr 'testid=git-sync-repo-link' href` is `https://github.com/qa-org/qa-repo`, `attr ... target` is `_blank` and `browser text 'testid=git-sync-repo-link'` is `git@github.com:qa-org/qa-repo.git`.",
          "ids": [
            "F24.repo-link-ssh"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Clear the repository (`F24.clear-repo`).",
          "body": "Fill `testid=git-sync-repo-url-input` with `''`, click Save, `control-openhands browser wait 'testid=git-sync-check-failure' --timeout 25000`; its text is `Could not reach the repository with these settings / No repository URL is configured. / Fix the settings above, ...`. Click Save again, wait for the toast, reload: the card reads `Disabled` and Repository `Not configured` (branch, path and last commit stay), and the URL input is `\"\"` with its placeholder `https://github.com/org/repo.git`.",
          "ids": [
            "F24.clear-repo"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Phone layout (`F24.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `control-openhands browser bbox 'role=heading[name=\"Sync Status\"] >> xpath=ancestor::div[2]'` (`insideViewport` `true`, `pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F24.phone --name status`: two columns of fields, long paths wrap. Then `control-openhands browser scroll 'testid=git-sync-repo-url-input'`, `control-openhands browser bbox 'testid=git-sync-save-and-sync-button'` (`pageHorizontalOverflow` `false`) and `control-openhands browser screenshot --feature F24.phone --name form`. Return with `control-openhands browser viewport desktop`.",
          "ids": [
            "F24.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Automation service down (`F24.backend-down`).",
          "body": "Run `control-openhands service stop automation`, `control-openhands browser reload` and `control-openhands browser wait-text 'Automations Unavailable' --timeout 40000`; the page shows the heading, `The automations backend is not available right now...` and **Retry**, and no Back link. Screenshot with `control-openhands browser screenshot --feature F24.backend-down --name unavailable`. `control-openhands browser network --clear`, `control-openhands browser click 'role=button[name=\"Retry\"]'`; `control-openhands browser network` shows a new `/api/automation/health` request and the panel stays. Run `control-openhands restart`, click Retry again and `control-openhands browser wait 'testid=git-sync-enabled-pill' --timeout 15000`: the page is back with the stored configuration. `control-openhands doctor` is `ok`.",
          "ids": [
            "F24.backend-down"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Old backend (`F24.unsupported`).",
          "body": "Needs a second, fresh run on an automation release without the Git Sync API (1.7.1 is the last one; 1.8.0 added `git_sync/router.py`). Stop the main run first if memory is short, then `export OH_VERIFY_RUN=$(OH_VERIFY_RUN= control-openhands launch --new --automation-ref 1.7.1 --print-run)` (uvx installs the tag from GitHub), `control-openhands doctor` (`ok`) and `control-openhands onboard --skip`. `control-openhands api GET /api/automation/v1/git-sync/status` is `404`. Run `control-openhands browser goto /automations/git-sync` and `control-openhands browser wait-text 'Git Sync is not available on this backend' --timeout 20000`; the page also reads `The automation backend is running a version without the Git Sync API. Update it to a version that supports Git Sync.` with **Back to Automations** below it. Screenshot with `control-openhands browser screenshot --feature F24.unsupported --name unsupported`. `control-openhands browser click 'role=link[name=\"Back to Automations\"]' --expect-url '/automations(\\?|$)'` returns to the dashboard, where `browser count 'testid=automations-git-sync'` is still `1`. `browser errors --app-only` lists only the expected `404` on `git-sync/status`. `control-openhands stop` this run.",
          "ids": [
            "F24.unsupported"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "No permission (`F24.no-access`).",
          "body": "Blocked: local backends always grant `manage_automations`. Needs a Cloud backend signed in as an organization member (not admin or owner); expected `Git Sync is managed by organization admins` / `Only organization admins and owners can view and configure Git Sync.` at `/automations/git-sync`, and `control-openhands browser count 'testid=automations-git-sync'` `0` on `/automations`.",
          "ids": [
            "F24.no-access"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Repository taken by another org (`F24.conflict`).",
          "body": "Blocked: the local backend has one organization. Needs two Cloud orgs; saving the same URL, branch and path in the second shows the error toast `Another organization already syncs this repository, branch and path. Sharing them would import each other's automations; use a different repository or path.` and keeps the form dirty for a retry.",
          "ids": [
            "F24.conflict"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Status error (`F24.error-state`).",
          "body": "Not driven: no non-mocked way makes `GET /api/automation/v1/git-sync/status` fail with a non-404 status while `/api/automation/health` is ok. Expected: the generic automation error panel with **Retry**.",
          "ids": [
            "F24.error-state"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Clean up.",
          "body": "The fixture automation was deleted in **Sync a deletion** (`control-openhands api GET /api/automation/v1` lists none) and Git Sync is cleared by **Clear the repository**; the fixture repo goes away with the run. Check `control-openhands browser errors --app-only` on the main run: `pageErrors` is `0`, and only the induced `503` on `git-sync/sync` from **Trigger while disabled elsewhere** and the `502`s on `/api/automation/health`, `git-sync/status`, `telemetry/consent` and `sdk-version` while the service was stopped are expected.",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F25",
      "title": "Backends, Cloud and sharing",
      "file": "F25-backends-and-cloud.md",
      "page": "F25-backends-and-cloud.html",
      "sha256": "d3e115eb528f9c778de79da2f4cd4923e72013a8b3dc0689ad001a0cc11ea2ff",
      "behaviors": [
        {
          "id": "F25.selector-dropdown",
          "description": "clicking (or hovering) the selector opens a listbox of backends, each option named `<status> <name>` with a status dot, and a footer with **Add Backend** and **Manage Backends**."
        },
        {
          "id": "F25.add-backend-modal",
          "description": "**Add Backend** opens \"Choose how you want to connect\" with a description, a **Learn more** link to the deployment docs and two tabs, **OpenHands Cloud** (selected) and **Agent-server**. Escape does not close it; the X does."
        },
        {
          "id": "F25.cloud-advanced-host",
          "description": "on the Cloud tab, **Advanced** reveals a **Host** field (placeholder `https://app.all-hands.dev`) for a self-hosted Cloud."
        },
        {
          "id": "F25.cloud-device-flow",
          "description": "**Connect to OpenHands** opens a popup and shows `Starting authentication...`, then `Waiting for authorization...` with the verification link and **Cancel**; Cancel returns to the idle button and closes the popup. An unreachable host shows `Failed to start device flow: ...` with **Try again**. Approving the code (adds a backend named OpenHands Cloud) needs a Cloud account."
        },
        {
          "id": "F25.agent-server-guidance",
          "description": "the Agent-server tab has a **Local / Remote** toggle. Local shows a collapsible **Before you connect** with `agent-canvas --backend-only --port 8001` and a local-setup docs link; Remote shows **Recommended setup**, **Connection details** and a remote-setup docs link."
        },
        {
          "id": "F25.add-agent-server",
          "description": "**Connect** stays disabled until Host Name and a valid Host are filled; the API Key field is optional for Connect on Local and required on Remote, but a key-protected host still rejects an empty key. A dead host, an empty key or a wrong key shows `Could not connect to <host>` with the reason inline (`Disconnected (check URL or network)` for a dead host, `Invalid API key` for an empty or wrong key); a reachable host with the right key, entered on the default Local location, adds the backend, makes it active (BM-001) and redirects a `/conversations/<id>` page to `/conversations` without an error toast, also on the backend's first activation, without asking the new backend for the old conversation id."
        },
        {
          "id": "F25.switch-backend",
          "description": "choosing another option shows a full-screen `Switching to <name>` overlay for about a second, then the sidebar and pages show that backend's data; on `/conversations/<id>` the app moves to `/conversations` (BM-002)."
        },
        {
          "id": "F25.backend-pinned-url",
          "description": "in-app links carry `?backend=<id>`; opening such a URL pins the tab to that backend; an unknown id is ignored."
        },
        {
          "id": "F25.per-tab-backend",
          "description": "the active backend is per browser tab: switching it in one tab leaves the other open tabs where they were, a new tab opened from a plain link starts from the last choice made in any tab, and a sidebar conversation row opened in a new tab (Control-click) lands on the backend that owns the conversation because its link carries `?backend=<owner>`."
        },
        {
          "id": "F25.manage-backends",
          "description": "Manage backends lists every backend with status dot, name, version (`v1.50.1`), host, status text and a LOCAL/CLOUD pill, plus Edit and Remove; Escape, a backdrop click, X and **Done** close it."
        },
        {
          "id": "F25.manage-add",
          "description": "**Add Backend** in Manage backends opens the same Add form stacked over Manage; its X returns to Manage, and a successful add keeps Manage open with the new row while the new backend becomes active."
        },
        {
          "id": "F25.manage-select",
          "description": "clicking a healthy row makes that backend active and closes the modal; on a detail page it leaves it for that section's list, like the selector does (BM-002)."
        },
        {
          "id": "F25.edit-backend",
          "description": "the pencil opens \"Edit backend\" pre-filled (key masked) with a `Connected · Local v<version>` badge; empty name and invalid host show `Name is required` and `Enter a valid URL (e.g. http://localhost:8080)` and disable Save; Save re-tests the connection, keeps the modal open with `Could not connect to <host>` on failure, and persists on success."
        },
        {
          "id": "F25.edit-cancel",
          "description": "**Cancel** and the X in Edit backend close only the Edit modal and discard the draft; Manage backends stays open with the stored values."
        },
        {
          "id": "F25.edit-escape",
          "description": "Escape in the Edit modal should not discard the draft (the modal disables Escape). Known failure, see Gotchas."
        },
        {
          "id": "F25.health-status",
          "description": "a backend that stops answering turns its dot red (`Disconnected`) within one health probe (every 30 s for local and remote agent servers; Cloud backends every 5 min and not on window focus or reconnect, #18128); its Manage row reads `Disconnected (check URL or network)` with a red detail line and cannot be selected."
        },
        {
          "id": "F25.recovery-gate",
          "description": "reloading while the active backend is down shows only `agent-server-onboarding-screen` with Manage backends in recovery mode (no X, no Done, Escape ignored, primary **Add Backend**); choosing a healthy row restores the app."
        },
        {
          "id": "F25.remove-backend",
          "description": "the trash icon asks `Remove backend \"<name>\"? If it is active, the app will switch back to Local.`; Cancel keeps it, Confirm removes it and an active removed backend falls back to Local (BM-003)."
        },
        {
          "id": "F25.collapsed-entry",
          "description": "with the rail collapsed, the backend icon's popover offers the same Add Backend and Manage Backends items, and both modals open."
        },
        {
          "id": "F25.phone",
          "description": "at 390 px the drawer's selector opens both modals inside the viewport without horizontal overflow."
        },
        {
          "id": "F25.device-verify-page",
          "description": "`/oauth/device/verify` shows a \"Device Authorization\" code form; with `?user_code=` it shows \"Device Authorization Request\", the code, a Security Notice and Cancel / Authorize Device. On a local stack Authorize and Continue end in the `Error` card with **Try Again**."
        },
        {
          "id": "F25.shared-conversation-view",
          "description": "`/shared/conversations/<id>` is a standalone read-only page (no app shell); on a local backend it reads `Conversation not found`."
        },
        {
          "id": "F25.share-publicly",
          "description": "on a Cloud backend the conversation menu has a **Public Share** toggle with copy-link and open-link buttons; on a local backend the item is absent."
        },
        {
          "id": "F25.cloud-org-rows",
          "description": "each Cloud backend appears once per organization as `<name> – <org>` (`Personal Workspace` for the user's own), and choosing one scopes lists to that org."
        },
        {
          "id": "F25.cloud-log-back-in",
          "description": "a logged-out Cloud row shows `Logged out` and a **Log back in** button that reruns the device flow."
        },
        {
          "id": "F25.cloud-settings-link",
          "description": "with a Cloud backend active the gear next to the selector opens `<cloud host>/settings?org=<org>` in a new tab."
        },
        {
          "id": "F25.cloud-sandbox-states",
          "description": "Cloud conversations show waiting, archived and error sandbox states in chat."
        },
        {
          "id": "F25.cloud-org-suspended",
          "description": "when a Cloud call made for the selected organization (`X-Org-Id`) fails with 403 and `detail` `Organization is suspended` or `User membership is suspended`, the app is replaced by `<org> is suspended. Contact your administrator to restore access.` (or `Your access to <org> is suspended. Contact your administrator to restore it.`), then `Switch to another workspace` and one button per other organization (`Personal Workspace` for the user's own). A button selects that organization and the app returns; with no other organization only the message shows. A reload clears the in-memory record. Blocked here: needs an OpenHands Enterprise instance with `ENABLE_SUPER_ADMIN` where a Super Admin suspended the organization or this member's membership, signed in as a non-Super-Admin member with that organization selected (#17988)."
        },
        {
          "id": "F25.locked-cloud",
          "description": "a Canvas served with `--lock-to-cloud <url>` shows only the Cloud login (no close) on first run, and its selector footer reads **Reconnect to Cloud** with no add, edit or remove."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Open the selector (`F25.selector-dropdown`).",
          "body": "From `/conversations` run `control-openhands browser click 'testid=backend-selector'` and `control-openhands browser snapshot 'testid=backend-selector'`. The snapshot shows `listbox` with `option \"Connected Local\"`, a separator, `button \"Add Backend\"` and `button \"Manage Backends\"`. Screenshot with `control-openhands browser screenshot --feature F25.selector-dropdown --name open`. Hover opens it too: `browser press Escape`, move the pointer away with `control-openhands browser hover 'testid=command-menu-trigger'` (hovering a selector the pointer is already on does nothing), `control-openhands browser hover 'testid=backend-selector'`, then the same snapshot shows `combobox \"Local\" [expanded]` and the two footer buttons.",
          "ids": [
            "F25.selector-dropdown"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Add Backend modal (`F25.add-backend-modal`).",
          "body": "Run `control-openhands browser click 'testid=add-backend-menu-item'` and `control-openhands browser snapshot 'testid=add-backend-modal'`: heading `Choose how you want to connect`, link `Compare Cloud, self-hosted, and Enterprise deployment options` (`/url: https://docs.openhands.dev/overview/introduction`, text `Learn more`) and tab `OpenHands Cloud ...` `[selected]` next to tab `Agent-server ...`. `control-openhands browser press Escape` leaves `browser count 'testid=add-backend-modal'` at `1`; `control-openhands browser click 'testid=add-backend-close'` makes it `0`.",
          "ids": [
            "F25.add-backend-modal"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Advanced host (`F25.cloud-advanced-host`).",
          "body": "Reopen with `browser click 'testid=backend-selector'` and `browser click 'testid=add-backend-menu-item'`, run `control-openhands browser click 'testid=add-backend-advanced-toggle'`, then `control-openhands browser attr 'testid=add-backend-advanced-toggle' aria-expanded` (`true`) and `control-openhands browser snapshot 'testid=add-backend-cloud-panel'` (textbox `Host` with placeholder `https://app.all-hands.dev`).",
          "ids": [
            "F25.cloud-advanced-host"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Device flow error (`F25.cloud-device-flow`).",
          "body": "Run `control-openhands browser fill 'testid=add-backend-cloud-host' http://127.0.0.1:9`, `control-openhands browser click 'testid=add-backend-login-button' --observe 'testid=add-backend-device-flow'` and `control-openhands browser wait 'testid=add-backend-auth-error' --timeout 15000`. The observation goes `Starting authentication...` → `Failed to start device flow: Failed to fetch` / `Try again`, and `control-openhands browser network --last 5` shows `POST /oauth/device/authorize` to `http://127.0.0.1:9`. `control-openhands browser click 'testid=add-backend-auth-retry'` starts again and fails the same way. Each attempt leaves an `about:blank` popup: `control-openhands browser tabs`, then `control-openhands browser close-tab <i>` for each extra page and `control-openhands browser tab 0`. Close the modal with `testid=add-backend-close`.",
          "ids": [
            "F25.cloud-device-flow"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Device flow awaiting and Cancel (`F25.cloud-device-flow`).",
          "body": "Reopen Add Backend (Cloud tab, default host), run `control-openhands browser click 'testid=add-backend-login-button' --observe 'testid=add-backend-device-flow' --observe-ms 6000`, then `control-openhands browser snapshot 'testid=add-backend-device-flow'`. If `https://app.all-hands.dev` is reachable (`control-openhands browser network --external --last 5` lists it), the snapshot shows `Waiting for authorization...`, `Browser opened. Complete sign-in to continue. If browser didn't open, visit:`, a link `https://app.all-hands.dev/oauth/device/verify?user_code=<code>` and button `Cancel`; `browser tabs` lists a second page on that URL. Screenshot with `--feature F25.cloud-device-flow --name awaiting`, then `control-openhands browser click 'testid=add-backend-auth-cancel'`: the panel is back to `Connect to OpenHands` / `Advanced` and `browser tabs` lists one page. Never approve the code. If Cloud is unreachable you get the error state of the previous bullet instead.",
          "ids": [
            "F25.cloud-device-flow"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Agent-server guidance (`F25.agent-server-guidance`).",
          "body": "Run `control-openhands browser click 'testid=add-backend-option-agent-server'` and `control-openhands browser snapshot 'testid=add-backend-agent-server-panel'`: radiogroup `Agent-server location` with `Local` checked, heading `Before you connect`, textboxes `Host Name`, `Host`, `API Key` and `button \"Connect\" [disabled]`. `control-openhands browser click 'testid=add-backend-local-guidance-toggle'` and `browser snapshot 'testid=add-backend-local-guidance'` show the code `agent-canvas --backend-only --port 8001` and link `Read the local backend setup guide`. `control-openhands browser click 'testid=add-backend-location-option-remote'` and `control-openhands browser click 'testid=add-backend-remote-guidance-toggle'`; `browser snapshot 'testid=add-backend-remote-guidance'` shows `Recommended setup`, `Connection details` and `Read the remote backend setup guide`.",
          "ids": [
            "F25.agent-server-guidance"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Connect rules and errors (`F25.add-agent-server`).",
          "body": "Still on Remote: `control-openhands browser fill 'testid=add-backend-name' QA_Second`, `control-openhands browser fill 'testid=add-backend-host' 127.0.0.1:9`; `control-openhands browser enabled 'testid=add-backend-submit'` is `false` (Remote needs a key). `control-openhands browser fill 'testid=add-backend-api-key' wrong-key` makes it `true`. `control-openhands browser click 'testid=add-backend-location-option-local'` keeps the fields. `control-openhands browser click 'testid=add-backend-submit' --observe 'testid=add-backend-submit'` shows `Checking…`, then `control-openhands browser text 'testid=add-backend-error'` starts `Could not connect to http://127.0.0.1:9` / `Disconnected (check URL or network). ...`. Fill `add-backend-host` with `<second-url>` and submit again: the error reads `Could not connect to <second-url>` / `Invalid API key`. On Local the key is optional for Connect but not for a key-protected host: `control-openhands browser fill 'testid=add-backend-api-key' ''` leaves `control-openhands browser enabled 'testid=add-backend-submit'` at `true` (`browser value 'testid=add-backend-api-key'` reports `length` `0`), and `control-openhands browser click 'testid=add-backend-submit'` then `control-openhands browser wait 'testid=add-backend-error' --timeout 15000` shows the same `Could not connect to <second-url>` / `Invalid API key` (`browser screenshot --feature F25.add-agent-server --name local-no-key-error`). Close with `testid=add-backend-close`.",
          "ids": [
            "F25.add-agent-server"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Add the second backend (`F25.add-agent-server`).",
          "body": "Run `control-openhands browser goto /conversations/<id>`, `browser click 'testid=backend-selector'`, `browser click 'testid=add-backend-menu-item'`, `browser click 'testid=add-backend-option-agent-server'`, fill `add-backend-name` with `QA_Second` and `add-backend-host` with `<second-url>`, then `control-openhands browser fill 'testid=add-backend-api-key' --value-file \"$OH_VERIFY_RUN_2/private/session-key\"` and `control-openhands browser click 'testid=add-backend-submit' --expect-url '/conversations(\\?|$)'`. The URL is `/conversations`, `browser count 'testid=add-backend-modal'` is `0`, `browser snapshot 'testid=backend-selector'` shows `combobox \"QA_Second\"`, and `browser count 'testid=conversation-card'` is `0`. Before anything reloads the page, `control-openhands browser toasts --history` lists only `Loading...` and the new backend's `Your LLM isn't set up yet, so conversations won't run. ...` (the second stack has no LLM profile; no error toast), and `control-openhands browser network --external --filter 'ids='` lists no request: the new backend is never asked for the first stack's conversation id. (`--external` leaves out this stack's own `GET /api/conversations?ids=<id>` from the `goto /conversations/<id>` above, which the request log keeps across page loads; `<second-url>` is another origin, so a request to it is listed.) The new backend asks its own telemetry consent (\"This preference is saved for the local backend “QA_Second”\"): run `control-openhands onboard --skip` (it leaves the browser on `/`). After `control-openhands browser reload` the selector still reads `QA_Second`.",
          "ids": [
            "F25.add-agent-server"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Switch with the overlay (`F25.switch-backend`).",
          "body": "From `/` run `browser click 'testid=backend-selector'` and `control-openhands browser click 'testid=backend-selector >> role=option[name=\"Connected Local\"]' --observe 'testid=environment-switch-overlay' --observe-ms 2500`. The observation shows `Switching to Local`, then `<absent>` about one second later; the selector reads `Local` and `browser count 'testid=conversation-card'` is `1`. Then `control-openhands browser click 'testid=conversation-card' --expect-url '/conversations/[0-9a-f-]+'`, open the selector and `control-openhands browser click 'testid=backend-selector >> role=option[name=\"Connected QA_Second\"]' --expect-url '/conversations(\\?|$)'`: the URL is `/conversations`, the selector reads `QA_Second` and the card count is `0`.",
          "ids": [
            "F25.switch-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Pinned URL (`F25.backend-pinned-url`).",
          "body": "With `QA_Second` active run `control-openhands browser goto '/conversations/<id>?backend=default-local'` and `control-openhands browser wait 'testid=chat-interface'`: the selector reads `Local` and the conversation opens. `control-openhands browser goto '/conversations?backend=qa-unknown-id'` keeps `Local` (card count `1`). The sidebar card links themselves end in `?backend=default-local` (`browser snapshot`).",
          "ids": [
            "F25.backend-pinned-url"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Per-tab backend (`F25.per-tab-backend`).",
          "body": "With `Local` active and `QA_Second` registered, run `control-openhands browser goto /conversations` and `control-openhands browser eval \"JSON.parse(sessionStorage.getItem('openhands-active-backend')).backendId\"` (`default-local`); note QA_Second's id with `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).find(b=>b.name==='QA_Second').id\"` (`<second-id>`, a generated UUID). Open a plain second tab, as a user opening the app again: `control-openhands browser tab new /conversations` prints `index` `1` and makes it the daemon's active tab (`control-openhands browser tabs` lists two pages at `/conversations` with `active` `1`); `control-openhands browser wait-tab '/conversations$' --new --timeout 5000` then prints `index` `0`, the tab you came from, because `--new` leaves out the tab the daemon is on and that is now the new one (run from `browser tab 0` it prints `index` `1`, the second tab). `control-openhands browser tab 1`, `control-openhands browser wait 'testid=backend-selector'`: `browser snapshot 'testid=backend-selector'` reads `combobox \"Local\"` and `control-openhands browser eval \"sessionStorage.getItem('openhands-active-backend')\"` is `null` (a new tab inherits nothing and starts from the localStorage fallback). Switch this tab: `browser click 'testid=backend-selector'`, `control-openhands browser click 'testid=backend-selector >> role=option[name=\"Connected QA_Second\"]'`, `control-openhands browser wait 'testid=environment-switch-overlay' --state detached --timeout 10000`; the selector reads `QA_Second`, `browser count 'testid=conversation-card'` is `0` and `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-active-backend')).backendId\"` is `<second-id>`. Back in `control-openhands browser tab 0` nothing moved: the selector still reads `Local`, the card count is `1`, the sessionStorage eval is still `default-local` while the localStorage eval is `<second-id>` (`browser screenshot --feature F25.per-tab-backend --name first-tab-unchanged`). A plain new tab starts from the last choice made in any tab: from tab 0 run `control-openhands browser tab new /conversations` (`index` `2`, now active), `browser wait 'testid=backend-selector'`: the selector reads `QA_Second`, the card count is `0`, its sessionStorage item is `null` and the localStorage eval is `<second-id>` (`--name plain-new-tab-last-choice`). A conversation row opens on its owner instead: `control-openhands browser tab 0`, `control-openhands browser click 'testid=conversation-card' --modifiers Control` (add `>> nth=0` when the run holds more conversations than the precondition's one: the click is strict), `control-openhands browser wait-tab '/conversations/<id>' --timeout 20000` prints `index` `3` and the URL `/conversations/<id>?backend=default-local`; `control-openhands browser tab 3`, `control-openhands browser wait 'testid=chat-interface' --timeout 30000`, `browser url` still ends in `/conversations/<id>?backend=default-local` (no bounce to `/conversations`), the selector reads `Local` and `browser toasts --history` lists only `Loading...` (no `This conversation does not exist…`); `browser screenshot --feature F25.per-tab-backend --name new-tab-on-owner`. Close the extra tabs highest first: `control-openhands browser close-tab 3`, `control-openhands browser close-tab 2`, `control-openhands browser close-tab 1`, `control-openhands browser tab 0`; `browser tabs` lists one page and the selector reads `Local`. Opening the pinned link also recorded `default-local` as the last choice (the localStorage eval in tab 3 reads `default-local`), so run the plain-new-tab check before the row check.",
          "ids": [
            "F25.per-tab-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Manage list and closing (`F25.manage-backends`).",
          "body": "Run `browser click 'testid=backend-selector'`, `control-openhands browser click 'testid=manage-backends-menu-item'` and `control-openhands browser snapshot 'testid=manage-backends-modal'`: heading `Manage backends`, two rows `Connected Local v1.50.1 <this run's URL> Connected Local` and `Connected QA_Second v1.50.1 <second-url> Connected Local`, each with `Edit` and `Remove`, then `Add Backend` and `Done`. `browser text 'testid=manage-backends-status-QA_Second'` is `Connected` and `browser text 'testid=manage-backends-version-QA_Second'` is `v1.50.1` (whatever `doctor` reports). Screenshot with `--feature F25.manage-backends --name list`. Close it four ways, reopening between: `browser press Escape`, `control-openhands browser mouse-click 20 500` (backdrop), `browser click 'testid=close-manage-backends-modal'`, `browser click 'testid=manage-backends-done'`; each time `browser count 'testid=manage-backends-modal'` is `0`.",
          "ids": [
            "F25.manage-backends"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Select from Manage (`F25.manage-select`).",
          "body": "With `Local` active, `control-openhands browser click 'testid=conversation-card' --expect-url '/conversations/[0-9a-f-]+'`, open Manage Backends, then `control-openhands browser click 'testid=manage-backends-row-QA_Second >> role=button >> nth=0' --expect-url '/conversations(\\?|$)'`. Like the selector, the app leaves the detail page for `/conversations` (BM-002): `browser count 'testid=manage-backends-modal'` is `0`, the selector reads `QA_Second`, `browser count 'testid=chat-interface'` and `browser count 'testid=conversation-card'` are `0`, and `browser screenshot --feature F25.manage-select --name after-select` shows QA_Second's Home with its empty conversation list.",
          "ids": [
            "F25.manage-select"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Edit validation and errors (`F25.edit-backend`).",
          "body": "Open Manage Backends, `control-openhands browser click 'testid=manage-backends-edit-QA_Second'`, then `browser value 'testid=edit-backend-host'` (`<second-url>`), `browser attr 'testid=edit-backend-api-key' type` (`password`) and `browser text 'testid=edit-backend-status'` (`Connected`, `·`, `Local`, `v1.50.1` on separate lines). Fill `edit-backend-name` with an empty string and `edit-backend-host` with `not a host`, then `browser focus 'testid=edit-backend-api-key'` and `browser snapshot 'testid=edit-backend-form'`: alerts `Name is required` and `Enter a valid URL (e.g. http://localhost:8080)`; `browser enabled 'testid=edit-backend-submit'` is `false`.",
          "ids": [
            "F25.edit-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Escape in Edit (`F25.edit-escape`).",
          "body": "In the same Edit modal press `control-openhands browser press Escape`. Expected: the Edit modal stays (it disables Escape) or only it closes. Today `browser count 'testid=edit-backend-modal'` and `browser count 'testid=manage-backends-modal'` are both `0`: the whole stack closes and the draft is lost. Nothing was saved (`browser snapshot 'testid=backend-selector'` still reads `QA_Second`).",
          "ids": [
            "F25.edit-escape"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "Edit and save (`F25.edit-backend`).",
          "body": "Reopen Manage Backends and the pencil. Fill `edit-backend-host` with `http://127.0.0.1:9`, `browser click 'testid=edit-backend-submit'`, `browser wait 'testid=edit-backend-error' --timeout 10000`: the text starts `Could not connect to http://127.0.0.1:9` and the modal stays. Fill the host back with `<second-url>` and `edit-backend-name` with `QA_Renamed`, click `edit-backend-submit`, then `control-openhands browser wait 'testid=edit-backend-modal' --state detached --timeout 10000`; `browser count 'testid=manage-backends-row-QA_Renamed'` is `1`. Click `manage-backends-done`, `browser reload`: the selector reads `QA_Renamed`.",
          "ids": [
            "F25.edit-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Health status (`F25.health-status`).",
          "body": "With QA_Renamed the active backend (as the Edit bullet's reload leaves it; the closed selector shows only the active backend's dot), run `control-openhands service stop agent-server --run \"$OH_VERIFY_RUN_2\"`, then on the open page `control-openhands browser wait 'testid=backend-selector >> role=status[name=\"Disconnected\"]' --timeout 40000`. `browser click 'testid=backend-selector'` and `browser snapshot 'testid=backend-selector'` show `option \"Disconnected QA_Renamed\"` next to `option \"Connected Local\"`. In Manage Backends `browser text 'testid=manage-backends-status-QA_Renamed'` is `Disconnected (check URL or network)`, `testid=manage-backends-status-detail-QA_Renamed` repeats it with `Check that the backend URL is correct ...`, and `browser enabled 'testid=manage-backends-row-QA_Renamed >> role=button >> nth=0'` is `false`. An error toast with the same text also appears. Screenshot with `--feature F25.health-status --name manage-down`.",
          "ids": [
            "F25.health-status"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Recovery gate (`F25.recovery-gate`).",
          "body": "Close the modal, `control-openhands browser reload`, then `control-openhands browser wait 'testid=manage-backends-modal' --timeout 20000`. `browser testids` lists `agent-server-onboarding-screen` and the Manage rows but no sidebar; `browser count 'testid=close-manage-backends-modal'` and `browser count 'testid=manage-backends-done'` are `0`, and `browser press Escape` leaves the modal (`count` `1`). `control-openhands browser click 'testid=manage-backends-row-Local >> role=button >> nth=0'` and `browser wait 'testid=agent-server-onboarding-screen' --state detached --timeout 15000` restore the app: selector `Local`, card count `1`. Screenshot the gate with `--feature F25.recovery-gate --name gate` before choosing.",
          "ids": [
            "F25.recovery-gate"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Remove the active backend (`F25.remove-backend`).",
          "body": "Run `control-openhands restart --run \"$OH_VERIFY_RUN_2\"`, `browser click 'testid=backend-selector'`, `browser wait 'testid=backend-selector >> role=option[name=\"Connected QA_Renamed\"]' --timeout 25000` and click that option (selector reads `QA_Renamed`). Open Manage Backends, `control-openhands browser click 'testid=manage-backends-remove-QA_Renamed'`; `browser text 'testid=confirmation-modal'` reads `Remove backend \"QA_Renamed\"? If it is active, the app will switch back to Local.` `control-openhands browser click 'testid=confirmation-modal >> role=button[name=\"Cancel\"]'` keeps the row (`count 'testid=manage-backends-row-QA_Renamed'` `1`). Click the trash again, then `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'`: the row count is `0`. Click Done and `browser reload`: the selector reads `Local`, `browser count 'testid=backend-selector >> role=option'` (dropdown open) is `1`, and the card count is `1`.",
          "ids": [
            "F25.remove-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Add from Manage, Edit Cancel and X (`F25.manage-add`, `F25.edit-cancel`).",
          "body": "The second stack is running again after the previous bullet. Run `browser goto /conversations/<id>`, `browser wait 'testid=chat-interface'`, open the selector, click `manage-backends-menu-item`, then `control-openhands browser click 'testid=manage-backends-add'` and `control-openhands browser wait 'testid=add-backend-modal' --timeout 5000` (`visible`); `browser count 'testid=manage-backends-modal'` is `1` underneath. `browser click 'testid=add-backend-close'`: the Add count is `0` and the Manage count stays `1`. Click `manage-backends-add` again, `add-backend-option-agent-server`, fill `add-backend-name` with `QA_Second`, `add-backend-host` with `<second-url>` and `add-backend-api-key` with `--value-file \"$OH_VERIFY_RUN_2/private/session-key\"`, then `browser click 'testid=add-backend-submit' --expect-url '/conversations(\\?|$)'`: the URL is `/conversations`, `count 'testid=add-backend-modal'` is `0`, `count 'testid=manage-backends-modal'` is still `1`, `count 'testid=manage-backends-row-QA_Second'` is `1` and the selector reads `QA_Second` (screenshot `--feature F25.manage-add --name after-add`). No consent prompt this time: it is stored on the second backend. Then `control-openhands browser click 'testid=manage-backends-edit-QA_Second'`, `browser fill 'testid=edit-backend-name' QA_Draft` and `control-openhands browser click 'testid=edit-backend-cancel'`: `count 'testid=edit-backend-modal'` `0`, `count 'testid=manage-backends-modal'` `1`, row `QA_Second` `1`, row `QA_Draft` `0`. Repeat with `control-openhands browser click 'testid=edit-backend-close'`: same counts, and reopening the pencil shows `browser value 'testid=edit-backend-name'` `QA_Second`; leave with `edit-backend-cancel`. Remove it again: `browser click 'testid=manage-backends-remove-QA_Second'`, `browser click 'testid=confirmation-modal >> testid=confirm-button'`, `browser click 'testid=manage-backends-done'` and `browser reload`: the selector reads `Local`.",
          "ids": [
            "F25.manage-add",
            "F25.edit-cancel"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Collapsed rail (`F25.collapsed-entry`).",
          "body": "Run `control-openhands browser click 'testid=sidebar-collapse-toggle'` (`attr 'aside[data-collapsed]' data-collapsed` is `true`), `control-openhands browser hover 'testid=collapsed-backend-selector-link'`, `control-openhands browser click 'testid=add-backend-menu-item'` and `control-openhands browser wait 'testid=add-backend-modal' --timeout 5000` (`visible`). Close it, hover again, click `manage-backends-menu-item` and `browser wait 'testid=manage-backends-modal' --timeout 5000`. Close with Done and expand with `browser click 'testid=sidebar-collapse-toggle'`.",
          "ids": [
            "F25.collapsed-entry"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Phone (`F25.phone`).",
          "body": "Run `control-openhands browser viewport phone`, `browser goto /conversations`, `control-openhands browser click 'testid=sidebar-mobile-menu-toggle'`, `control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=backend-selector'`, `browser click 'testid=add-backend-menu-item'`, `browser click 'testid=add-backend-option-agent-server'` and `control-openhands browser bbox 'testid=add-backend-modal'`: `insideViewport` `true`, `pageHorizontalOverflow` `false`. Screenshot `--feature F25.phone --name add-modal`. Close it (the drawer stays open), open Manage Backends from the drawer selector the same way and check `bbox 'testid=manage-backends-modal'` the same. Return with `browser viewport desktop`.",
          "ids": [
            "F25.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "Device verify page (`F25.device-verify-page`).",
          "body": "Run `control-openhands browser goto /oauth/device/verify` and `browser snapshot`: heading `Device Authorization`, `Enter the code displayed on your device:`, textbox `Device Code:` and button `Continue`, inside the normal app shell. `control-openhands browser fill 'role=textbox[name=\"Device Code:\"]' QA-0000` and `browser click 'role=button[name=\"Continue\"]'` go straight to the result: heading `Error`, `Failed to authorize device. Please try again.`, button `Try Again`; `browser network --filter verify-authenticated` shows `POST /oauth/device/verify-authenticated` `404`. Then `control-openhands browser goto '/oauth/device/verify?user_code=QA-0000'`: heading `Device Authorization Request`, `DEVICE CODE`, `QA-0000`, `Security Notice`, buttons `Cancel` and `Authorize Device` (screenshot `--feature F25.device-verify-page --name with-code`). `browser click 'role=button[name=\"Cancel\"]'` does nothing in a tab the page did not open (URL unchanged). `browser click 'role=button[name=\"Authorize Device\"]'` and `browser wait 'role=heading[name=\"Error\"]'` show the same Error card; **Try Again** reloads to the request.",
          "ids": [
            "F25.device-verify-page"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Shared view (`F25.shared-conversation-view`).",
          "body": "Run `control-openhands browser goto /shared/conversations/<id>` and `control-openhands browser wait-text 'Conversation not found' --timeout 15000`. The page has no sidebar; `browser network --filter shared` shows `GET /api/shared-conversations` and `/api/shared-events/search` `404`. Screenshot `--feature F25.shared-conversation-view --name local-not-found`.",
          "ids": [
            "F25.shared-conversation-view"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "No Public Share locally (`F25.share-publicly`).",
          "body": "Run `browser goto /conversations/<id>`, `control-openhands browser click 'testid=conversation-name >> testid=ellipsis-button'` and `control-openhands browser count 'testid=share-publicly-button'`: `0` on a local backend (`browser testids --filter button` lists `rename-button`, `show-skills-button`, `show-agent-tools-button`, `export-transcript-button`, `download-trajectory-button`, `display-cost-button` and `delete-button`; `show-hooks-button` and `stop-button` appear only while the conversation is active (idle, running, waiting for confirmation or finished), so a paused or errored conversation has neither). The toggle, `copy-share-link-button` and `open-share-link-button` need a Cloud backend: blocked.",
          "ids": [
            "F25.share-publicly"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Cleanup.",
          "body": "`browser press Escape`, then `control-openhands stop --run \"$OH_VERIFY_RUN_2\"` and `unset OH_VERIFY_RUN_2`. The conversation stays with this run's state.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Errors.",
          "body": "`control-openhands browser errors --app-only` after the family shows no page errors. Expected app-origin entries: `POST /oauth/device/verify-authenticated` 404 (twice), the shared-view 404s, the warning `Scripts may close only the windows that were opened by them.` from the device-verify Cancel, and a burst of CORS `console.error`s for `<second-url>` while the second agent server is stopped or restarting (see Gotchas).",
          "ids": [],
          "children": []
        }
      ]
    },
    {
      "id": "F26",
      "title": "Launcher modes, Docker, desktop and library",
      "file": "F26-runtime-variants.md",
      "page": "F26-runtime-variants.html",
      "sha256": "c0fe68da0d5d3badac36f71c26692015435526ba7851dc4fa2928322a308d783",
      "behaviors": [
        {
          "id": "F26.cli-version",
          "description": "`--version` and `-v` print the package version and exit 0."
        },
        {
          "id": "F26.cli-info",
          "description": "`--info` prints the package version, the default agent-server and automation pins, the minimum agent-server version, the default ports and the override env vars."
        },
        {
          "id": "F26.cli-help",
          "description": "`--help` prints usage, auth modes, options, env vars and examples."
        },
        {
          "id": "F26.cli-flag-conflicts",
          "description": "`--frontend-only` with `--backend-only`, and `--public` with `--frontend-only`, exit 1 with a one-line error before anything starts."
        },
        {
          "id": "F26.cli-public-needs-key",
          "description": "`--public` without `LOCAL_BACKEND_API_KEY` exits 1 with guidance."
        },
        {
          "id": "F26.cli-missing-build",
          "description": "a package without `build/` exits 1 with `No build found` and build instructions."
        },
        {
          "id": "F26.cli-port-in-use",
          "description": "an occupied ingress (or service) port stops the launch with `Cannot start: the following ports are already in use` naming each busy port."
        },
        {
          "id": "F26.loopback-bind-default",
          "description": "without `--host` the ingress listens on `127.0.0.1` only: the machine's own non-loopback address refuses the connection while loopback serves the app."
        },
        {
          "id": "F26.session-key-rotated",
          "description": "in local mode a stale session key left in the browser (the launcher's key changed since the last visit) is replaced by the injected key on load: the app opens on Home without the API-key screen, and the stored `Local` backend holds the new key (public mode asks for it instead: F01's api-key-entry row)."
        },
        {
          "id": "F26.session-key-persist",
          "description": "in local mode without `LOCAL_BACKEND_API_KEY` the launcher generates a session key, saves it under `$HOME/.openhands/agent-canvas/api-key.txt` and injects the same key again on the next start."
        },
        {
          "id": "F26.session-key-pinned",
          "description": "`LOCAL_BACKEND_API_KEY=<key>` in local mode injects exactly that key into the page and the API accepts only it (the saved generated key gets 401)."
        },
        {
          "id": "F26.frontend-only",
          "description": "`--frontend-only` serves the SPA; `/server_info`, `/api/*`, `/sockets/*` and `/api/automation/*` answer 503, and a browser new to that origin opens on the **Add a backend** onboarding step without an error toast."
        },
        {
          "id": "F26.frontend-only-returning",
          "description": "on a frontend-only origin, a browser that already stores a backend for that origin (the `default-local` entry a full launcher on the same port seeded, or one added there earlier) gets the recovery gate instead of onboarding: `agent-server-onboarding-screen` with Manage backends in recovery mode and the `Local` row `Disconnected` with the 503 detail (the gate itself is F25's recovery-gate row); the failing probes raise generic `An error occurred` toasts there (Known failure, reproduced 2026-10-08: #18160)."
        },
        {
          "id": "F26.backend-only",
          "description": "`--backend-only` serves the APIs (key-protected) and answers 503 `No backend configured for this route` for `/` and static assets."
        },
        {
          "id": "F26.cross-connect",
          "description": "a frontend-only UI connects to a separate backend-only instance through **Add a backend**; the shell then shows that backend as Connected and all API traffic goes to it."
        },
        {
          "id": "F26.remote-backend",
          "description": "from a normal Canvas, backend selector → **Add Backend** → **Agent-server** → **Remote** shows the self-hosting guidance and connects to another instance (stand-in for a self-hosted VM, `docs/SELF_HOSTING.md`)."
        },
        {
          "id": "F26.seeded-local-backend",
          "description": "the first load of a launcher-served UI seeds one backend `Local` (id `default-local`) with the page origin and the injected key."
        },
        {
          "id": "F26.host-bind-lan",
          "description": "`--host 0.0.0.0` warns, listens on the machine's LAN address, and serves an index.html that carries `__AGENT_CANVAS_AUTH_REQUIRED__` and neither `__AGENT_CANVAS_SESSION_API_KEY__` nor the key itself (the loopback launcher's page carries the key marker); the UI behaves like `--public` (Add a backend with Next disabled; Skip leads to the API-key screen)."
        },
        {
          "id": "F26.host-bind-lan-optin",
          "description": "the opt-in the launcher's warning recommends (`--allow-lan-session-key`) should restore key injection on a LAN bind. Known failure (reproduced 2026-10-08): the npm launcher ignores the flag and the page still carries only `__AGENT_CANVAS_AUTH_REQUIRED__`; #17949."
        },
        {
          "id": "F26.host-bind-env",
          "description": "`OH_BIND_HOST=0.0.0.0` (the environment form of `--host`) prints the same two \"not loopback\" warnings and does not inject the session key, even with `LOCAL_BACKEND_API_KEY` set."
        },
        {
          "id": "F26.runtime-services",
          "description": "`/server_info.runtime_services` lists the agent-server, ingress, frontend and automation URLs as the agent sees them, and a new conversation's system prompt carries a matching `<RUNTIME_SERVICES>` block."
        },
        {
          "id": "F26.runtime-services-agent-use",
          "description": "with that block alone, the agent can reach the automation backend from its terminal: asked to, it reads the OpenAPI at the URL the block names, creates an enabled cron automation, and dispatches a run that completes; the automation and its run show up in the Automations UI."
        },
        {
          "id": "F26.lib-build",
          "description": "`npm run build:lib` produces `dist/` where every `package.json` `exports` target exists and the main entry exports `AgentServerUIProviders`, `AgentServerUIRoot`, `CloudOrganizationBoundary` and the telemetry helpers."
        },
        {
          "id": "F26.lib-style-scope",
          "description": "all bundled CSS is scoped under `[data-agent-server-ui]`, and theme tokens such as `--oh-color-base` live on that scope root (observable in the standalone app)."
        },
        {
          "id": "F26.lib-host-app",
          "description": "mounted in a separate host app, Canvas styles stay inside the scope and `styleOverrides` restyle it. Blocked: no host-app example exists."
        },
        {
          "id": "F26.docker-image",
          "description": "`docker run ghcr.io/openhands/agent-canvas` serves Canvas at `/canvas`; without `AGENT_CANVAS_ALLOW_LAN_SESSION_KEY=true` the UI asks for the key. Blocked without a Docker daemon. Known failure (reproduced 2026-10-08): on a host whose kernel has no IPv6 (no `/proc/net/if_inet6`), `docker/entrypoint.sh` starts the static server with `--host ::`, which exits with `listen EAFNOSUPPORT: address family not supported :::8000`, and the container stops within about half a minute of start, with exit code `0` (#18178)."
        },
        {
          "id": "F26.docker-conversation-runtime",
          "description": "`OH_CONVERSATION_RUNTIME=docker` runs each new conversation in its own container. Blocked without a Docker daemon."
        },
        {
          "id": "F26.helm-chart",
          "description": "`helm install agent-canvas ./helm/agent-canvas` runs the Docker image as a Kubernetes StatefulSet with a PVC and an Ingress. Blocked without `helm` and a cluster."
        },
        {
          "id": "F26.desktop-boot-splash",
          "description": "the Electron app first shows a dark splash (logo, **OpenHands Agent Canvas**, spinner, `Starting backend services…`, then live service-log lines, a first-launch note and **Show details**)."
        },
        {
          "id": "F26.desktop-main-window",
          "description": "after boot a native window with a File/Edit/View/Window menu loads `http://localhost:8000` with the key injected (first run, no API-key screen)."
        },
        {
          "id": "F26.desktop-macos-titlebar",
          "description": "on macOS the desktop window hides the native title bar and reserves a 28 px drag band (`titlebar-drag-region`, `aria-hidden`) above the shell, so the sidebar logo clears the traffic lights and the window can be dragged; the band is dropped in native fullscreen, also after a reload while fullscreen. Linux and Windows windows and browser tabs render no band (`window.desktopShell` is undefined in a tab; `platform` is `linux` on Linux). The band is rendered by the root layout, its error shell and the config-loading spinner, not by the first-run onboarding screen. Blocked without macOS (#17474)."
        },
        {
          "id": "F26.desktop-external-links",
          "description": "external links in the desktop window open in the system browser for allowed schemes only. Not driven: the harness cannot click inside the Electron window."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Version (`F26.cli-version`).",
          "body": "Run `node bin/agent-canvas.mjs --version; echo \"exit=$?\"` and `node bin/agent-canvas.mjs -v; echo \"exit=$?\"`. Each prints one line equal to `jq -r .version package.json` (the checkout's package version, which changes with every release: compare, do not expect a number; e.g. `[ \"$(node bin/agent-canvas.mjs --version)\" = \"$(jq -r .version package.json)\" ] && echo same`) and `exit=0`.",
          "ids": [
            "F26.cli-version"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Info (`F26.cli-info`).",
          "body": "Run `node bin/agent-canvas.mjs --info`. It prints `@openhands/agent-canvas <version>` (the `--version` value), `Default stack versions:` with `agent-server: <pin>` and `automation: <pin>`, `Compatibility:` `agent-server: >= <minimum>`, `Default ports:` `ingress: 8000`, `agent-server: 18000`, `automation: 18001`, and the override variables `OH_AGENT_SERVER_VERSION, OH_AGENT_SERVER_GIT_REF, OH_AGENT_SERVER_LOCAL_PATH` / `OH_AUTOMATION_VERSION, OH_AUTOMATION_GIT_REF`. The three versions are read from `config/defaults.json` (`versions.agentServer`, `versions.automation`, `compatibility.minimumAgentServer`) and move with every pin bump, so compare them with the file instead of expecting numbers: `test \"$(node bin/agent-canvas.mjs --info | awk '/^(Default stack versions|Compatibility):/{s=1;next} /^$/{s=0} s{print $NF}' | paste -sd' ' -)\" = \"$(jq -r '[.versions.agentServer,.versions.automation,.compatibility.minimumAgentServer]|join(\" \")' config/defaults.json)\" && echo match || echo mismatch` prints `match`, and `printf '%s\\n' \"$(jq -r .compatibility.minimumAgentServer config/defaults.json)\" \"$(jq -r .versions.agentServer config/defaults.json)\" | sort -VC && echo pin-meets-minimum || echo below-minimum` prints `pin-meets-minimum` (the default Agent Server passes the UI's own version gate).",
          "ids": [
            "F26.cli-info"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Help (`F26.cli-help`).",
          "body": "Run `node bin/agent-canvas.mjs --help`. It prints `USAGE:`, `AUTH MODES:` (`--public ... Users must paste it when the UI loads.`), `OPTIONS:` (`-p, --port`, `-H, --host`, `--public`, `--frontend-only`, `--backend-only`, `-v, --version`, `--info`, `-h, --help`), `ENVIRONMENT VARIABLES:` and `EXAMPLES:`, exit 0.",
          "ids": [
            "F26.cli-help"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Conflicting flags (`F26.cli-flag-conflicts`).",
          "body": "Run `node bin/agent-canvas.mjs --frontend-only --backend-only; echo \"exit=$?\"` → `Error: --frontend-only and --backend-only cannot be used together`, `exit=1`. Run `node bin/agent-canvas.mjs --public --frontend-only; echo \"exit=$?\"` → `Error: --public cannot be used with --frontend-only`, `exit=1`.",
          "ids": [
            "F26.cli-flag-conflicts"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Public mode needs a key (`F26.cli-public-needs-key`).",
          "body": "Run `T=$(mktemp -d); env -u LOCAL_BACKEND_API_KEY HOME=$T OH_CANVAS_SAFE_STATE_DIR=$T/state timeout 60 node bin/agent-canvas.mjs --public --port 18990; echo \"exit=$?\"; rm -rf $T`. After `✓ uvx found` it prints `✗ PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.` and `exit=1`. (The successful `--public` launch is `control-openhands launch --new --public`, mapped in F01.)",
          "ids": [
            "F26.cli-public-needs-key"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Missing build (`F26.cli-missing-build`).",
          "body": "Never move this checkout's `build/` (other runs serve it); copy the launcher instead: `T=$(mktemp -d); mkdir -p $T/bin $T/config; cp bin/agent-canvas.mjs $T/bin/; cp package.json $T/; cp config/defaults.json $T/config/; node $T/bin/agent-canvas.mjs --port 18990; echo \"exit=$?\"; rm -rf $T`. It prints `Error: No build found at <T>/build`, the `npm install` / `npm run build` hint and `exit=1`.",
          "ids": [
            "F26.cli-missing-build"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Port in use (`F26.cli-port-in-use`).",
          "body": "Point a second launcher at your run's ingress port: `P=$(control-openhands status | jq -r .ports.ingress); T=$(mktemp -d); HOME=$T OH_CANVAS_SAFE_STATE_DIR=$T/state timeout 60 node bin/agent-canvas.mjs --port $P; echo \"exit=$?\"; rm -rf $T`. It prints `Cannot start: the following ports are already in use:` with `• ingress: port <P>` and `Another agent-canvas instance may already be running.`, then exits 1. Your run is untouched (`control-openhands doctor` stays ok).",
          "ids": [
            "F26.cli-port-in-use"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Loopback-only ingress (`F26.loopback-bind-default`).",
          "body": "Your run was started without `--host`. Run `P=$(control-openhands status | jq -r .ports.ingress); curl -s -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:$P/` (`200`), then find the machine's own address portably, `LAN=$(node -e 'for (const a of Object.values(require(\"os\").networkInterfaces()).flat()) if (a.family === \"IPv4\" && !a.internal) { console.log(a.address); break }'); echo \"LAN=$LAN\"`, and `curl -s -o /dev/null -w '%{http_code}\\n' --connect-timeout 3 http://$LAN:$P/; echo \"exit=$?\"`. At that address curl prints `000` and `exit=7` (connection refused). If `LAN` is empty the machine has no non-loopback address: record the loopback half and leave the refused-connection half `not-run` with that reason (it is the behavior this ID asserts, so an empty `LAN` is never a pass). Read-only second view, Linux form: `awk -v p=$(printf '%04X' $P) 'NR>1 && $4==\"0A\" && $2 ~ \":\"p\"$\" {print $2}' /proc/net/tcp` prints only `0100007F:<hex port>` (`127.0.0.1` in `/proc/net/tcp`'s byte order; elsewhere list the listener with the OS's socket tool, for example `ss -ltn 'sport = :'$P` or `lsof -nP -iTCP:$P -sTCP:LISTEN`, and expect `127.0.0.1:<P>` only), and `grep -a 'Listening on' $OH_VERIFY_RUN/private/stack.log` shows the ingress banner `Listening on: http://localhost:<P>/`. The LAN half of `F26.host-bind-lan` below is the contrast: there the same curl answers `200`.",
          "ids": [
            "F26.loopback-bind-default"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Backend-only (`F26.backend-only`).",
          "body": "Start it: `Q=$OH_VERIFY_RUN/private/f26-backend; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 LOCAL_BACKEND_API_KEY=qa-f26-backend-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --backend-only --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid`. Wait until `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/health` is `200` (30–90 s; the first start downloads the agent-server into the private `HOME`). Then `curl -s http://127.0.0.1:18960/` prints `No backend configured for this route` (status 503, also for `/index.html` and `/assets/x.js`), `/server_info` is 200, `/api/settings` is 401 without a key and 200 with `-H 'X-Session-API-Key: qa-f26-backend-key'`. Keep it running for the next two bullets.",
          "ids": [
            "F26.backend-only"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Frontend-only (`F26.frontend-only`).",
          "body": "Start it: `Q=$OH_VERIFY_RUN/private/f26-frontend; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18971 OH_CANVAS_SAFE_AUTOMATION_PORT=18972 OH_CANVAS_SAFE_VITE_PORT=18973 DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --frontend-only --port 18970 > $Q/launcher.log 2>&1 & echo $! > $Q/pid` (ready in a few seconds). `for u in / /server_info /api/settings /sockets/events/x /api/automation/v1 /vscode/; do curl -s -o /dev/null -w \"%{http_code} $u\\n\" http://127.0.0.1:18970$u; done` prints `200 /` and `503` for the four backend paths and for `/vscode/`: the launcher serves the VS Code editor and reserves its path, and `grep -a 'vscode -> 503' $Q/launcher.log` prints `[static] /vscode -> 503 (rejected)` (see Gotchas). In the browser: `control-openhands browser goto http://127.0.0.1:18970/ --allow-external`, `control-openhands browser value 'testid=onboarding-backend-name'` (`Local`), `control-openhands browser value 'testid=onboarding-backend-host'` (`http://127.0.0.1:18970`, the page origin), `sleep 12`, `control-openhands browser toasts --history` (`[]`: no error toast) and `control-openhands browser screenshot --feature F26.frontend-only --name add-backend-step` (the **Add a backend** card).",
          "ids": [
            "F26.frontend-only"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Cross-connect (`F26.cross-connect`).",
          "body": "On that page run `control-openhands browser fill 'testid=onboarding-backend-name' 'QA Backend Only'`, `control-openhands browser fill 'testid=onboarding-backend-host' 'http://127.0.0.1:18960'`, `control-openhands browser fill 'testid=onboarding-backend-api-key' qa-f26-backend-key`, `control-openhands browser click 'testid=onboarding-backend-next'` and `control-openhands browser wait 'testid=onboarding-step-choose-agent' --timeout 20000`. On a backend state that has never answered telemetry consent, the consent dialog opens on top (`control-openhands browser wait 'testid=telemetry-consent-form' --timeout 10000` succeeds; it appears a second or two after the step, so an immediate `browser count` can still read `0`; consent is stored on the backend, so a second pass with the same `$Q` skips it): `control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'`, `control-openhands browser click 'testid=confirm-telemetry-preferences'`, then `control-openhands browser click 'testid=onboarding-skip'` and `control-openhands browser wait 'testid=root-layout' --timeout 20000`. After `control-openhands browser reload`, `control-openhands browser snapshot 'testid=backend-selector'` shows `status \"Connected\"` and `combobox \"QA Backend Only\"`. `control-openhands browser goto http://127.0.0.1:18970/settings/secrets --allow-external` and `control-openhands browser count 'testid=secret-item >> has-text=OPENHANDS_AUTOMATION_API_KEY'` is `1` (the backend-only instance's seeded secret), and `control-openhands browser network --filter 18960 --last 5` shows `byOrigin` with only `http://127.0.0.1:18960`. `control-openhands browser errors --app-only` has `pageErrors` `0`.",
          "ids": [
            "F26.cross-connect"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Remote backend from a normal Canvas (`F26.remote-backend`).",
          "body": "Needs the backend-only instance. Run `control-openhands browser goto /`, `control-openhands browser click 'testid=backend-selector'`, `control-openhands browser click 'testid=add-backend-menu-item'`, `control-openhands browser click 'testid=add-backend-option-agent-server'` and `control-openhands browser click 'testid=add-backend-location-option-remote'`. `control-openhands browser text 'testid=add-backend-agent-server-panel'` includes `Run the remote backend with --public, set a strong LOCAL_BACKEND_API_KEY, and expose it through an SSH tunnel, ngrok, or a TLS reverse proxy.` Fill `testid=add-backend-name` with `QA_Remote`, `testid=add-backend-host` with `http://127.0.0.1:18960` and `testid=add-backend-api-key` with `qa-f26-backend-key`, click `testid=add-backend-submit`, then `control-openhands browser wait 'testid=add-backend-modal' --state detached --timeout 15000`, `control-openhands browser reload` and `control-openhands browser snapshot 'testid=backend-selector'`: `status \"Connected\"`, `combobox \"QA_Remote\"` (the new backend becomes active). If you run this bullet without Cross-connect first, the backend-only state has not answered telemetry consent yet: the consent dialog (`This preference is saved for the local backend “QA_Remote” at http://127.0.0.1:18960.`) opens over the page and blocks the selector; answer it as in Cross-connect (`control-openhands browser wait 'testid=telemetry-consent-form' --timeout 10000`, `control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'`, `control-openhands browser click 'testid=confirm-telemetry-preferences'`). Switch back with `control-openhands browser click 'testid=backend-selector'` and `control-openhands browser click 'role=option[name=\"Local\"]'`, then `control-openhands browser wait 'testid=backend-selector >> role=combobox[name=\"Local\"]' --timeout 5000` (the switch applies after a short delay: a snapshot taken right after the click still reads `combobox \"QA_Remote\": Local`; after the wait it shows `combobox \"Local\"`). Clean up: `backend-selector` → `testid=manage-backends-menu-item`; `control-openhands browser text 'testid=manage-backends-row-QA_Remote'` reads `QA_Remote`, `v<version>`, `http://127.0.0.1:18960`, `Connected`, `LOCAL`, where `<version>` is the backend-only instance's own report, `curl -s http://127.0.0.1:18960/server_info | jq -r .version` (the `versions.agentServer` pin in `config/defaults.json` unless your shell exports an `OH_AGENT_SERVER_*` override, which a hand-started launcher inherits); it passes the UI's gate: `printf '%s\\n' \"$(jq -r .compatibility.minimumAgentServer config/defaults.json)\" \"$(curl -s http://127.0.0.1:18960/server_info | jq -r .version)\" | sort -VC && echo meets-minimum || echo below-minimum` prints `meets-minimum`. Then click `testid=manage-backends-remove-QA_Remote`, `testid=confirmation-modal >> testid=confirm-button`, `testid=manage-backends-done`, `browser reload`.",
          "ids": [
            "F26.remote-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Seeded Local backend (`F26.seeded-local-backend`).",
          "body": "On your run, `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,name:b.name,host:b.host,kind:b.kind,hasKey:!!b.apiKey}))\"` returns exactly one entry `{id: \"default-local\", name: \"Local\", host: \"http://127.0.0.1:<ingress>\", kind: \"local\", hasKey: true}` (after the Remote cleanup above), and `control-openhands browser eval \"({key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__), authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})\"` is `{key: true, authRequired: false}`.",
          "ids": [
            "F26.seeded-local-backend"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Rotated session key (`F26.session-key-rotated`).",
          "body": "Still on your run's `/`, `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends'))[0].apiKey === window.__AGENT_CANVAS_SESSION_API_KEY__\"` is `true` (compare keys inside the page; never print one). Run `md5sum $OH_VERIFY_RUN/private/session-key | cut -c1-12`, then `control-openhands restart --rotate-key` (`rotatedKey` `true`: the launcher comes back injecting a new key) and the `md5sum` line again; the hash changed. The open page still holds the old key, so its polls get `401` until it reloads: run `control-openhands browser errors --clear`, then `control-openhands browser reload` and `control-openhands browser wait 'testid=home-screen' --timeout 20000`. `control-openhands browser count` is `1` for `testid=root-layout` and `0` for `testid=api-key-entry-screen`, `testid=agent-server-onboarding-screen` and `testid=onboarding-modal`: Home, not the key prompt public mode shows in the same state (`F01.api-key-entry`). Second view: `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,host:b.host,keyMatchesPage:b.apiKey===window.__AGENT_CANVAS_SESSION_API_KEY__}))\"` is `[{id: \"default-local\", host: \"http://127.0.0.1:<ingress>\", keyMatchesPage: true}]` against the new page key, `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-agent-server-config')).sessionApiKey === window.__AGENT_CANVAS_SESSION_API_KEY__\"` is `true` (the legacy key is overwritten too), `control-openhands browser network --filter 'api/settings' --last 2` shows `status` `200` under `recent`, and `control-openhands browser errors --app-only` has `pageErrors` `0` and `appErrors` `0`. `control-openhands api GET /api/settings` works with the rotated key, and `control-openhands browser screenshot --feature F26.session-key-rotated --name home-after-rotation` shows Home.",
          "ids": [
            "F26.session-key-rotated"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Stop the partial stacks.",
          "body": "`kill -TERM $(cat $OH_VERIFY_RUN/private/f26-frontend/pid) $(cat $OH_VERIFY_RUN/private/f26-backend/pid)`; after about 5 s the ports 18960–18963 and 18970–18973 are free again (check as in Preconditions). Kill by the saved pid only, never by pattern.",
          "ids": [],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "LAN bind (`F26.host-bind-lan`).",
          "body": "Start a full stack on all interfaces: `Q=$OH_VERIFY_RUN/private/f26-lan; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 LOCAL_BACKEND_API_KEY=qa-f26-lan-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --host 0.0.0.0 --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid`, and wait until `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/` is `200`. `grep -a 'not loopback' $Q/launcher.log` shows `[auth] Bind host 0.0.0.0 is not loopback — session key will not be injected into HTML` and `[static] WARNING: bind host 0.0.0.0 is not loopback; refusing to inject the session API key into HTML.` The served page proves it: `curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\\(SESSION_API_KEY\\|AUTH_REQUIRED\\)__' | sort -u` prints only `__AGENT_CANVAS_AUTH_REQUIRED__` and `curl -s http://127.0.0.1:18960/ | grep -c qa-f26-lan-key` prints `0`, while your loopback run's page, `curl -s \"$(control-openhands status | jq -r .baseUrl)/\" | grep -o '__AGENT_CANVAS_\\(SESSION_API_KEY\\|AUTH_REQUIRED\\)__' | sort -u`, prints only `__AGENT_CANVAS_SESSION_API_KEY__`. The bind is real: with `LAN` from the loopback bullet, `curl -s -o /dev/null -w '%{http_code}\\n' --connect-timeout 3 http://$LAN:18960/` is `200` here (refused on your run, `F26.loopback-bind-default`; with an empty `LAN` this check is `not-run`). The browser part below expects a profile that has never visited `127.0.0.1:18960` (true in this file's order; on a repeat pass run `control-openhands browser reset` first and restore your run afterwards, see Gotchas). Then `control-openhands browser goto http://127.0.0.1:18960/ --allow-external` and `control-openhands browser eval \"({key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__), authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})\"` → `{key: false, authRequired: true}`. The onboarding shows `testid=onboarding-step-check-backend` with `control-openhands browser enabled 'testid=onboarding-backend-next'` `false` (no key typed). `control-openhands browser click 'testid=onboarding-skip'` and `control-openhands browser wait 'testid=api-key-entry-screen' --timeout 15000` succeed; after `sleep 12`, `control-openhands browser toasts --history` is `[]` (no error toast on either screen); `control-openhands browser screenshot --feature F26.host-bind-lan --name api-key-screen`. Fill `testid=api-key-entry-name` with `QA_LAN` and `testid=api-key-entry-api-key` with `qa-f26-lan-key`, click `testid=api-key-entry-submit`; `control-openhands browser wait 'testid=root-layout' --timeout 10000` succeeds. Stop it with `kill -TERM $(cat $Q/pid)`.",
          "ids": [
            "F26.host-bind-lan"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "LAN opt-in (`F26.host-bind-lan-optin`).",
          "body": "Expected: the opt-in the warning names (`Pass --host 127.0.0.1 (default) for local mode, or --allow-lan-session-key only if you accept LAN exposure.`) makes the npm launcher inject the key again. Start the LAN stack as above with `--host 0.0.0.0 --allow-lan-session-key --port 18960` (log to `$Q/launcher2.log`), wait for `200`, then `curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\\(SESSION_API_KEY\\|AUTH_REQUIRED\\)__' | sort -u`. Today it prints only `__AGENT_CANVAS_AUTH_REQUIRED__` and the log repeats both \"not loopback\" warnings: the launcher ignores the flag (fail; see Gotchas). Stop it with `kill -TERM $(cat $Q/pid)`.",
          "ids": [
            "F26.host-bind-lan-optin"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Generated session key persists (`F26.session-key-persist`).",
          "body": "With 18960–18963 free, define a helper and start a loopback stack without a key: `Q=$OH_VERIFY_RUN/private/f26-keys; mkdir -p $Q; start(){ HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 \"$@\" > $Q/launcher.log 2>&1 & echo $! > $Q/pid; for i in $(seq 1 60); do c=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/); [ \"$c\" = 200 ] && break; sleep 3; done; echo \"code=$c\"; }; start env -u LOCAL_BACKEND_API_KEY nohup node bin/agent-canvas.mjs --port 18960` (`code=200`). `curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_SESSION_API_KEY__[^;<]*' | md5sum` prints a hash (never print the key itself); `$Q/home/.openhands/agent-canvas/api-key.txt` exists (64 hex characters) and `curl -s -o /dev/null -w '%{http_code}' -H \"X-Session-API-Key: $(cat $Q/home/.openhands/agent-canvas/api-key.txt)\" http://127.0.0.1:18960/api/settings` is `200`. `kill -TERM $(cat $Q/pid); sleep 6`, run the same `start ...` line again in the same shell and repeat the `md5sum` line: the hash is identical.",
          "ids": [
            "F26.session-key-persist"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Pinned session key (`F26.session-key-pinned`).",
          "body": "Same shell, after `kill -TERM $(cat $Q/pid); sleep 6`: `start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key nohup node bin/agent-canvas.mjs --port 18960`. `curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_SESSION_API_KEY__[^;<]*'` prints `__AGENT_CANVAS_SESSION_API_KEY__=\"qa-f26-pin-key\"`; `/api/settings` is `200` with `-H 'X-Session-API-Key: qa-f26-pin-key'` and `401` with the saved generated key. In the browser, `control-openhands browser goto http://127.0.0.1:18960/ --allow-external` and `control-openhands browser eval \"({key: window.__AGENT_CANVAS_SESSION_API_KEY__ === 'qa-f26-pin-key', authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})\"` → `{key: true, authRequired: false}`.",
          "ids": [
            "F26.session-key-pinned"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Bind host from the environment (`F26.host-bind-env`).",
          "body": "Same shell, after `kill -TERM $(cat $Q/pid); sleep 6`: `start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key OH_BIND_HOST=0.0.0.0 nohup node bin/agent-canvas.mjs --port 18960`. `grep -a 'not loopback' $Q/launcher.log` shows the same `[auth]` and `[static]` warnings as `--host 0.0.0.0`, and `curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\\(SESSION_API_KEY\\|AUTH_REQUIRED\\)__' | sort -u` prints only `__AGENT_CANVAS_AUTH_REQUIRED__`. Stop it with `kill -TERM $(cat $Q/pid)`; after about 6 s ports 18960–18963 are free.",
          "ids": [
            "F26.host-bind-env"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "Frontend-only, returning user (`F26.frontend-only-returning`).",
          "body": "Same shell, after `kill -TERM $(cat $Q/pid); sleep 6`. Arrange a browser that onboarded on this origin against the full launcher: `start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key nohup node bin/agent-canvas.mjs --port 18960` (`code=200`), `control-openhands browser reset` (a fresh profile, which also forgets your run's onboarding: restore it at the end), `control-openhands browser goto http://127.0.0.1:18960/ --allow-external`, `control-openhands browser wait 'testid=telemetry-consent-form' --timeout 15000` (this backend state has never answered consent; a second pass on the same `$Q` skips the form), `control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'`, `control-openhands browser click 'testid=confirm-telemetry-preferences'`, `control-openhands browser wait 'testid=onboarding-step-choose-agent' --timeout 15000`, `control-openhands browser click 'testid=onboarding-skip'` and `control-openhands browser wait 'testid=root-layout' --timeout 20000`. `control-openhands browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,name:b.name,host:b.host}))\"` is `[{id: \"default-local\", name: \"Local\", host: \"http://127.0.0.1:18960\"}]`. Now serve the same port without backends: `kill -TERM $(cat $Q/pid); sleep 6; start nohup node bin/agent-canvas.mjs --frontend-only --port 18960` (`code=200`, and `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/server_info` is `503`). `control-openhands browser goto http://127.0.0.1:18960/ --allow-external` and `control-openhands browser wait 'testid=agent-server-onboarding-screen' --timeout 20000` succeed: the gate of `F25.recovery-gate`, not onboarding. `control-openhands browser count` is `1` for `testid=manage-backends-modal` and `0` for `testid=onboarding-step-check-backend`, `testid=first-run-onboarding-screen`, `testid=api-key-entry-screen`, `testid=root-layout`, `testid=close-manage-backends-modal` and `testid=manage-backends-done`; `control-openhands browser press Escape` leaves the modal (`count` `1`). `control-openhands browser text 'testid=manage-backends-row-Local'` reads `Local`, `http://127.0.0.1:18960`, `Disconnected`, `HTTP request failed (503 Service Unavailable): \"Service Unavailable (no backend configured for this route)\"` and `LOCAL`, and `control-openhands browser testids` lists `manage-backends-add` (**Add Backend**) as the way on. `control-openhands browser screenshot --feature F26.frontend-only-returning --name recovery-gate`. The 503 probes are expected errors in the sweep: `control-openhands browser errors` (without `--app-only`, which keeps only your run's origin) lists `external:http-error` rows with `503` for `http://127.0.0.1:18960/server_info`, `/api/settings` and `/api/llm/models/verified`, and `pageErrors` is `0`. Stop it with `kill -TERM $(cat $Q/pid)`; after about 6 s ports 18960–18963 are free. Restore your run: `control-openhands browser goto /` and `control-openhands onboard --skip`.",
          "ids": [
            "F26.frontend-only-returning"
          ],
          "children": []
        },
        {
          "anchor": "recipe-022",
          "label": "Runtime services (`F26.runtime-services`).",
          "body": "Run `control-openhands api GET /server_info --pick runtime_services.mode` (`agent-canvas`) and `control-openhands api GET /server_info --pick runtime_services.services.automation.url_from_agent` (`http://localhost:<automation port>`; `control-openhands status` prints the ports). Then `control-openhands conversation start --prompt \"Reply with the single word: ok\" --wait --timeout 180` (prints `<id>`) and `control-openhands conversation events <id> --grep RUNTIME_SERVICES --from-start`: the `SystemPromptEvent` matches, with an excerpt `<RUNTIME_SERVICES>\\nYou are running inside an agent-canvas dev stack started ...`. `control-openhands conversation events <id> --grep \"localhost:<automation port>\" --from-start` shows `* Automation backend: http://localhost:<port>` plus its `Docs:` and `OpenAPI:` URLs.",
          "ids": [
            "F26.runtime-services"
          ],
          "children": []
        },
        {
          "anchor": "recipe-023",
          "label": "The agent uses the runtime services (`F26.runtime-services-agent-use`).",
          "body": "Write `qa-f26-agent.txt` (outside the workspace) containing: ``Using the Automation backend described in your RUNTIME_SERVICES (read its OpenAPI to find the routes), create one automation named QA_rt_auto from a prompt: the prompt is \"Reply with the single word: pong. Do not run any tools.\", the schedule is the cron 0 9 * * *, and it is enabled. Then dispatch one run of it now. Do not change anything else. Reply with the automation id and the run id only.`` Run `control-openhands conversation start --prompt \"$(cat qa-f26-agent.txt)\" --wait --timeout 400` (note `<agent-id>`); the reply names `<automation-id>` and a run id. `control-openhands conversation events <agent-id> --kinds ActionEvent --from-start` shows the agent's own `curl` calls: `http://localhost:<automation port>/api/automation/openapi.json`, `POST …/api/automation/v1/preset/prompt` and `POST …/api/automation/v1/<automation-id>/dispatch`, each with the `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY` header the block names. Second view in the UI: `control-openhands browser goto /automations` and `control-openhands browser text '[data-testid^=automation-card-] >> has-text=QA_rt_auto'` (`QA_rt_auto`, the prompt, `cron`, `Runs 1`). Open it with `control-openhands browser click '[data-testid^=automation-card-] >> has-text=QA_rt_auto >> text=QA_rt_auto' --expect-url '/automations/[0-9a-f-]+'`. Within about a minute, `control-openhands browser text 'testid=automation-activity-log'` shows the run's summary (`Replied with the single word \"pong\" …`), a cost and `Successful` (`control-openhands browser screenshot --feature F26.runtime-services-agent-use --name agent-created-automation-run`). `control-openhands api GET /api/automation/v1/<automation-id>` has `name` `QA_rt_auto`, `trigger.schedule` `0 9 * * *` and `enabled` `true`. Delete it afterwards (`control-openhands api DELETE /api/automation/v1/<automation-id> --write`, arrange). It runs daily at 09:00 UTC otherwise.",
          "ids": [
            "F26.runtime-services-agent-use"
          ],
          "children": []
        },
        {
          "anchor": "recipe-024",
          "label": "Library build (`F26.lib-build`).",
          "body": "Never build inside the shared checkout (it regenerates i18n and typegen files the other runs hash); build in a copy: `L=$OH_VERIFY_RUN/private/f26-lib; mkdir -p $L; tar --exclude=./node_modules --exclude=./build --exclude=./.git --exclude=./dist -cf - . | tar -xf - -C $L; ln -s \"$PWD/node_modules\" $L/node_modules; (cd $L && npm run build:lib); echo \"exit=$?\"`. It ends with `✓ built in` and `exit=0` (about 30 s). Then `(cd $L && node -e 'const p=require(\"./package.json\"),fs=require(\"fs\");let ok=0,miss=[];for(const[k,v]of Object.entries(p.exports))for(const f of(typeof v===\"string\"?[v]:Object.values(v)))fs.existsSync(f)?ok++:miss.push(k+\" -> \"+f);console.log(JSON.stringify({entries:Object.keys(p.exports),ok,missing:miss}))')` prints 9 entries (`.`, `./browser`, `./conversation`, `./files`, `./settings`, `./sidebar`, `./terminal`, `./i18n`, `./package.json`), `ok` 25 and `missing` `[]`. `grep -ohE 'AgentServerUIProviders|AgentServerUIRoot|CloudOrganizationBoundary|configureTelemetry|setTelemetryConsent' $L/dist/lib/index.d.ts | sort -u` lists all five. Remove the copy with `rm -rf $L`.",
          "ids": [
            "F26.lib-build"
          ],
          "children": []
        },
        {
          "anchor": "recipe-025",
          "label": "Style scope (`F26.lib-style-scope`).",
          "body": "On your run's home page (`control-openhands browser goto /`) run `control-openhands browser eval \"(() => { let total=0, unscoped=[]; for (const sh of document.styleSheets) { let rules; try { rules = sh.cssRules } catch { continue } const walk = (rs) => { for (const r of rs) { if (r.selectorText) { total++; if (!r.selectorText.includes('data-agent-server-ui')) unscoped.push(r.selectorText) } else if (r.cssRules) walk(r.cssRules) } }; walk(rules) } return { scopeRoots: document.querySelectorAll('[data-agent-server-ui]').length, total, unscopedCount: unscoped.length, sample: unscoped.slice(0, 12) } })()\"`. `total` is in the thousands and `unscopedCount` is `2`: only `.go<hash>` and `.go<hash> > *`, react-hot-toast's runtime classes. `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-agent-server-ui]')).getPropertyValue('--oh-color-base').trim()\"` is `#181818`. This proves the bundle is scoped, not that a host page is unaffected (`F26.lib-host-app`).",
          "ids": [
            "F26.lib-style-scope"
          ],
          "children": []
        },
        {
          "anchor": "recipe-026",
          "label": "Blocked (`F26.lib-host-app`, `F26.docker-image`, `F26.docker-conversation-runtime`, `F26.helm-chart`).",
          "body": "If `docker info` cannot reach the daemon but `dockerd` is installed, start it (`setsid dockerd`); without one, record both Docker rows `blocked` naming the missing daemon. With a daemon, run the image with `-p 127.0.0.1:8000:8000`, open `http://localhost:8000/canvas` and expect the **Add a backend** step (`testid=onboarding-step-check-backend`, Next disabled until a key is typed); `onboarding-skip` leads to `testid=api-key-entry-screen`. With `-e AGENT_CANVAS_ALLOW_LAN_SESSION_KEY=true` the key is injected and first run opens on Choose your agent, under the telemetry consent dialog. `npm run build:docker` behind a TLS-intercepting proxy needs the proxy CA inside the build stages, and Docker Hub may answer 429. Record `F26.lib-host-app` `blocked` naming the missing host-app example. `command -v helm kubectl` prints nothing here: record `F26.helm-chart` `blocked` naming the missing `helm` binary and cluster; with them, `helm lint helm/agent-canvas` and `helm template qa helm/agent-canvas` render the StatefulSet, Service and Ingress, and `helm install` follows `helm/agent-canvas/README.md`.",
          "ids": [
            "F26.lib-host-app",
            "F26.docker-image",
            "F26.docker-conversation-runtime",
            "F26.helm-chart"
          ],
          "children": []
        },
        {
          "anchor": "recipe-027",
          "label": "Desktop splash and window (`F26.desktop-boot-splash`, `F26.desktop-main-window`).",
          "body": "Start a private display and the app in one process group: `Q=$OH_VERIFY_RUN/private/f26-desktop; mkdir -p $Q/shots; setsid nohup sh -c \"echo \\$\\$ > $Q/pgid; Xvfb :78 -screen 0 1440x1000x24 -nolisten tcp & sleep 1; DISPLAY=:78 HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state LOCAL_BACKEND_API_KEY=qa-f26-desktop-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 exec node_modules/.bin/electron --no-sandbox electron\" > $Q/desktop.log 2>&1 &`, then capture frames: `for i in $(seq 1 40); do sleep 1; DISPLAY=:78 import -window root $Q/shots/s$(printf %02d $i).png; done`. Read the frames: the first seconds show the splash (logo, `OpenHands Agent Canvas`, `AI coding agent interface`, spinner, `Starting backend services…`, then lines such as `agent-server: {\"asctime\": ...`, the note `First launch downloads Python + the OpenHands agent server.` and a **Show details** button); a later frame shows the main window with a `File Edit View Window` menu and the first-run **Choose your agent** step under the consent dialog naming `the local backend \"Local\" at http://localhost:8000` (about 30 s on a warm cache, minutes on the first download). Copy the frames you cite into `$OH_VERIFY_RUN/evidence/F26.desktop-boot-splash/` and `.../F26.desktop-main-window/`. Stop in two steps: `kill -TERM $(cat $Q/pgid)` signals only the Electron launcher (it quits and stops its backend services); after about 10 s ports 8000, 18000, 18001 and 3001 are free again. Then `kill -TERM -- -$(cat $Q/pgid)` ends Xvfb; `ps -o pid,cmd -g $(cat $Q/pgid)` lists nothing.",
          "ids": [
            "F26.desktop-boot-splash",
            "F26.desktop-main-window"
          ],
          "children": []
        },
        {
          "anchor": "recipe-028",
          "label": "External links (`F26.desktop-external-links`).",
          "body": "Not driven: record `not-run` (harness gap: no verb clicks inside the Electron window or observes the OS browser handoff).",
          "ids": [
            "F26.desktop-external-links"
          ],
          "children": []
        }
      ]
    },
    {
      "id": "F27",
      "title": "Workspace tools: terminal, browser, planner, tasks and usage",
      "file": "F27-workspace-tools.md",
      "page": "F27-workspace-tools.html",
      "sha256": "fdf6da3581c288f8c3fd303f212327945161ccd3a78720e9d06dd6d9eadc4e6a",
      "behaviors": [
        {
          "id": "F27.terminal-empty",
          "description": "with the runtime active and no command run yet, the Terminal tab reads \"No terminal output yet. Commands run by the agent will appear here.\""
        },
        {
          "id": "F27.terminal-waiting",
          "description": "while the runtime is starting or the conversation is in an error state, the Terminal tab reads \"Waiting for runtime to start...\"; a paused (stopped) conversation shows the empty message instead, by design."
        },
        {
          "id": "F27.terminal-output",
          "description": "the agent's commands appear as `$ <command>` followed by their output; typing into the terminal does nothing (stdin is disabled)."
        },
        {
          "id": "F27.terminal-history-reload",
          "description": "after a page reload the earlier commands and output are still shown (issue #17566)."
        },
        {
          "id": "F27.terminal-live-append",
          "description": "a command the agent runs while the page is open is appended live."
        },
        {
          "id": "F27.browser-empty",
          "description": "before any browsing the URL bar reads \"No URL loaded\", \"Open in New Tab\" is a disabled button, and the body reads \"No page loaded yet. Ask OpenHands to open a URL. Example: \"Open https://example.com\"\"."
        },
        {
          "id": "F27.browser-screenshot",
          "description": "after the agent's browser tool ran, the tab shows the last screenshot under a bar with its URL and an enabled \"Open in New Tab\" link."
        },
        {
          "id": "F27.planner-empty",
          "description": "with no plan the Planner reads \"There is currently no plan for this repo\" with an enabled \"Create a plan\" button; the drawer's \"Build ⌘↩\" button is disabled."
        },
        {
          "id": "F27.planner-create",
          "description": "\"Create a plan\" toasts \"Planning agent initialized\", switches the composer to plan mode (\"Let’s work on a plan\") and disables itself, also after a reload."
        },
        {
          "id": "F27.planner-plan",
          "description": "when the workspace has `.agents_tmp/PLAN.md`, the Planner renders it as Markdown with an enabled \"Build ⌘↩\" bar."
        },
        {
          "id": "F27.planner-build",
          "description": "\"Build ⌘↩\" sends \"Execute the plan based on the .agents_tmp/PLAN.md file.\" to the agent."
        },
        {
          "id": "F27.tasklist",
          "description": "the Task List tab appears only once the agent used `task_tracker`; it lists each task with its status icon and highlights the in-progress one; the drawer's tabs menu opens it too."
        },
        {
          "id": "F27.usage-metrics",
          "description": "the Usage tab shows the context meter (\"X% used (Y% left)\", tokens / window), Token Usage (Input, Cache Hit, Cache Write, Output, Total) and Total Cost."
        },
        {
          "id": "F27.usage-empty",
          "description": "before any metrics arrive the Usage tab reads \"No metrics data available\"."
        },
        {
          "id": "F27.usage-compact",
          "description": "\"Compact context\" (agent idle) toasts \"Context compaction started\" then \"Context compacted\" (or \"…freed N tokens\"), and the agent server records a Condensation; the (i) tooltip explains it."
        },
        {
          "id": "F27.usage-provider-balance",
          "description": "the provider balance card shows only when the agent server reports a balance (OpenRouter); otherwise it is absent without an error message."
        },
        {
          "id": "F27.composer-context-meter",
          "description": "the composer's context ring opens a popover with the fill label, a Compact context button, tokens and a Usage item; the bar or Usage item opens the Usage tab; Escape (focus returns to the ring) or an outside click closes it."
        },
        {
          "id": "F27.overview-peek",
          "description": "while the drawer is open, hovering the chat header (i) button shows a peek of the Overview rows (with the drawer closed there is no peek; clicking opens the column)."
        },
        {
          "id": "F27.overview-pin",
          "description": "the Overview \"...\" menu pins and unpins rows (Workspace, Git parts); an unpinned row stays hidden after a reload."
        },
        {
          "id": "F27.overview-changes-link",
          "description": "the Overview Changes row closes the Overview and opens the drawer on Commits."
        },
        {
          "id": "F27.overview-menu-open",
          "description": "the Overview \"...\" menu's Git > Commits item opens the drawer on Commits with Uncommitted collapsed; Git > Changes opens Commits with Uncommitted expanded; both close the Overview."
        },
        {
          "id": "F27.overview-identity",
          "description": "the Overview's Workspace row shows the attached folder's basename (`None` without a workspace), and once the workspace's `origin` is a GitHub remote the Git block adds an `owner/repo` link to the repository and a branch link."
        },
        {
          "id": "F27.overview-drawer",
          "description": "Overview menu > Pull Requests opens the secondary drawer with a close button; without a git repository it reads \"Connect a git repository to view pull requests and issues\"."
        },
        {
          "id": "F27.canvas-ui-open-tab",
          "description": "the agent's `canvas_ui_control` `open_tab` opens the drawer on the requested tab (files, browser, terminal, planner, tasklist), and the choice persists like a user tab click."
        },
        {
          "id": "F27.canvas-ui-navigate-file",
          "description": "`canvas_ui_control` `navigate_to_file` (and clicking a file path in chat) opens Files with that file selected."
        },
        {
          "id": "F27.canvas-ui-phone",
          "description": "at phone width an agent `open_tab` makes the requested tab visible on `/conversations/<id>/panel`."
        },
        {
          "id": "F27.launch-child",
          "description": "the agent's `launch_child_conversation` (local) toasts \"Launched a local child conversation\" with an Open link; the child runs, and the launch result (`[child-conversation] {\"status\":\"launched\",…}` with the child id) is posted back to the parent agent as a user message."
        },
        {
          "id": "F27.info-modals-phone",
          "description": "the composer `+` menu's Skills, Hooks and Agent Tools & Metadata modals fit a 390 px viewport (issue #17562); content is covered by F07."
        },
        {
          "id": "F27.plugins-modal",
          "description": "a conversation started with plugins offers a plugins item that lists `active-plugin-<name>` rows."
        },
        {
          "id": "F27.phone",
          "description": "at 390 px the panel route shows the Usage, Task List and Planner tabs without overflow, and the tab row stays visible."
        }
      ],
      "recipes": [
        {
          "anchor": "recipe-001",
          "label": "Empty tools (`F27.terminal-empty`, `F27.browser-empty`, `F27.planner-empty`).",
          "body": "Run `control-openhands conversation start --prompt \"Reply with only the word OK. Do not run any tools.\" --wait --timeout 240` (note the `id` as `<quiet-id>`), then `control-openhands browser click 'testid=right-panel-toggle'`, `control-openhands browser click 'testid=conversation-tab-terminal'` and `control-openhands browser wait-text 'No terminal output yet' --timeout 10000`. Then `control-openhands browser click 'testid=conversation-tab-browser'`, `control-openhands browser text 'testid=browser-chrome-url'` (`No URL loaded`), `control-openhands browser count 'testid=browser-chrome-open-external'` (`0`), `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=browser-chrome-bar] button')].map(b=>[b.disabled,b.getAttribute('aria-label')])\"` (`[[true,\"Open in New Tab\"]]`) and `control-openhands browser wait-text 'No page loaded yet' --timeout 5000`. Then `control-openhands browser click 'testid=conversation-tab-planner'`, `control-openhands browser wait-text 'There is currently no plan for this repo' --timeout 10000`, `control-openhands browser enabled 'role=button[name=\"Create a plan\"]'` (`true`) and `control-openhands browser enabled 'testid=planner-tab-build-button'` (`false`). The tab bar has no `conversation-tab-tasklist`. Screenshot with `control-openhands browser screenshot --feature F27.browser-empty --name empty`.",
          "ids": [
            "F27.terminal-empty",
            "F27.browser-empty",
            "F27.planner-empty"
          ],
          "children": []
        },
        {
          "anchor": "recipe-002",
          "label": "Usage totals (`F27.usage-metrics`, `F27.usage-provider-balance`).",
          "body": "In `<quiet-id>` run `control-openhands browser click 'testid=conversation-tab-usage'`, `control-openhands browser text 'testid=usage-panel'` and `control-openhands browser attr 'testid=context-meter-bar' style`. The text reads `Context Window`, `1% used (99% left)`, `<tokens> / 1,000,000`, `Compact context`, `Token Usage`, Input, Cache Hit, Cache Write, Output, Total and `Total Cost $0.00…`; the bar style is `width: <percent>%`. `control-openhands browser count 'testid=provider-balance-card'` is `0` with DeepSeek (the `GET /api/llm/balance` 404 is expected). Screenshot with `control-openhands browser screenshot --feature F27.usage-metrics --name usage`.",
          "ids": [
            "F27.usage-metrics",
            "F27.usage-provider-balance"
          ],
          "children": []
        },
        {
          "anchor": "recipe-003",
          "label": "Compact context (`F27.usage-compact`).",
          "body": "With the agent idle run `control-openhands browser tooltip 'testid=compact-context-info'` (`Summarizes older messages to free context space while keeping recent details.`), `control-openhands browser mouse-click 1300 700` (closes the tooltip), `control-openhands browser click 'testid=compact-context-button' --observe '[data-testid=compact-context-button]' --observe-ms 4000`, then `control-openhands browser toasts --history` (a plain `browser toasts` right after the click often lists only the first toast: the second lands about 2.5 s later) and `control-openhands conversation events <quiet-id> --last 4`. The last two history toasts are `Context compaction started` and `Context compacted` (a short history frees nothing, so no token count); the events include `CondensationRequest` and `Condensation`.",
          "ids": [
            "F27.usage-compact"
          ],
          "children": []
        },
        {
          "anchor": "recipe-004",
          "label": "Composer context ring (`F27.composer-context-meter`).",
          "body": "Run `control-openhands browser click 'testid=conversation-tab-files'`, `control-openhands browser click 'testid=context-window-meter'` and `control-openhands browser text 'testid=context-window-meter-popover'` (`Context Window`, `1% used (99% left)`, `Compact context`, `11.7k / 1.0M`-style tokens, `Usage`). `control-openhands browser click 'testid=context-window-plan-usage'` then `control-openhands browser count 'testid=usage-panel'` is `1` and the popover count is `0`. Reopen the ring and `control-openhands browser click 'testid=context-window-meter-bar-button'` opens Usage the same way. Reopen it, `control-openhands browser click 'testid=context-window-compact-button'` and, a few seconds later, `control-openhands browser toasts --history` ends with `Context compaction started`, then `Context compacted`; the popover stays open. `control-openhands browser press Escape` closes it (`control-openhands browser count 'testid=context-window-meter-popover'` is `0`) and returns focus to the ring (`control-openhands browser eval \"document.activeElement.dataset.testid\"` is `context-window-meter`). Reopen it with `control-openhands browser click 'testid=context-window-meter'`; `control-openhands browser mouse-click 600 400` closes it too (`0`).",
          "ids": [
            "F27.composer-context-meter"
          ],
          "children": []
        },
        {
          "anchor": "recipe-005",
          "label": "Agent-driven tools (`F27.terminal-output`, `F27.canvas-ui-open-tab`).",
          "body": "Run `control-openhands conversation start --prompt \"Do these steps in order, in the workspace only. 1) Use the task_tracker tool to set exactly three tasks: 'qa-one' status done, 'qa-two' status in_progress, 'qa-three' status todo. 2) Run the shell command: echo QA_TERM_MARK_1 3) Create the file .agents_tmp/PLAN.md with the content '# QA Plan' followed by a bullet list of three items: alpha, beta, gamma. 4) Call the canvas_ui_control tool with command open_tab and tab terminal. Then reply DONE.\" --wait --timeout 300` (note `<tools-id>`). The drawer was closed; the agent's tool call opened it: `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` is `true` and `control-openhands browser text 'testid=tabs-pane-header'` starts with `Terminal`. `control-openhands browser text '.xterm-rows'` shows `$ echo QA_TERM_MARK_1`, `QA_TERM_MARK_1` and the PLAN.md command with its output. `control-openhands browser click '.xterm-screen'`, `control-openhands browser press KeyZ` and `control-openhands browser press Enter` change neither the rows nor the event list (`conversation events <tools-id> --last 1`). `control-openhands conversation events <tools-id> --kinds ActionEvent,ObservationEvent,MessageEvent` shows `open_tab` with `Tool call dispatched to client.`",
          "ids": [
            "F27.terminal-output",
            "F27.canvas-ui-open-tab"
          ],
          "children": []
        },
        {
          "anchor": "recipe-006",
          "label": "Terminal after reload (`F27.terminal-history-reload`, `F27.terminal-live-append`).",
          "body": "Before clicking any other tab in `<tools-id>` (a user tab click is persisted and would mask the next check), run `control-openhands browser reload`, `control-openhands browser click 'testid=right-panel-toggle'`, `control-openhands browser text 'testid=tabs-pane-header'` (read now for `F27.canvas-ui-open-tab` below; it starts with `Terminal`, so the drawer is already on Terminal: do not click `conversation-tab-terminal`, because clicking the active tab closes the drawer) and `control-openhands browser text '.xterm-rows'`. Expected: the earlier commands are still listed. Known failure (#17566 reproduces, 2026-10-06): the text is empty and the tab reads \"No terminal output yet\" while the chat still lists the actions. Live append is checked after the Build step below.",
          "ids": [
            "F27.terminal-history-reload",
            "F27.terminal-live-append"
          ],
          "children": []
        },
        {
          "anchor": "recipe-007",
          "label": "Agent tab survives reload (`F27.canvas-ui-open-tab`).",
          "body": "The `tabs-pane-header` text read in the reload step above (right after `right-panel-toggle`) starts with `Terminal`, the agent's last choice: `src/services/canvas-ui.ts` saves the tab per conversation like a user click (#18045), and `control-openhands browser eval \"JSON.parse(localStorage.getItem('conversation-state-<tools-id>')).selectedTab\"` is `terminal` (`browser screenshot --feature F27.canvas-ui-open-tab --name reload-reopens-terminal`). For contrast, `control-openhands browser click 'testid=conversation-tab-usage'`, `control-openhands browser reload`, `control-openhands browser click 'testid=right-panel-toggle'`: a user-clicked tab is restored (`Usage`).",
          "ids": [
            "F27.canvas-ui-open-tab"
          ],
          "children": []
        },
        {
          "anchor": "recipe-008",
          "label": "Task List (`F27.tasklist`).",
          "body": "In `<tools-id>` run `control-openhands browser click 'testid=conversation-tab-tasklist'`, `control-openhands browser wait '[data-active=\"true\"] >> has-text=qa-two' --timeout 10000` (the rows render a moment after the click; an immediate count reads `0`), `control-openhands browser count '[data-active=\"true\"] >> has-text=qa-two'` (`1`) and `control-openhands browser count '[data-active=\"false\"] >> has-text=qa-three'` (`1`); screenshot with `control-openhands browser screenshot --feature F27.tasklist --name tasks` (done/in-progress/todo icons). Menu entry: `control-openhands browser click 'testid=tabs-pane-header >> testid=ellipsis-button'` then `control-openhands browser click 'testid=conversation-tabs-menu-open-tasklist'`; the header starts with `Task List`.",
          "ids": [
            "F27.tasklist"
          ],
          "children": []
        },
        {
          "anchor": "recipe-009",
          "label": "Plan and Build (`F27.planner-plan`, `F27.planner-build`).",
          "body": "In `<tools-id>` run `control-openhands browser click 'testid=conversation-tab-planner'` and `control-openhands browser snapshot 'testid=app-route'`: it ends with `heading \"QA Plan\" [level=1]` and a list `alpha`, `beta`, `gamma`; `control-openhands browser enabled 'testid=planner-tab-build-button'` is `true`. Run `control-openhands browser click 'testid=planner-tab-build-button'`, then `control-openhands browser count 'testid=user-message >> has-text=Execute the plan based on the .agents_tmp/PLAN.md file.'` (`1`) and `control-openhands conversation wait <tools-id> --timeout 240`; the agent reads PLAN.md and answers. Live append (`F27.terminal-live-append`): `control-openhands browser click 'testid=conversation-tab-terminal'` and `control-openhands browser text '.xterm-rows'` now show the new `$ cat .agents_tmp/PLAN.md` block and its output (but not the pre-reload history).",
          "ids": [
            "F27.planner-plan",
            "F27.planner-build"
          ],
          "children": []
        },
        {
          "anchor": "recipe-010",
          "label": "Create a plan (`F27.planner-create`).",
          "body": "In `<quiet-id>` (no plan yet) run `control-openhands browser goto /conversations/<quiet-id>` and `control-openhands browser click 'testid=right-panel-toggle'` (the browser is on `<tools-id>`, and a fresh load has the drawer closed; it reopens on the tab last chosen there, Usage after the context-ring bullet (its Usage item and bar save `usage`): if that is already Planner, skip the next click, which would close the drawer), then `control-openhands browser click 'testid=conversation-tab-planner'`, `control-openhands browser click 'role=button[name=\"Create a plan\"]'`, then `control-openhands browser toasts` (`Planning agent initialized`) and `control-openhands browser enabled 'role=button[name=\"Create a plan\"]'` (`false`). The composer placeholder is `Let’s work on a plan` (`browser screenshot --feature F27.planner-create --name after-create`). After `control-openhands browser reload` and reopening Planner the button stays disabled, and `control-openhands api GET /api/conversations/<quiet-id>` lists one `sub_conversation_ids` entry (the helper; it is not in the sidebar list).",
          "ids": [
            "F27.planner-create"
          ],
          "children": []
        },
        {
          "anchor": "recipe-011",
          "label": "Open a file from the agent (`F27.canvas-ui-navigate-file`).",
          "body": "On `/conversations/<tools-id>` (`control-openhands browser goto /conversations/<tools-id>`; Create a plan left `<quiet-id>`, and `--stay` sends into the open page) run `control-openhands conversation start --stay --prompt \"Call the canvas_ui_control tool with command navigate_to_file and path .agents_tmp/PLAN.md, then reply OK.\" --wait --timeout 200`, then `control-openhands browser count 'testid=file-quick-row-item-.agents_tmp/PLAN.md'` (`1`) and `control-openhands browser text 'testid=files-tab-content'` (`Rich`, `Plain`, `QA Plan`, `alpha`…). Chat link entry: `control-openhands browser click 'testid=right-panel-toggle'` (closes the drawer), `control-openhands browser click 'testid=markdown-file-path-link >> nth=0'`, then `control-openhands browser attr 'testid=right-panel-toggle' aria-pressed` is `true` with the same file selected.",
          "ids": [
            "F27.canvas-ui-navigate-file"
          ],
          "children": []
        },
        {
          "anchor": "recipe-012",
          "label": "Launch a child (`F27.launch-child`).",
          "body": "On `/conversations/<tools-id>` (code mode, not a plan-mode conversation) run `control-openhands conversation start --stay --prompt \"Call the launch_child_conversation tool with target local, isolation shared and task 'Reply with only the word PONG. Do not run any tools.' Then reply OK.\"` and poll `control-openhands browser toasts` every few seconds: one toast reads `Launched a local child conversation Open`. Take the child id from `control-openhands conversation events <tools-id> --kinds MessageEvent` (the `[child-conversation] {\"status\":\"launched\",…,\"conversation_id\":\"<child-id>\"…}` message), then `control-openhands conversation wait <child-id> --timeout 120` (`finished`) and `control-openhands conversation events <child-id> --kinds MessageEvent` (agent `PONG`). `control-openhands browser count 'testid=conversation-card >> has-text=PONG'` is `1`.",
          "ids": [
            "F27.launch-child"
          ],
          "children": []
        },
        {
          "anchor": "recipe-013",
          "label": "Overview (`F27.overview-peek`, `F27.overview-pin`, `F27.overview-changes-link`, `F27.overview-drawer`).",
          "body": "On `/conversations/<quiet-id>` (`control-openhands browser goto /conversations/<quiet-id>`; Launch a child left `<tools-id>`) run `control-openhands browser click 'testid=right-panel-toggle'` (the peek exists only while the drawer is open; a fresh page load has it closed), `control-openhands browser hover 'testid=conversation-overview-toggle'` and `control-openhands browser text 'testid=conversation-overview-peek'` (`Overview`, `Changes`, `+0`, `-0`, `Workspace`, `None`). `control-openhands browser click 'testid=conversation-overview-toggle'`; `control-openhands browser attr 'testid=conversation-overview-toggle' aria-pressed` is `true` and `right-panel-toggle` is `false`. Pin: `control-openhands browser click 'testid=conversation-overview-ellipsis'`, `control-openhands browser click 'testid=conversation-overview-menu-pin-workspace'`; `control-openhands browser count 'testid=conversation-overview-workspace'` is `0` and the pin button's `aria-pressed` is `false`; after `control-openhands browser reload` and `browser click 'testid=conversation-overview-toggle'` the count is still `0`. Re-pin the same way (count `1`). Git parts work the same: `conversation-overview-menu-pin-git-changes` hides `testid=conversation-overview-diffs` (count `0`, also after a reload) and a second click restores it. Close the menu with `control-openhands browser press Escape`: `control-openhands browser count 'testid=conversation-overview-context-menu'` is `0`, `control-openhands browser eval \"document.activeElement.dataset.testid\"` is `conversation-overview-ellipsis` and `testid=conversation-overview-panel` still counts `1` (a second click on the ellipsis closes the menu too). Changes: `control-openhands browser click 'testid=conversation-overview-diffs'`; the overview panel count is `0`, `right-panel-toggle` is pressed and `tabs-pane-header` starts with `Commits`. Drawer: reopen the Overview, `control-openhands browser click 'testid=conversation-overview-ellipsis'`, `control-openhands browser click 'testid=conversation-overview-menu-open-git-pull_requests'`, `control-openhands browser text 'testid=conversation-overview-drawer'` (`Pull Requests` / `Connect a git repository to view pull requests and issues`), then `control-openhands browser click 'testid=conversation-overview-drawer-close'` and `control-openhands browser wait 'testid=conversation-overview-drawer' --state hidden --timeout 5000` (an immediate `visible` still reads `true` during the slide-out animation).",
          "ids": [
            "F27.overview-peek",
            "F27.overview-pin",
            "F27.overview-changes-link",
            "F27.overview-drawer"
          ],
          "children": []
        },
        {
          "anchor": "recipe-014",
          "label": "Overview menu opens Commits (`F27.overview-menu-open`).",
          "body": "On `/conversations/<tools-id>` (it has an uncommitted `.agents_tmp/PLAN.md`; `control-openhands browser goto /conversations/<tools-id>`, since the Overview bullet is on `<quiet-id>`) run `control-openhands browser reload`, `control-openhands browser click 'testid=conversation-overview-toggle'`, `control-openhands browser click 'testid=conversation-overview-ellipsis'` and `control-openhands browser click 'testid=conversation-overview-menu-open-git-commits'`: `control-openhands browser count 'testid=conversation-overview-panel'` is `0`, `tabs-pane-header` starts with `Commits` and `control-openhands browser attr 'testid=uncommitted-changes-row-toggle' aria-expanded` is `false`. Repeat with `conversation-overview-menu-open-git-changes`: same tab, `aria-expanded` `true` and `control-openhands browser count 'testid=uncommitted-changes-row-content'` `1` (the `.agents_tmp/PLAN.md` row). Screenshot with `control-openhands browser screenshot --feature F27.overview-menu-open --name changes`.",
          "ids": [
            "F27.overview-menu-open"
          ],
          "children": []
        },
        {
          "anchor": "recipe-015",
          "label": "Workspace and repository identity (`F27.overview-identity`).",
          "body": "Run `control-openhands conversation start --workspace qa-f27-repo --prompt \"List the files in this folder with ls. Do nothing else.\" --wait --timeout 240` (note `<repo-id>`) and, staying on the page it leaves open, `control-openhands browser click 'testid=conversation-overview-toggle'`, `control-openhands browser wait 'testid=conversation-overview-git-repo' --timeout 20000`, `control-openhands browser text 'testid=conversation-overview-workspace'` (`Workspace`, `qa-f27-repo`: the folder's basename, not its path), `control-openhands browser text 'testid=conversation-overview-git-repo'` (`qa-example/qa-f27-repo`), `control-openhands browser attr 'testid=conversation-overview-git-repo' href` (`https://github.com/qa-example/qa-f27-repo`), `control-openhands browser text 'testid=conversation-overview-git-branch'` (`main`; `control-openhands browser attr 'testid=conversation-overview-git-branch' href` is `https://github.com/qa-example/qa-f27-repo/tree/main`) and `control-openhands browser snapshot 'testid=conversation-overview-panel'`: Changes `+0 -0`, the repo and branch links, the `Commits` and `Pull Requests` items, then `Workspace qa-f27-repo`. `control-openhands browser screenshot --feature F27.overview-identity --name repo-rows`. The repo and branch rows come from a git probe the page runs while the conversation is active: without an LLM key the conversation ends in `error`, and after `control-openhands browser reload` and `control-openhands browser click 'testid=conversation-overview-toggle'` the panel lists only `Workspace qa-f27-repo` (`control-openhands browser count 'testid=conversation-overview-git-repo'` is `0`; see Gotchas), so read the rows before any reload. With a key the conversation finishes and the rows survive a reload: after `browser reload`, the toggle and `browser wait 'testid=conversation-overview-git-repo' --timeout 20000`, the count is `1`, and `control-openhands browser text 'testid=agent-message >> nth=-1'` still shows the reply (driven 2026-10-08 with the prompt `List the files in this folder with ls. Do nothing else.`, reply `The folder contains: README.md src`).",
          "ids": [
            "F27.overview-identity"
          ],
          "children": []
        },
        {
          "anchor": "recipe-016",
          "label": "No browser tool here (`F27.browser-screenshot`, blocked).",
          "body": "`control-openhands conversation start --prompt \"Use your browser tool to navigate to https://example.com and then reply with the page title only.\" --wait --timeout 240` made the agent fall back to `curl`. Confirm with `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser click 'testid=show-agent-tools-button'`, `control-openhands browser click 'testid=system-message-modal >> role=tab[name=\"Available Tools\"]'` and `control-openhands browser snapshot 'testid=system-message-modal'`: the tools are terminal, file_editor, task_tracker, canvas_ui_control, launch_child_conversation, finish, think, switch_llm, invoke_skill (no browser tools). Close with `control-openhands browser click 'testid=close-system-message-modal'`. With a working browser tool, expect `browser-chrome-url` to hold the URL and `browser-chrome-open-external` count `1`.",
          "ids": [
            "F27.browser-screenshot"
          ],
          "children": []
        },
        {
          "anchor": "recipe-017",
          "label": "Info modals at phone width (`F27.info-modals-phone`).",
          "body": "Run `control-openhands browser viewport phone`; for each of `skills`/`skills-modal`, `hooks`/`hooks-modal`, `agent-tools`/`system-message-modal`: `control-openhands browser click 'testid=chat-plus-button'`, `control-openhands browser click 'testid=show-skills-button'`, `control-openhands browser wait 'testid=skills-modal' --timeout 10000`, `control-openhands browser bbox 'testid=skills-modal'`, `control-openhands browser screenshot --feature F27.info-modals-phone --name skills`, `control-openhands browser click 'testid=close-skills-modal'` (substitute the names). Each box is 351 px wide at x 19.5 with `insideViewport` `true` and `pageHorizontalOverflow` `false`.",
          "ids": [
            "F27.info-modals-phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-018",
          "label": "Agent tab at phone width (`F27.canvas-ui-phone`).",
          "body": "Still at phone width on `/conversations/<id>`, run `control-openhands conversation start --stay --prompt \"Call the canvas_ui_control tool with command open_tab and tab terminal, then reply OK.\" --wait --timeout 200`, then `control-openhands browser url` (it ends `/conversations/<id>/panel`), `control-openhands browser count 'testid=tabs-pane-header'` (`1`) and `control-openhands browser attr 'role=tab[name=\"Terminal\"]' aria-selected` (`true`). The panel page has no `right-panel-toggle`. Screenshot with `control-openhands browser screenshot --feature F27.canvas-ui-phone --name panel-terminal`.",
          "ids": [
            "F27.canvas-ui-phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-019",
          "label": "Phone panel tabs (`F27.phone`).",
          "body": "At phone width run `control-openhands browser goto /conversations/<tools-id>/panel`; tabs that do not fit the 390 px row move into the tabs menu (with Commits active, as the Overview menu bullet leaves it, Usage is not in the row and `conversation-tab-usage` does not exist), so open Usage with `control-openhands browser click 'testid=tabs-pane-header >> testid=ellipsis-button'` and `control-openhands browser click 'testid=conversation-tabs-menu-open-usage'`, then `control-openhands browser bbox 'testid=usage-panel'` (390 wide, `insideViewport` `true`, no overflow) and `control-openhands browser screenshot --feature F27.phone --name usage`. Then `control-openhands browser click 'testid=conversation-tab-planner'`, `control-openhands browser bbox 'testid=conversation-tab-planner'` and `control-openhands browser bbox 'testid=planner-tab-build-button'`: the tab row stays inside the viewport (the Planner tab at y 5.5, `insideViewport` `true`) and the Build bar sits below the 39 px top bar (Build at y 49.5); screenshot with `control-openhands browser screenshot --feature F27.phone --name planner`. Return with `control-openhands browser viewport desktop`; the app replaces `/panel` with `/conversations/<tools-id>`.",
          "ids": [
            "F27.phone"
          ],
          "children": []
        },
        {
          "anchor": "recipe-020",
          "label": "Terminal waiting (`F27.terminal-waiting`).",
          "body": "Arrange an errored conversation without model cost: `echo sk-qa-invalid > $OH_VERIFY_RUN/private/qa-bad.key`, `control-openhands llm set --profile qa-bad --model deepseek/deepseek-flash --api-key-file $OH_VERIFY_RUN/private/qa-bad.key --no-validate` (activates it), then `control-openhands conversation start --prompt \"Reply OK.\" --wait --timeout 120` (status `error`, the chat shows the authentication error). Run `control-openhands browser click 'testid=right-panel-toggle'`, `control-openhands browser click 'testid=conversation-tab-terminal'` and `control-openhands browser wait-text 'Waiting for runtime to start' --timeout 10000`; screenshot with `control-openhands browser screenshot --feature F27.terminal-waiting --name error`. Restore with `control-openhands llm preset deepseek --api-key-file <key file>` (re-activates `deepseek-flash`) and `control-openhands api DELETE /api/profiles/qa-bad --write`. For contrast, a conversation paused with F07's Stop Runtime (`conversation status` `paused`, composer `Stopped`) shows \"No terminal output yet\": `PAUSED` is not in `RUNTIME_INACTIVE_STATES`.",
          "ids": [
            "F27.terminal-waiting"
          ],
          "children": []
        },
        {
          "anchor": "recipe-021",
          "label": "State not reached (`F27.usage-empty`).",
          "body": "A new conversation's metrics arrive before the drawer can be opened (the Usage tab shows `Loading...`, then numbers); even the errored conversation above shows all-zero totals, not the empty state; and `control-openhands service stop agent-server` replaces the page with \"Manage backends\". \"No metrics data available\" was not observed.",
          "ids": [
            "F27.usage-empty"
          ],
          "children": []
        }
      ]
    }
  ]
}
