{
  "published_snapshot": "2026-10-16",
  "source_repository": "https://github.com/OpenHands/OpenHands",
  "source_revision": "9a3c3a699e0057bd4c240c4d8158df23711c095b",
  "source_label": "PR #18334",
  "source_pr": 18334,
  "source_directory": ".agents/skills/verify-openhands/references/feature-map",
  "versions": {
    "agentServer": "1.55.0",
    "agentCanvas": "1.27.1",
    "automation": "1.20.0"
  },
  "family_count": 28,
  "behavior_count": 778,
  "named_recipe_count": 844,
  "script_and_contract_count": 866,
  "families": [
    {
      "id": "F01",
      "title": "First run, onboarding and sign-in",
      "file": "F01-first-run-and-sign-in.md",
      "sha256": "3cbb1784b818e66475fb0c4fed8a839d2990569c21f4b9f2e55d0bed23cbaa8d",
      "overview": "What a user sees before the normal app shell. A browser that has never finished\nor skipped onboarding gets a full-screen onboarding modal on every route: choose\nan agent, set up the LLM (or ACP credentials), then say hello, which starts the\nfirst conversation. In public mode the flow starts with an \"Add a backend\" step\nthat asks for the server's API key, and a browser with no stored key gets a\nfull-screen API-key screen instead of the app. A one-time telemetry consent\nmodal appears for each local backend. Unknown routes render an in-app \"Page not\nfound\" page inside the app shell, and the tab title follows the open\nconversation.",
      "entry": "- First run: open any URL of the app in a browser profile that has never onboarded. Every `control-openhands launch --new` starts with a fresh browser profile, and `control-openhands browser reset` gives the same run a fresh one again. Backend-side state (consent answer, LLM profile, agent kind) stays.\n- Public mode: `control-openhands launch --new --public` serves the UI without the injected key, so step 0 is \"Add a backend\". The API-key screen appears after skipping onboarding, after removing the last backend under backend selector → Manage backends (see F25), or when the server's key changes under a stored one.\n- Telemetry consent: appears by itself over onboarding or the app. It is changed later in Settings → Application (see F16).\n- Onboarding preview: direct URL with `?previewOnboardingStep=N`, for example `/?previewOnboardingStep=3` or `/settings/app?previewOnboardingStep=3`.\n- Last onboarding step: Close, a recommended-automation card, or the \"Skip Getting Started checklist\" box below the modal.\n- Not-found page: any unknown direct URL, such as `/this-route-does-not-exist`. Its **Home** button returns to `/`.\n- There is no command-menu entry or keyboard shortcut for onboarding.",
      "given": "Preconditions:\n\n- A fresh run you launched yourself: `control-openhands launch --new` (local) or `control-openhands launch --new --public`, then `export OH_VERIFY_RUN=<run from the launch JSON>` and `control-openhands doctor`. Do **not** run `control-openhands onboard --skip`: it consumes the first-run state this family tests.\n- The model-backed say-hello check needs a DeepSeek key in `DEEPSEEK_API_KEY`. It is typed into the onboarding form with `--value-env`, which replaces `llm preset deepseek` here.\n- Each fresh browser profile walks first run once; `control-openhands browser reset` starts another first run on the same stack. Local and public mode still need separate runs (`launch --new` and `launch --new --public`): the local walk, public run A (backend step walked) and public run B (onboarding skipped). Stop each one with `control-openhands stop` when done.\n- Desktop viewport unless stated.\n\nLocal run (`launch --new`):",
      "gotchas": "- Use a fresh run or `browser reset` for every first-run check, and never `onboard --skip` first. The browser profile lives in `<run>/private/browser-profile`. `browser reset` clears only the browser, so the server keeps the consent answer (the consent modal does not return), the LLM profile and the agent kind.\n- `control-openhands login` passes both key prompts: the onboarding backend step, and the API-key screen, where it names the backend `Local` when Host Name is empty (after Skip and after `restart --rotate-key`, with no duplicate backend). Use it when the screen is only in the way. To check the screen itself (the disabled Connect, the wrong-key line, a chosen name), use the explicit fills above.\n- Inactive onboarding slides stay mounted and are translated off-screen. Plain `browser testids` lists only the active slide, but `count` and `wait` still find handles on the others. Assert the active step with `data-current-step`, for example `browser wait '[data-testid=onboarding-modal][data-current-step=\"2\"]'`. The `testid=` selector cannot carry attribute filters.\n- Slide indices renumber. After the public backend step succeeds, \"Choose your agent\" becomes index `0` and has no Back. `previewOnboardingStep` counts phases with the backend step included, so `0` and `1` look identical on a healthy backend.\n- The consent modal (z-70) sits over the onboarding modal, and clicks at the screen center land on agent tiles once it closes. A forced click on `first-run-onboarding-screen` selected Codex. Check backdrop non-dismissal with `browser mouse-click 40 40`, a corner outside the modal.\n- The say-hello default message (\"Create a basic webpage…\") makes the agent work for a while. Replace it with a tiny prompt to keep model cost down.\n- On a local backend, Next on the LLM step also points the `default` agent profile at the profile it just created. Activating another LLM profile does not move that pointer, and while it stays, deleting the onboarding profile answers `409` `LLM profile is referenced by 1 agent profile(s): default`. `control-openhands llm preset deepseek` moves it to `deepseek-flash` and reports `repointed`; `llm set` does not. In the UI, edit `default` in Settings → Agents and pick the profile in `testid=agent-profile-llm-selector`.\n- On a local backend the LLM step edits an LLM profile and never reads or writes the raw LLM settings (#18218). It takes the active profile, else the one the `default` agent profile points at (the Agent Server's backfill of an older install's raw settings), else a fresh one with the default model. It reads the profile only once the backend's health check reports it connected, so a step mounted while the Agent Server was down fills in after it comes back (verified 2026-10-12 with `control-openhands service stop agent-server`, then `restart`). If the profiles cannot be read it shows `Failed to load existing profile. Please try again.` (`testid=onboarding-llm-load-error`) with Retry (`testid=onboarding-llm-load-retry`) and Next disabled. Next without changes only sends `POST /api/profiles/<name>/activate`.\n- The tab title prefixes a status emoji to the stored title. A generated title that already starts with an emoji therefore shows twice (`✅ ✅ Reply hello without tools | OpenHands`). Right after launch the title is `Conversation <id prefix>` until a reload picks up the generated one.\n- Right after Next, the ACP step reads \"Checking for an existing Claude Code login…\" with Next disabled. Wait for `testid=onboarding-acp-auth-detected` before reading the banner. The \"already signed in\" banner depends on the host's CLI login. On a clean CI machine the fields become required, and Next is blocked until they are filled.\n- Choosing an ACP agent saves `agent_kind: acp` immediately. Re-choose OpenHands before leaving, or later conversations in the run use the ACP agent.\n- In preview mode, Skip and Close are inert. The LLM slide shows `gpt-5.6-sol` even when another model is saved, so do not read it as data loss.\n- `browser toasts --history` lists every status or alert text (toasts and alert banners) seen since the page loaded, including success toasts and progress lines such as `Checking your backend connection…`. Read it on a page that has just loaded (or reloaded) to check that no error toast appeared. A page with a standing banner never reads `[]`: the app shell of a backend with no LLM lists the `Your LLM isn't set up yet` alert.\n- Known issue [#16601](https://github.com/OpenHands/OpenHands/issues/16601) (`F01.telemetry-consent`): Analytics consent reappears sporadically. Live prerequisite: The multi-day trigger and backend consent response when the modal returns; opted-in localStorage alone does not reproduce it.\n- Known issue [#16526](https://github.com/OpenHands/OpenHands/issues/16526) (`F01.onboarding-choose-agent`): 403 error when selecting an agent in the onboarding agent selection modal. Live prerequisite: An authenticated hosted Cloud account with the reported organization-membership mismatch during onboarding.",
      "features": [
        {
          "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, plus Pi and OpenCode on a local backend (#17758; a Cloud backend offers only the first four, and a hidden choice falls back to OpenHands). Next saves the agent kind with a \"Settings saved\" toast."
        },
        {
          "id": "F01.onboarding-setup-llm",
          "description": "on a first run 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 Next saves the form as an LLM profile named after the model (`deepseek/deepseek-flash` → `deepseek-flash`) and activates it, without writing the raw LLM settings."
        },
        {
          "id": "F01.onboarding-repeat-endpoint",
          "description": "onboarding again (a new browser, the same backend) fills the form from the active LLM profile, or from the one the `default` agent profile points at, model included. The All view prefills its Base URL and key even when the raw LLM settings hold another Base URL. Next without changes keeps that profile and saves no copy. Changing only the model creates a profile that keeps that Base URL and key, and the say-hello conversation reaches that endpoint."
        },
        {
          "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. OpenCode's fields (`OPENCODE_AUTH_CONTENT`, `OPENCODE_API_KEY`) are always optional; Pi's two fields (`PI_AUTH_JSON`, `ANTHROPIC_API_KEY`) are each labelled Optional, but without a host login at least one of them is needed: Next stays disabled under a warning, readable in light themes, until either is filled. 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)."
        }
      ],
      "scenarios": [
        {
          "name": "F01--first-run-gate",
          "title": "First-run gate",
          "ids": [
            "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\".",
          "raw": "**First-run gate (`F01.first-run-gate`).** 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\".",
          "source_line": 61,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--consent",
          "title": "Consent",
          "ids": [
            "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`.",
          "raw": "**Consent (`F01.telemetry-consent`).** 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`.",
          "source_line": 62,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16601](https://github.com/OpenHands/OpenHands/issues/16601) (`F01.telemetry-consent`): Analytics consent reappears sporadically. Live prerequisite: The multi-day trigger and backend consent response when the modal returns; opted-in localStorage alone does not reproduce it."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F01--modal-shell",
          "title": "Modal shell",
          "ids": [
            "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.",
          "raw": "**Modal shell (`F01.onboarding-modal`).** 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.",
          "source_line": 63,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--choose-agent",
          "title": "Choose agent",
          "ids": [
            "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'`. `control-openhands browser snapshot 'testid=onboarding-step-choose-agent'` lists six radios: OpenHands, Claude Code, Codex, Gemini CLI, `Pi The open source Pi coding agent.` and `OpenCode The open source OpenCode coding agent.` (`onboarding-agent-option-pi`, `-opencode`). 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.",
          "raw": "**Choose agent (`F01.onboarding-choose-agent`).** 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'`. `control-openhands browser snapshot 'testid=onboarding-step-choose-agent'` lists six radios: OpenHands, Claude Code, Codex, Gemini CLI, `Pi The open source Pi coding agent.` and `OpenCode The open source OpenCode coding agent.` (`onboarding-agent-option-pi`, `-opencode`). 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.",
          "source_line": 64,
          "expected": [
            "radio tiles for OpenHands (preselected), Claude Code, Codex and Gemini CLI, plus Pi and OpenCode on a local backend (#17758; a Cloud backend offers only the first four, and a hidden choice falls back to OpenHands). Next saves the agent kind with a \"Settings saved\" toast."
          ],
          "notes": [
            "Known issue [#16526](https://github.com/OpenHands/OpenHands/issues/16526) (`F01.onboarding-choose-agent`): 403 error when selecting an agent in the onboarding agent selection modal. Live prerequisite: An authenticated hosted Cloud account with the reported organization-membership mismatch during onboarding."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F01--llm-step",
          "title": "LLM step",
          "ids": [
            "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`.",
          "raw": "**LLM step (`F01.onboarding-setup-llm`).** 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`.",
          "source_line": 65,
          "expected": [
            "on a first run 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 Next saves the form as an LLM profile named after the model (`deepseek/deepseek-flash` → `deepseek-flash`) and activates it, without writing the raw LLM settings."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--say-hello",
          "title": "Say hello",
          "ids": [
            "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`.",
          "raw": "**Say hello (`F01.onboarding-say-hello`).** 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`.",
          "source_line": 66,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--tab-title",
          "title": "Tab title",
          "ids": [
            "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.",
          "raw": "**Tab title (`F01.document-title`).** 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.",
          "source_line": 67,
          "expected": [
            "the tab title is `OpenHands`. On a conversation it is `<status emoji> <conversation title> | OpenHands`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--preview",
          "title": "Preview",
          "ids": [
            "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.",
          "raw": "**Preview (`F01.onboarding-preview`).** 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.",
          "source_line": 68,
          "expected": [
            "`?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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--skip-checklist-box",
          "title": "Skip checklist box",
          "ids": [
            "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`.",
          "raw": "**Skip checklist box (`F01.onboarding-skip-checklist`).** 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`.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F01.onboarding-phone`).** 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`.",
          "source_line": 70,
          "expected": [
            "every onboarding step fits a 390 px viewport without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--error-page",
          "title": "Error page",
          "ids": [
            "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`.",
          "raw": "**Error page (`F01.route-error-boundary`).** 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`.",
          "source_line": 71,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--onboarding-again-with-the-saved-endpoint",
          "title": "Onboarding again with the saved endpoint",
          "ids": [
            "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`. Raw LLM settings that disagree with the active profile do not reach the form. To check it, arrange them before pass 2 with `control-openhands api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"llm\":{\"base_url\":\"http://127.0.0.1:9/stale-raw-settings\"}}}'`. `browser value 'testid=onboarding-step-setup-llm >> testid=base-url-input'` still reads `https://api.deepseek.com/v1`. Fill only the model, run `browser network --clear` before Next and `browser network --filter '/api/(settings|profiles)'` after it: the rows are the profile `POST` and its `/activate`, with no `PATCH /api/settings`. `deepseek-v4-flash` keeps the DeepSeek Base URL, and the say-hello conversation finishes with `hello` (verified 2026-10-09). Known failure (reproduced 2026-10-12 and 2026-10-13, #18302): Next replaces a profile that already has the model-derived name. Arrange one before pass 2 with `control-openhands llm set --profile deepseek-v4-flash --model openai/deepseek-v4-flash --base-url http://127.0.0.1:9/other-endpoint --api-key-env DEEPSEEK_API_KEY --no-activate --no-validate`. After pass 2's Next, `browser network --filter '/api/profiles'` shows `POST /api/profiles/deepseek-v4-flash`, and `control-openhands llm show` lists `deepseek-v4-flash` with `base_url` `https://api.deepseek.com/v1`: its own Base URL is gone, and nothing warned. Expected: the existing profile is kept, under a name that does not collide or after a confirmation. 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.",
          "raw": "**Onboarding again with the saved endpoint (`F01.onboarding-repeat-endpoint`).** 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`. Raw LLM settings that disagree with the active profile do not reach the form. To check it, arrange them before pass 2 with `control-openhands api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"llm\":{\"base_url\":\"http://127.0.0.1:9/stale-raw-settings\"}}}'`. `browser value 'testid=onboarding-step-setup-llm >> testid=base-url-input'` still reads `https://api.deepseek.com/v1`. Fill only the model, run `browser network --clear` before Next and `browser network --filter '/api/(settings|profiles)'` after it: the rows are the profile `POST` and its `/activate`, with no `PATCH /api/settings`. `deepseek-v4-flash` keeps the DeepSeek Base URL, and the say-hello conversation finishes with `hello` (verified 2026-10-09). Known failure (reproduced 2026-10-12 and 2026-10-13, #18302): Next replaces a profile that already has the model-derived name. Arrange one before pass 2 with `control-openhands llm set --profile deepseek-v4-flash --model openai/deepseek-v4-flash --base-url http://127.0.0.1:9/other-endpoint --api-key-env DEEPSEEK_API_KEY --no-activate --no-validate`. After pass 2's Next, `browser network --filter '/api/profiles'` shows `POST /api/profiles/deepseek-v4-flash`, and `control-openhands llm show` lists `deepseek-v4-flash` with `base_url` `https://api.deepseek.com/v1`: its own Base URL is gone, and nothing warned. Expected: the existing profile is kept, under a name that does not collide or after a confirmation. 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.",
          "source_line": 72,
          "expected": [
            "onboarding again (a new browser, the same backend) fills the form from the active LLM profile, or from the one the `default` agent profile points at, model included. The All view prefills its Base URL and key even when the raw LLM settings hold another Base URL. Next without changes keeps that profile and saves no copy. Changing only the model creates a profile that keeps that Base URL and key, and the say-hello conversation reaches that endpoint."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12 and 2026-10-13, #18302): Next replaces a profile that already has the model-derived name. Arrange one before pass 2 with `control-openhands llm set --profile deepseek-v4-flash --model openai/deepseek-v4-flash --base-url http://127.0.0.1:9/other-endpoint --api-key-env DEEPSEEK_API_KEY --no-activate --no-validate`. After pass 2's Next, `browser network --filter '/api/profiles'` shows `POST /api/profiles/deepseek-v4-flash`, and `control-openhands llm show` lists `deepseek-v4-flash` with `base_url` `https://api.deepseek.com/v1`: its own Base URL is gone, and nothing warned. Expected: the existing profile is kept, under a name that does not collide or after a confirmation. 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."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F01--clean-up",
          "title": "Clean up",
          "ids": [],
          "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`.\n\nPublic run A (`launch --new --public`), backend step walked:",
          "raw": "**Clean up.** 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`.\n\nPublic run A (`launch --new --public`), backend step walked:",
          "source_line": 73,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F01--backend-step",
          "title": "Backend step",
          "ids": [
            "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'`.",
          "raw": "**Backend step (`F01.onboarding-backend-step`).** 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'`.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--acp-credentials",
          "title": "ACP credentials",
          "ids": [
            "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`.",
          "raw": "**ACP credentials (`F01.onboarding-acp-secrets`).** 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`.",
          "source_line": 78,
          "expected": [
            "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. OpenCode's fields (`OPENCODE_AUTH_CONTENT`, `OPENCODE_API_KEY`) are always optional; Pi's two fields (`PI_AUTH_JSON`, `ANTHROPIC_API_KEY`) are each labelled Optional, but without a host login at least one of them is needed: Next stays disabled under a warning, readable in light themes, until either is filled. Back returns to the agent step."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--pi-and-opencode-credentials",
          "title": "Pi and OpenCode credentials",
          "ids": [
            "F01.onboarding-acp-secrets"
          ],
          "body": "The previous bullet ends on the LLM step: run `control-openhands browser click 'testid=onboarding-llm-back'` to return to the agent step, then `control-openhands browser click 'testid=onboarding-agent-option-opencode'`, `control-openhands browser click 'testid=onboarding-agent-next'`, `browser wait-text 'Settings saved'` and `browser snapshot 'testid=onboarding-step-setup-acp-secrets'`: `OPENCODE_AUTH_CONTENT Optional` and `OPENCODE_API_KEY Optional`, and `control-openhands browser enabled 'testid=onboarding-acp-secrets-next'` is `true`. Back, then the same with `onboarding-agent-option-pi`: `This backend has no host login, so credentials are required to run Pi.`, `PI_AUTH_JSON Optional` and `ANTHROPIC_API_KEY Optional` (either one is enough), Next `false` while both are empty, and `browser text 'testid=onboarding-acp-secrets-blocked'` reads `Add the credentials above to continue, or sign in on the backend.` Its colour, `browser eval \"getComputedStyle(document.querySelector('[data-testid=onboarding-acp-secrets-blocked]')).color\"`, is `rgb(201, 185, 116)` in OpenHands-Neutral and `rgb(125, 93, 8)` in Light+ (arrange with `browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, which returns to the agent step with OpenHands selected again, so click `onboarding-agent-option-pi` and `onboarding-agent-next` once more before reading the colour; #18333). Restore the theme with `localStorage.removeItem`, go Back, choose OpenHands and Next (`agent_kind` back to `openhands`).",
          "raw": "**Pi and OpenCode credentials (`F01.onboarding-acp-secrets`).** The previous bullet ends on the LLM step: run `control-openhands browser click 'testid=onboarding-llm-back'` to return to the agent step, then `control-openhands browser click 'testid=onboarding-agent-option-opencode'`, `control-openhands browser click 'testid=onboarding-agent-next'`, `browser wait-text 'Settings saved'` and `browser snapshot 'testid=onboarding-step-setup-acp-secrets'`: `OPENCODE_AUTH_CONTENT Optional` and `OPENCODE_API_KEY Optional`, and `control-openhands browser enabled 'testid=onboarding-acp-secrets-next'` is `true`. Back, then the same with `onboarding-agent-option-pi`: `This backend has no host login, so credentials are required to run Pi.`, `PI_AUTH_JSON Optional` and `ANTHROPIC_API_KEY Optional` (either one is enough), Next `false` while both are empty, and `browser text 'testid=onboarding-acp-secrets-blocked'` reads `Add the credentials above to continue, or sign in on the backend.` Its colour, `browser eval \"getComputedStyle(document.querySelector('[data-testid=onboarding-acp-secrets-blocked]')).color\"`, is `rgb(201, 185, 116)` in OpenHands-Neutral and `rgb(125, 93, 8)` in Light+ (arrange with `browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, which returns to the agent step with OpenHands selected again, so click `onboarding-agent-option-pi` and `onboarding-agent-next` once more before reading the colour; #18333). Restore the theme with `localStorage.removeItem`, go Back, choose OpenHands and Next (`agent_kind` back to `openhands`).",
          "source_line": 79,
          "expected": [
            "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. OpenCode's fields (`OPENCODE_AUTH_CONTENT`, `OPENCODE_API_KEY`) are always optional; Pi's two fields (`PI_AUTH_JSON`, `ANTHROPIC_API_KEY`) are each labelled Optional, but without a host login at least one of them is needed: Next stays disabled under a warning, readable in light themes, until either is filled. Back returns to the agent step."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--skip-into-the-app",
          "title": "Skip into the app",
          "ids": [
            "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`).",
          "raw": "**Skip into the app (`F01.onboarding-skip`).** 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`).",
          "source_line": 80,
          "expected": [
            "\"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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--last-backend-removed",
          "title": "Last backend removed",
          "ids": [
            "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.\n\nPublic run B (`launch --new --public`), onboarding skipped:",
          "raw": "**Last backend removed (`F01.api-key-entry`).** 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.\n\nPublic run B (`launch --new --public`), onboarding skipped:",
          "source_line": 81,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--skip-to-api-key-screen",
          "title": "Skip to API-key screen",
          "ids": [
            "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`.",
          "raw": "**Skip to API-key screen (`F01.onboarding-skip`, `F01.api-key-entry`).** 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`.",
          "source_line": 85,
          "expected": [
            "\"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.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--connect",
          "title": "Connect",
          "ids": [
            "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`.",
          "raw": "**Connect (`F01.api-key-entry`).** 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`.",
          "source_line": 86,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--stale-key-re-prompt",
          "title": "Stale key re-prompt",
          "ids": [
            "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.",
          "raw": "**Stale key re-prompt (`F01.api-key-entry`).** 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.",
          "source_line": 87,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--close-on-the-last-step",
          "title": "Close on the last step",
          "ids": [
            "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`.",
          "raw": "**Close on the last step (`F01.onboarding-hello-close`).** 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`.",
          "source_line": 88,
          "expected": [
            "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 `/`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--recommended-automation",
          "title": "Recommended automation",
          "ids": [
            "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`.",
          "raw": "**Recommended automation (`F01.onboarding-recommended-automation`).** 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`.",
          "source_line": 89,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F01--cloud-login-column",
          "title": "Cloud login column",
          "ids": [
            "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.\n\nNot reachable locally:",
          "raw": "**Cloud login column (`F01.onboarding-cloud-login`).** 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.\n\nNot reachable locally:",
          "source_line": 90,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F01--locked-cloud-first-run",
          "title": "Locked Cloud first run",
          "ids": [
            "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.",
          "raw": "**Locked Cloud first run (`F01.locked-cloud-first-run`).** 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.",
          "source_line": 94,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F01--cookie-auth-redirect",
          "title": "Cookie-auth redirect",
          "ids": [
            "F01.cookie-auth-redirect"
          ],
          "body": "Blocked: needs an OHE cookie-auth deployment.",
          "raw": "**Cookie-auth redirect (`F01.cookie-auth-redirect`).** Blocked: needs an OHE cookie-auth deployment.",
          "source_line": 95,
          "expected": [
            "OHE cookie-auth deployments probe the main-app session behind a spinner and redirect to its `/login` when it is missing (blocked)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F01--bootstrap-spinner",
          "title": "Bootstrap spinner",
          "ids": [
            "F01.bootstrap-loading"
          ],
          "body": "Blocked on the 2026-10-09 run: the agent attempted a fresh first paint, but the spinner lasted under a second on the healthy stack; slowing `/server_info` would require request interception rather than the real service response.",
          "raw": "**Bootstrap spinner (`F01.bootstrap-loading`).** Blocked on the 2026-10-09 run: the agent attempted a fresh first paint, but the spinner lasted under a second on the healthy stack; slowing `/server_info` would require request interception rather than the real service response.",
          "source_line": 96,
          "expected": [
            "a centered spinner card shows while `/server_info` loads (transient, not-run)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "not-run"
          ]
        }
      ]
    },
    {
      "id": "F02",
      "title": "App shell, sidebar and command menu",
      "file": "F02-app-shell.md",
      "sha256": "6efc6bd0b46b22dbc1d083e457212d45b851a49b766057cffed107b39b1081b4",
      "overview": "Every page inside the root layout shares one shell. On desktop it is a left rail\nwith the logo, **Search commands**, **New Chat**, **Customize**, **Automate**\n(when the automation interface is present), the conversation list, the\n**Getting started** checklist, an update tile (only when a newer release exists)\nand the backend selector with a settings gear. The rail collapses to icons and\nremembers that choice. Below 768 px the rail turns into a drawer that opens from\na top bar or the chat header. A user can pin Customize or Automate as the page\n`/` opens. A Ctrl/Cmd+K command menu searches pages, settings and actions.\nFailed requests show error toasts.",
      "entry": "- Any page in the app: the left rail (desktop) or the hamburger (phone, `browser viewport phone`).\n- Direct URLs: `/` (the default home, or a redirect to the pinned page), `/conversations`, `/customize`, `/automations`, `/settings`.\n- Command menu: press `Control+k` or `Meta+k` anywhere, or click **Search commands** (`command-menu-trigger`) in the rail or drawer. Type a page or setting name, then press Enter or click the row.\n- Hover a sidebar row to reveal its pin. Hover the collapsed rail's logo to reveal the expand chevron.\n- Settings → Application (`/settings/app`): the **Show Getting Started checklist** switch.\n- The **Getting started** card: click an item row to go to its page, or hover it for the preview; hover the card for the X that dismisses it.\n- Keyboard: `Control+k` also works while the composer has focus; Tab moves from the search input into the option list.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done), desktop viewport, sidebar expanded, no home pin, checklist shown.\n- `F02.checklist-progress` (LLM and chat items) needs `control-openhands llm preset deepseek` and one conversation (`control-openhands conversation start --prompt \"Reply with only the word: ok\" --wait --timeout 180`). Run both before the `F02.conversation-list` bullet. The preset writes through the API, so `control-openhands browser reload` before you read the LLM item. The conversation-header hamburger check in `F02.mobile-drawer` reuses that conversation.\n- `F02.update-tile` (the sidebar tile) is blocked unless the installed version is older than npm `latest` and the browser can reach `registry.npmjs.org`. The Settings → Application check runs either way.\n- `F02.alert-banner` is blocked on the local backend, whose config always reports no alerts; it needs a Cloud backend that publishes them.\n- `F02.settings-404-modal` is blocked: it needs a backend whose settings endpoint returns 404.\n- `F02.checklist-all-complete` needs an automation and an MCP server on top of the four items above. Both are added through the UI from two fixtures (see that bullet) and deleted again afterwards.",
      "gotchas": "- The rail animates its width for 200 ms. A `bbox` taken right after the collapse click reads an intermediate width (about 70); measure after `browser reload`.\n- `browser url` straight after clicking **Customize** can still read `/customize`; desktop redirects to `/mcp` a moment later. Use `browser wait-url '/mcp$'`.\n- `/settings` redirects to `/settings/agents` on desktop but shows the settings menu on phone. That redirect also marks the checklist's **Customize your agent** item done, so visiting Settings by any route completes it. The sidebar **Customize** link (`/customize`) does not.\n- The pin toggles are hidden until hover (`visible` is `false`), except a pinned row's toggle, which stays visible. They exist only on the expanded rail.\n- The two desktop elements share the label `Sidebar navigation`, and settings pages add a second `complementary` landmark, so `role=complementary` is ambiguous. Use `aside[data-collapsed]` for the desktop rail and `testid=sidebar-mobile-drawer` for the drawer.\n- HeroUI tooltips (the collapsed rail and checklist previews) often stay closed on the first `browser hover` after a navigation. Hover a neighbor first, then the target, and `browser wait` for the tooltip. Never `count` it right away.\n- When collapsed, the expand chevron is `opacity-0 pointer-events-none` until the rail is hovered. A forced click on it lands on the logo link and does nothing; hover the logo first.\n- With the full list open, the command-menu panel covers the viewport center, so clicking the backdrop button by selector only works after a filter shortens the list (type `secr` first). `control-openhands browser mouse-click 1300 500` hits the desktop backdrop with any list length.\n- The new-tab path works with `browser click ... --button middle` and with `--modifiers Control` (or `Meta`). The phone drawer's backdrop has no handle; `browser mouse-click 350 400` clicks it.\n- Escape closes one layer at a time (#18044): with the command menu open over the phone drawer, the first Escape closes the menu and the second closes the drawer. Focus goes back to whatever had it when the menu opened: right after `browser goto /conversations` that is the composer (`chat-input` takes focus on load), and on `/settings/app` it is `<body>`.\n- **Toggle sidebar** at phone width flips the stored desktop state without any visible change. Expand the rail again after returning to desktop.\n- Typing does not reset the command menu's selection. The active option keeps its index, cut down to the shorter list, from the last ArrowUp/ArrowDown or hover, and a pointer resting where the list opens hovers the option under it. A term with several matches can then run a later one on Enter: after two `ArrowDown` presses, typing `LLM profiles` (LLM profiles and Model Router) selects Model Router. Before opening the menu for an Enter check, rest the pointer with `browser hover 'testid=command-menu-trigger'`, and read `aria-activedescendant` before Enter when a term matches several items. Repro candidate, not yet filed.\n- The command menu's **LLM profiles** item uses a magnifier icon (`lucide-search`, the same icon as the menu's search field).\n- Dismissing the checklist with its X and the App settings switch write the same browser-local flag, so either one undoes the other. Clicking **Join the OpenHands Slack** counts as joining and opens an external tab you must close.\n- Registry access depends on the sandbox. Where the browser cannot reach `registry.npmjs.org` (`ERR_CERT_AUTHORITY_INVALID`), the update check reports `Couldn't check for updates`; that is the environment, not the product. Where it can, the badge reads `Up to date` and the check says `You're running the latest version.`\n- Adding a custom MCP server tests the connection first and refuses an unreachable one (`Could not reach the server...`, or `Connection failed: McpError: Connection closed` for `uvx mcp-server-time` here), so the all-complete recipe uses the local stdio fixture.",
      "features": [
        {
          "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-dismiss",
          "description": "hovering or focusing the card (expanded or minimized) reveals an X (`Dismiss checklist`) in its header; touch devices always show it. Clicking it hides the card for good (it stays hidden after a reload) and turns off the **Show Getting Started checklist** switch in Settings → Application, which brings the card back in its previous expanded or minimized state."
        },
        {
          "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.super-admin-setup-tour",
          "description": "for the two setup steps done in Canvas (Choose an automation template, Add an integration), the guide walks the Super Admin through the page with a guided tour (`.oh-setup-tour-popover` with Back / Next / Done): the template step points at the recommended-automations section (a stop with no footer: no Back, Next or `1/2`, #18243), then the setup dialog; the integration step at the MCP library on `/mcp` (`mcp-marketplace-section`, \"Pick a server from the library\"), then the install dialog (#18261). A plain click on a Canvas step's row in the guide starts that step's tour and closes the panel (a modifier click follows the link), and while the tour waits for its next stop's element it hides its popover and overlay, so it never covers another dialog (#18261). Finishing the guide's next step in Canvas opens the step after it the way **Start** does (a Canvas step's tour or page; an enterprise step reopens the guide to point at Start, or navigates on a locked-Cloud canvas). An enterprise page that links back with `?setup_tour=<step id>` starts that step's tour once and drops the parameter. Local backends ignore `?setup_tour=` (no tour, no guide, the parameter stays) (#18138). The positive path is blocked here, with the same Enterprise prerequisite as the setup guide above."
        },
        {
          "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. Toasts stay readable in every colour theme."
        }
      ],
      "scenarios": [
        {
          "name": "F02--sidebar-links",
          "title": "Sidebar links",
          "ids": [
            "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`.",
          "raw": "**Sidebar links (`F02.nav-links`).** 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`.",
          "source_line": 67,
          "expected": [
            "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>`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--pin-as-home",
          "title": "Pin as home",
          "ids": [
            "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`.",
          "raw": "**Pin as home (`F02.pin-as-home`).** 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`.",
          "source_line": 68,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--collapse-and-expand",
          "title": "Collapse and expand",
          "ids": [
            "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.",
          "raw": "**Collapse and expand (`F02.sidebar-collapse`).** 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.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--settings-gear",
          "title": "Settings gear",
          "ids": [
            "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).",
          "raw": "**Settings gear (`F02.settings-link`).** 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).",
          "source_line": 70,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--conversation-list",
          "title": "Conversation list",
          "ids": [
            "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`.",
          "raw": "**Conversation list (`F02.conversation-list`).** After `conversation start` (preconditions), run `control-openhands browser goto /conversations` and `control-openhands browser testids 'testid=conversation-panel'`. The list includes `conversation-card`.",
          "source_line": 71,
          "expected": [
            "the expanded rail embeds the conversation list (`conversation-panel`). The list itself belongs to the conversations family."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--open-and-close-the-menu",
          "title": "Open and close the menu",
          "ids": [
            "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]'`.",
          "raw": "**Open and close the menu (`F02.command-menu-open-close`).** 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]'`.",
          "source_line": 72,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--every-item-navigates",
          "title": "Every item navigates",
          "ids": [
            "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`.",
          "raw": "**Every item navigates (`F02.command-menu-items`).** 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`.",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--toggle-sidebar",
          "title": "Toggle sidebar",
          "ids": [
            "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`.",
          "raw": "**Toggle sidebar (`F02.command-menu-toggle-sidebar`).** 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`.",
          "source_line": 74,
          "expected": [
            "the **Toggle sidebar** action collapses or expands the desktop rail."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--search-empty-state-keyboard",
          "title": "Search, empty state, keyboard",
          "ids": [
            "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`.",
          "raw": "**Search, empty state, keyboard (`F02.command-menu-search`).** 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`.",
          "source_line": 75,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--open-in-a-new-tab",
          "title": "Open in a new tab",
          "ids": [
            "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`).",
          "raw": "**Open in a new tab (`F02.command-menu-new-tab`).** 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`).",
          "source_line": 76,
          "expected": [
            "a middle-click or modifier-click on a **Go** item opens the route in a new tab and leaves the menu open."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--keyboard-focus",
          "title": "Keyboard focus",
          "ids": [
            "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.",
          "raw": "**Keyboard focus (`F02.command-menu-focus`).** 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.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--phone-drawer",
          "title": "Phone drawer",
          "ids": [
            "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`.",
          "raw": "**Phone drawer (`F02.mobile-drawer`).** 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`.",
          "source_line": 78,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--back-chevron",
          "title": "Back chevron",
          "ids": [
            "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`).",
          "raw": "**Back chevron (`F02.mobile-top-bar`).** 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`).",
          "source_line": 79,
          "expected": [
            "at phone width, settings sub-pages and Customize detail pages (`/skills`, `/mcp`, `/plugins`, `/apps`) show a Back chevron that returns to `/settings` or `/customize`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--phone-command-menu",
          "title": "Phone command menu",
          "ids": [
            "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.",
          "raw": "**Phone command menu (`F02.command-menu-phone`).** 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.",
          "source_line": 80,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--checklist-card",
          "title": "Checklist card",
          "ids": [
            "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`.",
          "raw": "**Checklist card (`F02.checklist`).** 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`.",
          "source_line": 81,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--item-links",
          "title": "Item links",
          "ids": [
            "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).",
          "raw": "**Item links (`F02.checklist-item-links`).** 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).",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--progress",
          "title": "Progress",
          "ids": [
            "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).",
          "raw": "**Progress (`F02.checklist-progress`).** 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).",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--item-preview",
          "title": "Item preview",
          "ids": [
            "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$'`.",
          "raw": "**Item preview (`F02.checklist-preview`).** 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$'`.",
          "source_line": 84,
          "expected": [
            "hovering an item shows a preview with a description, a **Documentation** link and an action button that navigates."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--minimize",
          "title": "Minimize",
          "ids": [
            "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`.",
          "raw": "**Minimize (`F02.checklist-minimize`).** 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`.",
          "source_line": 85,
          "expected": [
            "the card header minimizes and expands the card, and the state survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--dismiss",
          "title": "Dismiss",
          "ids": [
            "F02.checklist-dismiss"
          ],
          "body": "On `/conversations`, `control-openhands browser count 'role=button[name=\"Dismiss checklist\"]'` is `0` (the X is `opacity-0` and `invisible` until the card is hovered). Run `control-openhands browser hover 'testid=command-menu-trigger'`, `control-openhands browser hover 'testid=sidebar-onboarding-checklist-toggle'` and `control-openhands browser wait 'role=button[name=\"Dismiss checklist\"]' --state visible --timeout 5000` (`browser screenshot 'aside[data-collapsed]' --feature F02.checklist-dismiss --name hover-close`). Run `control-openhands browser click 'role=button[name=\"Dismiss checklist\"]'`: `browser count 'testid=sidebar-onboarding-checklist'` is `0`, still `0` after `control-openhands browser reload`, and `browser storage` lists `openhands-sidebar-onboarding-checklist-dismissed`. On `/settings/app`, `control-openhands browser eval \"document.querySelector('[data-testid=show-getting-started-checklist-switch]').checked\"` is `false`; `control-openhands browser click 'text=Show Getting Started checklist'` brings the card back. Minimized: `control-openhands browser click 'testid=sidebar-onboarding-checklist-toggle'` (`data-minimized` `true`), hover as above, click the X (count `0`), click the switch text again: the card returns with `data-minimized` `true`; click the toggle to expand it. At phone width the X shows in the drawer's card the same way.",
          "raw": "**Dismiss (`F02.checklist-dismiss`).** On `/conversations`, `control-openhands browser count 'role=button[name=\"Dismiss checklist\"]'` is `0` (the X is `opacity-0` and `invisible` until the card is hovered). Run `control-openhands browser hover 'testid=command-menu-trigger'`, `control-openhands browser hover 'testid=sidebar-onboarding-checklist-toggle'` and `control-openhands browser wait 'role=button[name=\"Dismiss checklist\"]' --state visible --timeout 5000` (`browser screenshot 'aside[data-collapsed]' --feature F02.checklist-dismiss --name hover-close`). Run `control-openhands browser click 'role=button[name=\"Dismiss checklist\"]'`: `browser count 'testid=sidebar-onboarding-checklist'` is `0`, still `0` after `control-openhands browser reload`, and `browser storage` lists `openhands-sidebar-onboarding-checklist-dismissed`. On `/settings/app`, `control-openhands browser eval \"document.querySelector('[data-testid=show-getting-started-checklist-switch]').checked\"` is `false`; `control-openhands browser click 'text=Show Getting Started checklist'` brings the card back. Minimized: `control-openhands browser click 'testid=sidebar-onboarding-checklist-toggle'` (`data-minimized` `true`), hover as above, click the X (count `0`), click the switch text again: the card returns with `data-minimized` `true`; click the toggle to expand it. At phone width the X shows in the drawer's card the same way.",
          "source_line": 86,
          "expected": [
            "hovering or focusing the card (expanded or minimized) reveals an X (`Dismiss checklist`) in its header; touch devices always show it. Clicking it hides the card for good (it stays hidden after a reload) and turns off the **Show Getting Started checklist** switch in Settings → Application, which brings the card back in its previous expanded or minimized state."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--hide-from-settings",
          "title": "Hide from settings",
          "ids": [
            "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`.",
          "raw": "**Hide from settings (`F02.checklist-hide`).** 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`.",
          "source_line": 87,
          "expected": [
            "the **Show Getting Started checklist** switch in Settings → Application hides the card (it stays hidden after a reload) and can show it again."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--all-complete",
          "title": "All complete",
          "ids": [
            "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`.",
          "raw": "**All complete (`F02.checklist-all-complete`).** 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`.",
          "source_line": 88,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--setup-guide",
          "title": "Setup guide",
          "ids": [
            "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.",
          "raw": "**Setup guide (`F02.super-admin-setup-guide`).** 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.",
          "source_line": 89,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F02--setup-tour",
          "title": "Setup tour",
          "ids": [
            "F02.super-admin-setup-tour"
          ],
          "body": "Local, at `desktop` and `phone`: run `control-openhands browser network --clear`, `control-openhands browser goto '/?setup_tour=first-automation'`, wait about 6 s, then `control-openhands browser count '.oh-setup-tour-popover'` and `browser count 'testid=super-admin-setup-guide'` are `0`, `browser count 'testid=home-screen'` is `1`, `browser url` still ends in `?setup_tour=first-automation`, and `browser network --filter setup-state` reports `total` `0`. The tour itself is blocked with the same Enterprise prerequisite as the guide.",
          "raw": "**Setup tour (`F02.super-admin-setup-tour`).** Local, at `desktop` and `phone`: run `control-openhands browser network --clear`, `control-openhands browser goto '/?setup_tour=first-automation'`, wait about 6 s, then `control-openhands browser count '.oh-setup-tour-popover'` and `browser count 'testid=super-admin-setup-guide'` are `0`, `browser count 'testid=home-screen'` is `1`, `browser url` still ends in `?setup_tour=first-automation`, and `browser network --filter setup-state` reports `total` `0`. The tour itself is blocked with the same Enterprise prerequisite as the guide.",
          "source_line": 90,
          "expected": [
            "for the two setup steps done in Canvas (Choose an automation template, Add an integration), the guide walks the Super Admin through the page with a guided tour (`.oh-setup-tour-popover` with Back / Next / Done): the template step points at the recommended-automations section (a stop with no footer: no Back, Next or `1/2`, #18243), then the setup dialog; the integration step at the MCP library on `/mcp` (`mcp-marketplace-section`, \"Pick a server from the library\"), then the install dialog (#18261). A plain click on a Canvas step's row in the guide starts that step's tour and closes the panel (a modifier click follows the link), and while the tour waits for its next stop's element it hides its popover and overlay, so it never covers another dialog (#18261). Finishing the guide's next step in Canvas opens the step after it the way **Start** does (a Canvas step's tour or page; an enterprise step reopens the guide to point at Start, or navigates on a locked-Cloud canvas). An enterprise page that links back with `?setup_tour=<step id>` starts that step's tour once and drops the parameter. Local backends ignore `?setup_tour=` (no tour, no guide, the parameter stays) (#18138). The positive path is blocked here, with the same Enterprise prerequisite as the setup guide above."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F02--update-tile",
          "title": "Update tile",
          "ids": [
            "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'`.",
          "raw": "**Update tile (`F02.update-tile`).** 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'`.",
          "source_line": 91,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--error-toast",
          "title": "Error toast",
          "ids": [
            "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. Light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"`, repeat the `goto`, and read `control-openhands browser eval \"(()=>{const t=[...document.querySelectorAll('[role=status]')].find(e=>e.textContent.includes('does not exist'));return getComputedStyle(t).color+' on '+getComputedStyle(t.closest('div[style]')).backgroundColor})()\"`: `rgb(31, 31, 31) on rgb(229, 229, 229)`: every toast uses the theme's high-emphasis ink (`--oh-contrast`) on its surface (the default theme reads `rgb(255, 255, 255) on rgb(49, 49, 49)`). A success toast reads the same: save any change on `/settings/app` (for example `control-openhands browser click 'testid=app-settings-screen >> text=Sound Notifications'`, `control-openhands browser click 'testid=submit-button'`, then toggle it back and save again) and read the `Settings saved` toast the same way: `rgb(31, 31, 31) on rgb(229, 229, 229)`. Solarized Light (`solarized-light`) gives `rgb(0, 43, 54) on rgb(230, 223, 204)` for both toasts. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"`.",
          "raw": "**Error toast (`F02.error-toasts`).** 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. Light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"`, repeat the `goto`, and read `control-openhands browser eval \"(()=>{const t=[...document.querySelectorAll('[role=status]')].find(e=>e.textContent.includes('does not exist'));return getComputedStyle(t).color+' on '+getComputedStyle(t.closest('div[style]')).backgroundColor})()\"`: `rgb(31, 31, 31) on rgb(229, 229, 229)`: every toast uses the theme's high-emphasis ink (`--oh-contrast`) on its surface (the default theme reads `rgb(255, 255, 255) on rgb(49, 49, 49)`). A success toast reads the same: save any change on `/settings/app` (for example `control-openhands browser click 'testid=app-settings-screen >> text=Sound Notifications'`, `control-openhands browser click 'testid=submit-button'`, then toggle it back and save again) and read the `Settings saved` toast the same way: `rgb(31, 31, 31) on rgb(229, 229, 229)`. Solarized Light (`solarized-light`) gives `rgb(0, 43, 54) on rgb(230, 223, 204)` for both toasts. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"`.",
          "source_line": 92,
          "expected": [
            "failed requests show a top-right error toast with the product or server message, and the toast closes by itself. Toasts stay readable in every colour theme."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F02--check-for-errors",
          "title": "Check for errors",
          "ids": [],
          "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.",
          "raw": "**Check for errors.** 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.",
          "source_line": 93,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F02--restore",
          "title": "Restore",
          "ids": [],
          "body": "Leave the sidebar expanded, unpinned, with the checklist shown and expanded, at `browser viewport desktop`.",
          "raw": "**Restore.** Leave the sidebar expanded, unpinned, with the checklist shown and expanded, at `browser viewport desktop`.",
          "source_line": 94,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F02--existing-conversations-while-collapsed",
          "title": "Existing conversations while collapsed",
          "ids": [
            "F02.sidebar-collapse"
          ],
          "body": "With at least one sidebar conversation, run `control-openhands browser click 'testid=sidebar-collapse-toggle'`, `control-openhands browser snapshot` and `control-openhands browser count 'testid=conversation-panel'`. Known failure (reproduced 2026-10-12, [#15662](https://github.com/OpenHands/OpenHands/issues/15662)): the count is `0`, and the rail has no chooser for existing conversations; expansion is required. Restore with `control-openhands browser hover 'role=link[name=\"OpenHands Logo\"]'` and `control-openhands browser click 'testid=sidebar-collapse-toggle'`.",
          "raw": "**Existing conversations while collapsed (`F02.sidebar-collapse`).** With at least one sidebar conversation, run `control-openhands browser click 'testid=sidebar-collapse-toggle'`, `control-openhands browser snapshot` and `control-openhands browser count 'testid=conversation-panel'`. Known failure (reproduced 2026-10-12, [#15662](https://github.com/OpenHands/OpenHands/issues/15662)): the count is `0`, and the rail has no chooser for existing conversations; expansion is required. Restore with `control-openhands browser hover 'role=link[name=\"OpenHands Logo\"]'` and `control-openhands browser click 'testid=sidebar-collapse-toggle'`.",
          "source_line": 96,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#15662](https://github.com/OpenHands/OpenHands/issues/15662)): the count is `0`, and the rail has no chooser for existing conversations; expansion is required. Restore with `control-openhands browser hover 'role=link[name=\"OpenHands Logo\"]'` and `control-openhands browser click 'testid=sidebar-collapse-toggle'`."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F02--contract-f02-alert-banner",
          "title": "Alert banner",
          "ids": [
            "F02.alert-banner"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F02--contract-f02-settings-404-modal",
          "title": "Settings 404 modal",
          "ids": [
            "F02.settings-404-modal"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "if the backend's settings return 404, the LLM settings modal opens by itself. Other settings fetch errors show a toast."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F03",
      "title": "Home and starting work",
      "file": "F03-home.md",
      "sha256": "f5a3aca0a9b9af8d753633c5592bd2cbea087d98c87d222dc5776ef9e1cc28f2",
      "overview": "Home is the \"What do you want to work on?\" screen at `/` and `/conversations`.\nA **Code / Automate** toggle above the composer picks what a submit starts: a\ncoding conversation, or an automation setup conversation where the agent fills\nan automation form. A user describes a task in the composer and, on a local\nbackend, can first pick a workspace folder (Open Workspace, folder browser, Manage Workspaces), a\nworkspace mode and plugins; submitting creates a conversation and opens it. A\nwarning banner replaces a silent failure when no LLM is configured. Below the\ncomposer, home offers recommended automations, a pinned-automations grid and an\nactivity list of enabled automations with per-item actions.",
      "entry": "- Sidebar **New Chat** (`sidebar-conversations-link`), which opens `/conversations`; the logo does the same (see F02).\n- Direct URL `/` (unless a page is pinned as home, see F02) or `/conversations`.\n- Command menu (`Control+k`, `Meta+k` or `command-menu-trigger`): search \"New chat\", choose **New chat**.\n- Above the composer: the **Code** / **Automate** toggle (`home-launcher-mode-code`, `home-launcher-mode-automate`).\n- Under the composer: **Open Workspace** (`open-workspace-button`) or, once confirmed, the preview bar (`home-git-control-bar-preview`); **Plugins** (`open-plugin-picker`).\n- In the Open Workspace dialog: the combobox (`workspace-dropdown`) and its footer **+ Add Workspace** (`add-workspaces-button`) and **Manage Workspaces** (`manage-workspaces-button`, shown once any workspace or parent exists); in **Add workspaces**, the Favorites/Locations sidebar (`folder-browser-sidebar-<name>`).\n- Below the composer: recommended cards (`recommended-automation-rail-card-<id>`), pinned cards (`pinned-automation-card-<id>`) and rows (`running-automation-row-<id>`) with their kebabs; **+ Add** (`home-automations-manage`).",
      "given": "Preconditions:\n\n- `F03.llm-not-configured-banner` and `F03.composer-blocked-without-llm` need a fresh `control-openhands launch --new` with only `control-openhands onboard --skip` done (no `llm preset` yet). Run them first.\n- Everything else: baseline state with `control-openhands llm preset deepseek` (deepseek-flash active).\n- Workspace fixtures: `control-openhands fixture git-repo --name qa-f03-repo` and `control-openhands fixture folder --name qa-f03-folder` (both under `<run>/workspace`).\n- Automation fixtures (arranged through F22 import and F21 Turn on, not proof): `control-openhands fixture file --name qa-f03-pong.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F03 Pong\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":true,\"prompt\":\"Reply with the single word: pong. Do not run any tools.\"}}'` and the same with `qa-f03-beta` / `QA_F03 Beta`. Run `control-openhands browser goto /automations`, then for each file 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-f03-pong.automation.json\"`, `control-openhands browser click 'testid=import-automation-confirm'`, `control-openhands browser wait 'testid=import-automation-modal' --state detached`. Imports arrive disabled: turn each on with `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=QA_F03 Pong >> role=button[name=\"Automation actions\"]'` and `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Turn on\"]'`. The yearly cron never fires by itself.\n- Blocked here: `F03.open-repository-dialog` (OpenHands Cloud backend with a connected GitHub/GitLab/Bitbucket provider), `F03.isolated-workspace-notice` (isolated-runtime backend), `F03.workspaces-unsupported` (an Agent Server without `/api/workspaces`), `F03.recommended-prompt-launch` (a Tavily/Linear/Jira integration installed so some card has nothing missing).\n\nStarting work:",
      "gotchas": "- Once a workspace is confirmed, `open-workspace-button` is not rendered (the preview bar replaces it): a click on it waits the full 30 s. Reopen through `home-git-control-bar-preview`, or reload.\n- A pick in the workspace dropdown is stored in sessionStorage at once, before Confirm or Launch, and closing the dialog with the X keeps it: a bullet that needs the earlier workspace selected must pick it again instead of relying on the last confirmation.\n- `control-openhands workspace open` and `conversation start --workspace` save that folder as a standalone workspace (`api GET /api/workspaces` lists it under `workspaces`). When another family used them first on the same stack, Add all subdirectories leaves those folders out of the `workspace` parent and the dropdown is grouped: options read `workspace, qa-f03-repo` and `Other, qa-f18-repo`. `role=option[name=\"qa-f03-repo\"]` still matches (substring), but the menu text and the `workspace-group-header` count `2` in the Search bullet assume F03's own fixtures only.\n- Folder rows navigate on a single click; there is no path field. The browser starts at the server home, `<run>/private/home`, two levels below `<run>`. After any conversation without a workspace, `<run>/private/home/workspace` exists: it shows as a `workspace` row and, after the next page load, a `workspace` favorite (`folder-browser-sidebar-workspace`), which is not `<run>/workspace`.\n- A workspace and its parent can share a name (`workspace`); their test ids differ only by prefix (`manage-workspaces-row-` vs `manage-workspaces-parent-row-`).\n- `chat-input` is a contenteditable `div`: it stays editable when the composer is \"disabled\"; assert the Send button and the absence of a new conversation instead.\n- `conversation start` without `--stay` navigates home and loses a picked workspace and plugins; drive the submit button as above.\n- Responder **Continue with local setup** writes an `OPENHANDS_URL` secret; delete it so F14 counts stay clean.\n- The rail lists about 15 cards (proven ones, then conversation launchers such as `research-brief-writer`); `browser testids` reports only the first few, so check a card with `browser count 'testid=recommended-automation-rail-card-<id>'`. Any automation whose name matches a card (`Daily news digest`) hides that card, so avoid catalog names in fixtures.\n- The home draft (`oh:home-prompt-draft`) survives the no-LLM bullets; `browser type` appends to it, so `browser fill 'testid=chat-input' ''` before typing a slash command.\n- Imported automations arrive disabled and home lists only enabled ones; turn them on before looking for rows. A run dispatched from home and then cancelled still leaves a `QA_F03 Pong — <date>` conversation in the sidebar.\n- With the Agent Server stopped, a reload does not render home at all; trigger the error path before reloading, then `restart`.\n- Deleting the last automation leaves its id in the pinned localStorage key (pruning skips an empty list); the grid still hides it.\n- Pinned cards reorder only by mouse drag; there is no keyboard or menu alternative. Drive it with `browser drag <handle> --by DX,DY --steps 20`; `browser drag <handle> <target>` reports ok but leaves the order unchanged.\n- `VITE_HOME_AUTOMATIONS_DEMO=true` (build-time) swaps the home automation sections for demo data; never verify against such a build.\n- The Code / Automate choice is component state, not stored: every page load and every return to home starts in Code. A toggle check after a navigation must click Automate again.\n- When the Agent Server is stopped, background refetches can add a generic `An error occurred` toast (#18051). For `F03.create-error-toast`, retain the complete video and toast history and report any extra toast with its timing; do not waive it from the count.\n- The workspace combobox uses one message for an empty list and for a search with no match: both read `No workspaces yet` (`workspace-dropdown-empty`), with **+ Add Workspace** still below it (#18037).\n- Known issue [#17108](https://github.com/OpenHands/OpenHands/issues/17108) (`F03.open-repository-dialog`): The repository picker lists a search result for input the user has already cleared. Live prerequisite: An authenticated Cloud Git provider and a real repository search response that remains pending after clearing the input.\n- Known issue [#16801](https://github.com/OpenHands/OpenHands/issues/16801) (`F03.launch-without-workspace`): Agent starts by looking in workspace, even when started with \"no workspace\". Live prerequisite: A working Claude Opus agent and the original task/prompt that makes it explore the default workspace; the prompt is absent.\n- Known issue [#16210](https://github.com/OpenHands/OpenHands/issues/16210) (`F03.plugin-picker`): [Cloud Backends] Plugins not available when starting a new conversation. Live prerequisite: An authenticated Cloud organization with configured account plugins before opening New Chat > Plugins.\n- Known issue [#15701](https://github.com/OpenHands/OpenHands/issues/15701) (`F03.launch-in-workspace`): Creating Conversation toast stuck visible. Live prerequisite: The original stuck creation request/outcome sequence; the report is intermittent and gives no deterministic trigger.\n- Known issue [#18480](https://github.com/OpenHands/OpenHands/issues/18480) (`F03.open-repository-dialog`): an Azure DevOps Enterprise user belonging to several organizations can see only one organization's repositories. Live prerequisite: a real Cloud/Enterprise sign-in with two organizations and distinct repositories; the report's mocked two-organization capture is not a real-backend reproduction.\n- Known issue [#18418](https://github.com/OpenHands/OpenHands/issues/18418) (`F03.create-error-toast`, `F03.launch-with-plugin`): hosted runtime 1.50.1 can fail during skills/plugins setup because its packaged plugin-schema resource is missing. Live prerequisite: the affected Cloud runtime/PyInstaller image and original plugin setup plus its account. A Local 1.55.0 launch does not verify that deployment.\n- Known issue [OpenHands/software-agent-sdk#5837](https://github.com/OpenHands/software-agent-sdk/issues/5837) (`F03.folder-browser`): case-only directory names at a page boundary can repeat the server cursor; Canvas detects the repeated cursor and the directory listing fails instead of showing all choices. Live prerequisite: a case-sensitive filesystem with enough directories to cross the real picker page boundary, including two distinct case-only names. A case-insensitive Mac volume cannot hold that fixture.\n- Known issue [OpenHands/software-agent-sdk#5849](https://github.com/OpenHands/software-agent-sdk/issues/5849) (`F03.folder-browser`, `F03.manage-workspaces`): re-adding a saved workspace or removing an already-removed stale row can return500 because the registry writes even for a no-op. Live prerequisite: an owned read-only workspace registry plus the already-saved or stale picker row; changes to registry mtime/inode alone are not a Canvas failure.",
      "features": [
        {
          "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, the Code / Automate toggle and the composer, whose Code-mode placeholder reads `What do you want to build?`."
        },
        {
          "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.launcher-mode-toggle",
          "description": "a `Choose launcher mode` radio group above the composer offers **Code** (checked on every page load) and **Automate**. Automate changes the placeholder to `What work do you want to automate?` and greys out and disables the Open Workspace and Plugins row (`home-composer-actions` `aria-disabled=\"true\"`); Code restores both. The active option stays readable in every colour theme."
        },
        {
          "id": "F03.launch-automate-mode",
          "description": "submitting in Automate mode creates a conversation and opens `/conversations/<id>` as an automation setup split view: the chat on the left and the automation form (`automation-setup-panel`) on the right, whose fields the agent fills with `✦ Filled by OpenHands` markers. Attached plugins are not sent, and nothing is created until **Create Automation**. The setup view itself is F22's."
        },
        {
          "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. Workspaces (local backend):"
        },
        {
          "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). Automations on home:"
        },
        {
          "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-run-outcome-health",
          "description": "an automation whose latest Completed run reported a task outcome shows it on Home (#17682): `blocked` and `partial_success` read **Blocked** and **Partial**, `unknown` or an unrecognised status reads **Needs review**, each with an amber (`bg-warning`) health dot, in the row text, the row link's aria-label, the tooltip's Status line and the pinned card's strip. A finish response without a string status counts as success."
        },
        {
          "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 opens the automation setup page `/automations/setup?automationId=<id>` with the form prefilled (no modal), where **Save** toasts `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 installed list and pinned grid are hidden and the composer still works. The recommendation rail returns after loading from its static catalog; opening a setup card reaches its route and pre-setup dialog. The rail asks for the installed automations only once `/api/automation/health` answers ok (#17838), so home raises no error toast for the stopped service."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F03--no-llm-banner",
          "title": "No-LLM banner",
          "ids": [
            "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`.",
          "raw": "**No-LLM banner (`F03.llm-not-configured-banner`).** 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`.",
          "source_line": 90,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--blocked-composer",
          "title": "Blocked composer",
          "ids": [
            "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).",
          "raw": "**Blocked composer (`F03.composer-blocked-without-llm`).** 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).",
          "source_line": 91,
          "expected": [
            "with no LLM, Send is disabled and Enter creates nothing (the text stays editable)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--draft",
          "title": "Draft",
          "ids": [
            "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).",
          "raw": "**Draft (`F03.draft-persistence`).** 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).",
          "source_line": 92,
          "expected": [
            "the home draft survives a reload and is cleared after a conversation is created."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--entry-points",
          "title": "Entry points",
          "ids": [
            "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`.",
          "raw": "**Entry points (`F03.home-entry-points`).** `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`.",
          "source_line": 93,
          "expected": [
            "`/`, `/conversations`, the sidebar **New Chat** and the command-menu item **New chat** show the launcher (`home-chat-launcher`) with the heading, subtitle, the Code / Automate toggle and the composer, whose Code-mode placeholder reads `What do you want to build?`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--model-on-home",
          "title": "`/model` on home",
          "ids": [
            "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`.",
          "raw": "**`/model` on home (`F03.home-model-command`).** `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`.",
          "source_line": 94,
          "expected": [
            "`/model <profile>` sent from home activates that LLM profile without creating a conversation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--launch-without-a-folder",
          "title": "Launch without a folder",
          "ids": [
            "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`.",
          "raw": "**Launch without a folder (`F03.launch-without-workspace`, `F03.draft-persistence`).** 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`.",
          "source_line": 95,
          "expected": [
            "with nothing picked, the conversation runs in a fresh per-conversation folder under the server home.",
            "the home draft survives a reload and is cleared after a conversation is created."
          ],
          "notes": [
            "Known issue [#16801](https://github.com/OpenHands/OpenHands/issues/16801) (`F03.launch-without-workspace`): Agent starts by looking in workspace, even when started with \"no workspace\". Live prerequisite: A working Claude Opus agent and the original task/prompt that makes it explore the default workspace; the prompt is absent."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--code-automate-toggle",
          "title": "Code / Automate toggle",
          "ids": [
            "F03.launcher-mode-toggle"
          ],
          "body": "Run `control-openhands browser goto /`, `control-openhands browser fill 'testid=chat-input' ''` and `control-openhands browser snapshot 'testid=home-launcher-mode-toggle'`: `radiogroup \"Choose launcher mode\"` with `radio \"Code\" [checked]` and `radio \"Automate\"`; `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').getAttribute('data-placeholder')\"` is `What do you want to build?`. Run `control-openhands browser click 'testid=home-launcher-mode-automate'`: Automate is checked, the placeholder is `What work do you want to automate?`, `control-openhands browser attr 'testid=home-composer-actions' aria-disabled` is `true`, and `control-openhands browser enabled 'testid=open-workspace-button'` and `control-openhands browser enabled 'testid=open-plugin-picker'` are `false` (`browser screenshot --feature F03.launcher-mode-toggle --name automate`). `control-openhands browser reload` brings Code back (`browser attr 'testid=home-launcher-mode-code' aria-checked` is `true`). Light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, then `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=home-launcher-mode-code]')).color\"` and `browser screenshot 'testid=home-launcher-mode-toggle' --feature F03.launcher-mode-toggle --name light-plus-code`. The checked label uses the theme's high-emphasis ink (`text-contrast`): `rgb(31, 31, 31)` on the light pill, and a hovered inactive option (`control-openhands browser hover 'testid=home-launcher-mode-automate'`) turns the same dark ink; Solarized Light reads `rgb(0, 43, 54)`, also at phone width. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"` and `browser reload`.",
          "raw": "**Code / Automate toggle (`F03.launcher-mode-toggle`).** Run `control-openhands browser goto /`, `control-openhands browser fill 'testid=chat-input' ''` and `control-openhands browser snapshot 'testid=home-launcher-mode-toggle'`: `radiogroup \"Choose launcher mode\"` with `radio \"Code\" [checked]` and `radio \"Automate\"`; `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').getAttribute('data-placeholder')\"` is `What do you want to build?`. Run `control-openhands browser click 'testid=home-launcher-mode-automate'`: Automate is checked, the placeholder is `What work do you want to automate?`, `control-openhands browser attr 'testid=home-composer-actions' aria-disabled` is `true`, and `control-openhands browser enabled 'testid=open-workspace-button'` and `control-openhands browser enabled 'testid=open-plugin-picker'` are `false` (`browser screenshot --feature F03.launcher-mode-toggle --name automate`). `control-openhands browser reload` brings Code back (`browser attr 'testid=home-launcher-mode-code' aria-checked` is `true`). Light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, then `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=home-launcher-mode-code]')).color\"` and `browser screenshot 'testid=home-launcher-mode-toggle' --feature F03.launcher-mode-toggle --name light-plus-code`. The checked label uses the theme's high-emphasis ink (`text-contrast`): `rgb(31, 31, 31)` on the light pill, and a hovered inactive option (`control-openhands browser hover 'testid=home-launcher-mode-automate'`) turns the same dark ink; Solarized Light reads `rgb(0, 43, 54)`, also at phone width. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"` and `browser reload`.",
          "source_line": 96,
          "expected": [
            "a `Choose launcher mode` radio group above the composer offers **Code** (checked on every page load) and **Automate**. Automate changes the placeholder to `What work do you want to automate?` and greys out and disables the Open Workspace and Plugins row (`home-composer-actions` `aria-disabled=\"true\"`); Code restores both. The active option stays readable in every colour theme."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--automate-launch",
          "title": "Automate launch",
          "ids": [
            "F03.launch-automate-mode"
          ],
          "body": "On `/` click `testid=home-launcher-mode-automate`, fill `chat-input` with `Set up a disabled automation named QA_F03 Auto that runs every day at 09:00 UTC with the prompt: Reply with the single word pong. Only fill the automation form; do not run any other tools and do not create the automation.` and run `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+'`. The page shows `automation-setup-panel` beside the chat (`browser testids --filter automation-setup` lists `automation-setup-form`, `automation-setup-create`, `automation-setup-save-draft`). After `control-openhands conversation wait <id> --timeout 240` is `finished`, `control-openhands browser value 'testid=automation-setup-name'` is `QA_F03 Auto` and the filled fields carry `✦ Filled by OpenHands` (`browser screenshot --feature F03.launch-automate-mode --name setup-split`), while `control-openhands api GET /api/automation/v1 --pick total` is still `0`. The sidebar row is tagged `Automation setup: Draft`.",
          "raw": "**Automate launch (`F03.launch-automate-mode`).** On `/` click `testid=home-launcher-mode-automate`, fill `chat-input` with `Set up a disabled automation named QA_F03 Auto that runs every day at 09:00 UTC with the prompt: Reply with the single word pong. Only fill the automation form; do not run any other tools and do not create the automation.` and run `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+'`. The page shows `automation-setup-panel` beside the chat (`browser testids --filter automation-setup` lists `automation-setup-form`, `automation-setup-create`, `automation-setup-save-draft`). After `control-openhands conversation wait <id> --timeout 240` is `finished`, `control-openhands browser value 'testid=automation-setup-name'` is `QA_F03 Auto` and the filled fields carry `✦ Filled by OpenHands` (`browser screenshot --feature F03.launch-automate-mode --name setup-split`), while `control-openhands api GET /api/automation/v1 --pick total` is still `0`. The sidebar row is tagged `Automation setup: Draft`.",
          "source_line": 97,
          "expected": [
            "submitting in Automate mode creates a conversation and opens `/conversations/<id>` as an automation setup split view: the chat on the left and the automation form (`automation-setup-panel`) on the right, whose fields the agent fills with `✦ Filled by OpenHands` markers. Attached plugins are not sent, and nothing is created until **Create Automation**. The setup view itself is F22's."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--plugins",
          "title": "Plugins",
          "ids": [
            "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).",
          "raw": "**Plugins (`F03.plugin-picker`).** 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).",
          "source_line": 98,
          "expected": [
            "**Plugins** opens **Attach plugins** (catalog, search, Add/Remove switch, `N selected`, Done and X); the pill then shows a count badge."
          ],
          "notes": [
            "Known issue [#16210](https://github.com/OpenHands/OpenHands/issues/16210) (`F03.plugin-picker`): [Cloud Backends] Plugins not available when starting a new conversation. Live prerequisite: An authenticated Cloud organization with configured account plugins before opening New Chat > Plugins."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--plugin-reaches-the-agent",
          "title": "Plugin reaches the agent",
          "ids": [
            "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`.",
          "raw": "**Plugin reaches the agent (`F03.launch-with-plugin`).** 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`.",
          "source_line": 99,
          "expected": [
            "a conversation started with a plugin attached loads it; the selection resets afterwards. Workspaces (local backend):"
          ],
          "notes": [
            "Known issue [#18418](https://github.com/OpenHands/OpenHands/issues/18418) (`F03.create-error-toast`, `F03.launch-with-plugin`): hosted runtime 1.50.1 can fail during skills/plugins setup because its packaged plugin-schema resource is missing. Live prerequisite: the affected Cloud runtime/PyInstaller image and original plugin setup plus its account. A Local 1.55.0 launch does not verify that deployment."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--creation-failure",
          "title": "Creation failure",
          "ids": [
            "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`.\n\nWorkspaces:",
          "raw": "**Creation failure (`F03.create-error-toast`).** 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`.\n\nWorkspaces:",
          "source_line": 100,
          "expected": [
            "when creation fails, the loading toast is replaced by one error toast and the prompt is not lost."
          ],
          "notes": [
            "Known issue [#18418](https://github.com/OpenHands/OpenHands/issues/18418) (`F03.create-error-toast`, `F03.launch-with-plugin`): hosted runtime 1.50.1 can fail during skills/plugins setup because its packaged plugin-schema resource is missing. Live prerequisite: the affected Cloud runtime/PyInstaller image and original plugin setup plus its account. A Local 1.55.0 launch does not verify that deployment."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--folder-browser",
          "title": "Folder browser",
          "ids": [
            "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`):\n  - `control-openhands browser click 'testid=folder-browser-cancel'`: `browser count 'testid=folder-browser-modal'` is `0` and the selection is unchanged.\n  - `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\"`).\n  - `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).",
          "raw": "**Folder browser (`F03.folder-browser`).** 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`):\n  - `control-openhands browser click 'testid=folder-browser-cancel'`: `browser count 'testid=folder-browser-modal'` is `0` and the selection is unchanged.\n  - `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\"`).\n  - `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).",
          "source_line": 104,
          "expected": [
            "**+ 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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5837](https://github.com/OpenHands/software-agent-sdk/issues/5837) (`F03.folder-browser`): case-only directory names at a page boundary can repeat the server cursor; Canvas detects the repeated cursor and the directory listing fails instead of showing all choices. Live prerequisite: a case-sensitive filesystem with enough directories to cross the real picker page boundary, including two distinct case-only names. A case-insensitive Mac volume cannot hold that fixture.",
            "Known issue [OpenHands/software-agent-sdk#5849](https://github.com/OpenHands/software-agent-sdk/issues/5849) (`F03.folder-browser`, `F03.manage-workspaces`): re-adding a saved workspace or removing an already-removed stale row can return500 because the registry writes even for a no-op. Live prerequisite: an owned read-only workspace registry plus the already-saved or stale picker row; changes to registry mtime/inode alone are not a Canvas failure."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--favorites-and-locations",
          "title": "Favorites and locations",
          "ids": [
            "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`.",
          "raw": "**Favorites and locations (`F03.folder-browser-locations`).** 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`.",
          "source_line": 108,
          "expected": [
            "the browser's **Favorites** (Home, plus `workspace` once a no-folder conversation exists) and **Locations** (`/`) jump to that folder; Up is disabled at `/`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--open-workspace",
          "title": "Open Workspace",
          "ids": [
            "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`.",
          "raw": "**Open Workspace (`F03.open-workspace-dialog`).** 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`.",
          "source_line": 109,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--launch-in-a-folder",
          "title": "Launch in a folder",
          "ids": [
            "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`.",
          "raw": "**Launch in a folder (`F03.launch-in-workspace`).** 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`.",
          "source_line": 110,
          "expected": [
            "submitting with a workspace picked shows `Creating conversation…`, opens `/conversations/<id>`, and the agent runs in that folder."
          ],
          "notes": [
            "Known issue [#15701](https://github.com/OpenHands/OpenHands/issues/15701) (`F03.launch-in-workspace`): Creating Conversation toast stuck visible. Live prerequisite: The original stuck creation request/outcome sequence; the report is intermittent and gives no deterministic trigger."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--search-groups-and-empty",
          "title": "Search, groups and empty",
          "ids": [
            "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.",
          "raw": "**Search, groups and empty (`F03.workspace-dropdown-search`).** 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.",
          "source_line": 111,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--reload",
          "title": "Reload",
          "ids": [
            "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.",
          "raw": "**Reload (`F03.selection-after-reload`).** 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.",
          "source_line": 112,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--workspace-mode",
          "title": "Workspace mode",
          "ids": [
            "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`.",
          "raw": "**Workspace mode (`F03.workspace-mode`).** 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`.",
          "source_line": 113,
          "expected": [
            "the preview bar's mode menu switches **Local Repo** / **New Worktree**; the choice persists across reloads."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--manage",
          "title": "Manage",
          "ids": [
            "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`.\n\nAutomations on home (fixtures imported and turned on):",
          "raw": "**Manage (`F03.manage-workspaces`).** 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`.\n\nAutomations on home (fixtures imported and turned on):",
          "source_line": 114,
          "expected": [
            "**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.`"
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5849](https://github.com/OpenHands/software-agent-sdk/issues/5849) (`F03.folder-browser`, `F03.manage-workspaces`): re-adding a saved workspace or removing an already-removed stale row can return500 because the registry writes even for a no-op. Live prerequisite: an owned read-only workspace registry plus the already-saved or stale picker row; changes to registry mtime/inode alone are not a Canvas failure."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--recommended-rail",
          "title": "Recommended rail",
          "ids": [
            "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).",
          "raw": "**Recommended rail (`F03.recommended-automations-rail`).** 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).",
          "source_line": 118,
          "expected": [
            "a horizontally scrolling rail of recommended automations with edge fades; cards with a setup form open `/automations/new/<id>`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--responder-choice",
          "title": "Responder choice",
          "ids": [
            "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\"]'`.",
          "raw": "**Responder choice (`F03.recommended-responder-choice`).** 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\"]'`.",
          "source_line": 119,
          "expected": [
            "GitHub/Slack responder cards on a local backend first ask **Choose how your responder should run**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--cloud-option",
          "title": "Cloud option",
          "ids": [
            "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.",
          "raw": "**Cloud option (`F03.responder-cloud-option`).** 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.",
          "source_line": 120,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--missing-integration",
          "title": "Missing integration",
          "ids": [
            "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.",
          "raw": "**Missing integration (`F03.recommended-missing-integration`).** `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.",
          "source_line": 121,
          "expected": [
            "a card whose required integration is missing opens that integration's install dialog first."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--already-added",
          "title": "Already added",
          "ids": [
            "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.",
          "raw": "**Already added (`F03.recommended-hides-added`).** `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.",
          "source_line": 122,
          "expected": [
            "a catalog automation that already exists (matched by name, enabled or not) drops off the rail; deleting it brings the card back."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--activity-list",
          "title": "Activity list",
          "ids": [
            "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.",
          "raw": "**Activity list (`F03.home-automations-list`, `F03.home-automation-links`).** `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.",
          "source_line": 123,
          "expected": [
            "**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.",
            "a row opens `/automations/<id>`, a sparkline bar opens `/automations/<id>?run=<runId>`, **+ Add** opens `/automations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--row-menu-run-now-and-cancel",
          "title": "Row menu, Run now and Cancel",
          "ids": [
            "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 /`. Known failure (reproduced 2026-10-12, [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514)): a bounded script run cancelled from this Home menu shows `Automation run cancelled` and `Cancelled` in the row and Activity Log, but its launcher-owned `python3 worker.py` process remains running after cancellation. This was reproduced in `backfill-18309-integrations-worker` on `fa1f724ab5737a17453ad6081129eb91c5a423c6`, SDK 1.54.0 and automation 1.19.3: package a run-owned `worker.py` that prints one tick and sleeps 2 seconds for 90 iterations; upload its tarball with `control-openhands api POST '/api/automation/v1/uploads?name=qa-backfill-sleep' --write --data-file <bounded-sleep-tarball> --content-type application/gzip`, then create an active scripted automation through `api POST /api/automation/v1 --write --data '@<fixture-json>'` with `entrypoint: python3 worker.py`, the returned `tarball_path`, a UTC `0 0 1 1 *` cron trigger and timeout 240. Open `/automations/<sleep-id>`, click `role=button[name=\"Run now\"]`, wait for the actual worker to start, return Home and click `testid=running-automation-menu-<sleep-id>` then `testid=running-automation-cancel-<sleep-id>`. Read-only process checks restricted to this launcher's descendants still found the worker after the cancellation UI settled. Evidence: `F03.cancel-in-flight-run/local-worker-cancel-514.png`, `F03.cancel-in-flight-run/owned-worker-post-cancel.json` and `F23.activity-log/cancelled-514.aria.txt`; the worker is bounded to exit without external calls. Let it exit, then delete the temporary automation with `control-openhands api DELETE /api/automation/v1/<sleep-id> --write`.",
          "raw": "**Row menu, Run now and Cancel (`F03.home-row-menu`, `F03.cancel-in-flight-run`, `F03.home-automation-links`).** 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 /`. Known failure (reproduced 2026-10-12, [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514)): a bounded script run cancelled from this Home menu shows `Automation run cancelled` and `Cancelled` in the row and Activity Log, but its launcher-owned `python3 worker.py` process remains running after cancellation. This was reproduced in `backfill-18309-integrations-worker` on `fa1f724ab5737a17453ad6081129eb91c5a423c6`, SDK 1.54.0 and automation 1.19.3: package a run-owned `worker.py` that prints one tick and sleeps 2 seconds for 90 iterations; upload its tarball with `control-openhands api POST '/api/automation/v1/uploads?name=qa-backfill-sleep' --write --data-file <bounded-sleep-tarball> --content-type application/gzip`, then create an active scripted automation through `api POST /api/automation/v1 --write --data '@<fixture-json>'` with `entrypoint: python3 worker.py`, the returned `tarball_path`, a UTC `0 0 1 1 *` cron trigger and timeout 240. Open `/automations/<sleep-id>`, click `role=button[name=\"Run now\"]`, wait for the actual worker to start, return Home and click `testid=running-automation-menu-<sleep-id>` then `testid=running-automation-cancel-<sleep-id>`. Read-only process checks restricted to this launcher's descendants still found the worker after the cancellation UI settled. Evidence: `F03.cancel-in-flight-run/local-worker-cancel-514.png`, `F03.cancel-in-flight-run/owned-worker-post-cancel.json` and `F23.activity-log/cancelled-514.aria.txt`; the worker is bounded to exit without external calls. Let it exit, then delete the temporary automation with `control-openhands api DELETE /api/automation/v1/<sleep-id> --write`.",
          "source_line": 124,
          "expected": [
            "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 opens the automation setup page `/automations/setup?automationId=<id>` with the form prefilled (no modal), where **Save** toasts `Automation updated`.",
            "**Cancel run** appears while a run is pending or running and cancels it (`Automation run cancelled`).",
            "a row opens `/automations/<id>`, a sparkline bar opens `/automations/<id>?run=<runId>`, **+ Add** opens `/automations`."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514)): a bounded script run cancelled from this Home menu shows `Automation run cancelled` and `Cancelled` in the row and Activity Log, but its launcher-owned `python3 worker.py` process remains running after cancellation. This was reproduced in `backfill-18309-integrations-worker` on `fa1f724ab5737a17453ad6081129eb91c5a423c6`, SDK 1.54.0 and automation 1.19.3: package a run-owned `worker.py` that prints one tick and sleeps 2 seconds for 90 iterations; upload its tarball with `control-openhands api POST '/api/automation/v1/uploads?name=qa-backfill-sleep' --write --data-file <bounded-sleep-tarball> --content-type application/gzip`, then create an active scripted automation through `api POST /api/automation/v1 --write --data '@<fixture-json>'` with `entrypoint: python3 worker.py`, the returned `tarball_path`, a UTC `0 0 1 1 *` cron trigger and timeout 240. Open `/automations/<sleep-id>`, click `role=button[name=\"Run now\"]`, wait for the actual worker to start, return Home and click `testid=running-automation-menu-<sleep-id>` then `testid=running-automation-cancel-<sleep-id>`. Read-only process checks restricted to this launcher's descendants still found the worker after the cancellation UI settled. Evidence: `F03.cancel-in-flight-run/local-worker-cancel-514.png`, `F03.cancel-in-flight-run/owned-worker-post-cancel.json` and `F23.activity-log/cancelled-514.aria.txt`; the worker is bounded to exit without external calls. Let it exit, then delete the temporary automation with `control-openhands api DELETE /api/automation/v1/<sleep-id> --write`."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F03--tooltip",
          "title": "Tooltip",
          "ids": [
            "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`.",
          "raw": "**Tooltip (`F03.home-run-tooltip`).** `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`.",
          "source_line": 125,
          "expected": [
            "hovering a row shows its trigger, status, last run and task."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--run-outcomes",
          "title": "Run outcomes",
          "ids": [
            "F03.home-run-outcome-health"
          ],
          "body": "Arrange as `F23.run-task-outcome` does (API `preset/prompt`, yearly cron `0 0 1 1 *`, enabled) `QA_F03 Blocked`, which finishes with status `blocked`, and `QA_F03 Review`, which finishes with status `unknown`, each prompt ending `Do not run any tools.` On `/`, for each row open `'[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Blocked >> [data-testid^=\"running-automation-menu-\"]'` and click `[data-testid^=\"running-automation-run-\"]` (`Automation dispatched`); poll `control-openhands api GET /api/automation/v1/<id>/runs` until the run is `COMPLETED` (about 35 s), then `control-openhands browser reload`. `control-openhands browser text 'testid=running-automations-list'` shows `… · Blocked` and `… · Needs review`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=running-automation-row-] a[aria-label]')].filter(a=>a.querySelector('.rounded-full')).map(a=>a.getAttribute('aria-label')+' | '+a.querySelector('.rounded-full').className.match(/bg-[a-z-]+/g)).join('; ')\"` lists `QA_F03 Blocked Blocked | bg-warning` and `QA_F03 Review Needs review | bg-warning` (in list order, which can vary). `control-openhands browser tooltip` on each row reads `Status Blocked` / `Task <its summary>` and `Status Needs review` / `Task <its summary>` (before each tooltip read, `browser hover 'testid=chat-input'` and `browser wait 'role=tooltip' --state detached --timeout 3000`, or the previous row's tooltip is returned). Pin both from their row menus (`[data-testid^=\"running-automation-pin-\"]`): each card's strip shows the Blocked (!) (`run-status-icon-warning`) or Needs review (?) (`run-status-icon-needs-review`) icon, then the run's summary and its conversation title. Expected: the summary is visible (`browser eval \"[...document.querySelectorAll('[data-testid^=pinned-automation-card-] [data-testid=run-status-badge-icon]')].map(i=>Math.round(i.nextElementSibling.getBoundingClientRect().width))\"` is above `0`). Known failure (reproduced 2026-10-09, #18234): when the run has a conversation, the summary is 0 px wide at desktop and phone, so only the conversation title shows (the summary is still in `browser text`). Repeat at `browser viewport phone`. The status-less success case has no live producer (automation 1.19.0 with SDK 1.53.0 always sends a status), as `F23.run-task-outcome` records: `blocked`. Delete both automations afterwards.",
          "raw": "**Run outcomes (`F03.home-run-outcome-health`).** Arrange as `F23.run-task-outcome` does (API `preset/prompt`, yearly cron `0 0 1 1 *`, enabled) `QA_F03 Blocked`, which finishes with status `blocked`, and `QA_F03 Review`, which finishes with status `unknown`, each prompt ending `Do not run any tools.` On `/`, for each row open `'[data-testid^=\"running-automation-row-\"] >> has-text=QA_F03 Blocked >> [data-testid^=\"running-automation-menu-\"]'` and click `[data-testid^=\"running-automation-run-\"]` (`Automation dispatched`); poll `control-openhands api GET /api/automation/v1/<id>/runs` until the run is `COMPLETED` (about 35 s), then `control-openhands browser reload`. `control-openhands browser text 'testid=running-automations-list'` shows `… · Blocked` and `… · Needs review`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid^=running-automation-row-] a[aria-label]')].filter(a=>a.querySelector('.rounded-full')).map(a=>a.getAttribute('aria-label')+' | '+a.querySelector('.rounded-full').className.match(/bg-[a-z-]+/g)).join('; ')\"` lists `QA_F03 Blocked Blocked | bg-warning` and `QA_F03 Review Needs review | bg-warning` (in list order, which can vary). `control-openhands browser tooltip` on each row reads `Status Blocked` / `Task <its summary>` and `Status Needs review` / `Task <its summary>` (before each tooltip read, `browser hover 'testid=chat-input'` and `browser wait 'role=tooltip' --state detached --timeout 3000`, or the previous row's tooltip is returned). Pin both from their row menus (`[data-testid^=\"running-automation-pin-\"]`): each card's strip shows the Blocked (!) (`run-status-icon-warning`) or Needs review (?) (`run-status-icon-needs-review`) icon, then the run's summary and its conversation title. Expected: the summary is visible (`browser eval \"[...document.querySelectorAll('[data-testid^=pinned-automation-card-] [data-testid=run-status-badge-icon]')].map(i=>Math.round(i.nextElementSibling.getBoundingClientRect().width))\"` is above `0`). Known failure (reproduced 2026-10-09, #18234): when the run has a conversation, the summary is 0 px wide at desktop and phone, so only the conversation title shows (the summary is still in `browser text`). Repeat at `browser viewport phone`. The status-less success case has no live producer (automation 1.19.0 with SDK 1.53.0 always sends a status), as `F23.run-task-outcome` records: `blocked`. Delete both automations afterwards.",
          "source_line": 126,
          "expected": [
            "an automation whose latest Completed run reported a task outcome shows it on Home (#17682): `blocked` and `partial_success` read **Blocked** and **Partial**, `unknown` or an unrecognised status reads **Needs review**, each with an amber (`bg-warning`) health dot, in the row text, the row link's aria-label, the tooltip's Status line and the pinned card's strip. A finish response without a string status counts as success."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-09, #18234): when the run has a conversation, the summary is 0 px wide at desktop and phone, so only the conversation title shows (the summary is still in `browser text`). Repeat at `browser viewport phone`. The status-less success case has no live producer (automation 1.19.0 with SDK 1.53.0 always sends a status), as `F23.run-task-outcome` records: `blocked`. Delete both automations afterwards."
          ],
          "states": [
            "failure",
            "blocked"
          ]
        },
        {
          "name": "F03--view-and-edit",
          "title": "View and Edit",
          "ids": [
            "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-\"]`, then `control-openhands browser wait-url 'automations/setup\\?automationId='`: the setup page opens prefilled (no modal; `browser count 'text=Edit automation'` is `0`), `browser value 'testid=automation-setup-name'` is `QA_F03 Beta` and the prompt is the stored one (`browser screenshot --feature F03.home-row-menu --name edit-opens-setup`). `control-openhands browser fill 'testid=automation-setup-prompt' 'Reply with the single word: beta2. Do not run any tools.'` and `control-openhands browser click 'testid=automation-setup-save' --observe 'role=status'` (`Automation updated`); `api GET /api/automation/v1/<automation-id>` shows the new prompt. The page's own controls (Test, Close, the timeout limit) are F22's.",
          "raw": "**View and Edit (`F03.home-row-menu`).** 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-\"]`, then `control-openhands browser wait-url 'automations/setup\\?automationId='`: the setup page opens prefilled (no modal; `browser count 'text=Edit automation'` is `0`), `browser value 'testid=automation-setup-name'` is `QA_F03 Beta` and the prompt is the stored one (`browser screenshot --feature F03.home-row-menu --name edit-opens-setup`). `control-openhands browser fill 'testid=automation-setup-prompt' 'Reply with the single word: beta2. Do not run any tools.'` and `control-openhands browser click 'testid=automation-setup-save' --observe 'role=status'` (`Automation updated`); `api GET /api/automation/v1/<automation-id>` shows the new prompt. The page's own controls (Test, Close, the timeout limit) are F22's.",
          "source_line": 127,
          "expected": [
            "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 opens the automation setup page `/automations/setup?automationId=<id>` with the form prefilled (no modal), where **Save** toasts `Automation updated`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--turn-off",
          "title": "Turn off",
          "ids": [
            "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.",
          "raw": "**Turn off (`F03.turn-off-confirmation`).** 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.",
          "source_line": 128,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--pin",
          "title": "Pin",
          "ids": [
            "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.",
          "raw": "**Pin (`F03.pinned-automations-grid`).** 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.",
          "source_line": 129,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--pinned-menu",
          "title": "Pinned menu",
          "ids": [
            "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.",
          "raw": "**Pinned menu (`F03.pinned-card-menu`).** 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.",
          "source_line": 130,
          "expected": [
            "a pinned card's kebab has the same actions with **Unpin from dashboard**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--pinned-links",
          "title": "Pinned links",
          "ids": [
            "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).",
          "raw": "**Pinned links (`F03.pinned-card-links`).** 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).",
          "source_line": 131,
          "expected": [
            "a pinned card's sparkline bar opens `/automations/<id>?run=<runId>`, and Enter on its focused title opens the detail page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--reorder",
          "title": "Reorder",
          "ids": [
            "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).",
          "raw": "**Reorder (`F03.pinned-reorder-drag`).** 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).",
          "source_line": 132,
          "expected": [
            "dragging a pinned card's title row (`Reorder <name>`) onto another card reorders the pins, and the order survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--backend-down",
          "title": "Backend down",
          "ids": [
            "F03.automations-backend-down"
          ],
          "body": "Stop only this run's automation service with `control-openhands service stop automation`, then `control-openhands browser reload`. Once loading settles, `browser count` on `testid=running-automations-list` and `testid=pinned-automations-dashboard` is `0`, while `testid=recommended-automations-rail` is `1`: its cards come from the static catalog even when `/api/automation/health` and `/v1` return 502. The composer remains enabled. `control-openhands browser toasts --history` is `[]`. Click the Custom automation recommendation: it navigates to `/automations/new/custom-automation` and opens the **Before you start** dialog; the toast history is still `[]` (before #17838 a `Request failed with status code 502` toast appeared here). Capture both Home and the pre-setup dialog, then `control-openhands restart` and `browser reload`; doctor should show the owned automation service healthy again.\n\nPhone and cleanup:",
          "raw": "**Backend down (`F03.automations-backend-down`).** Stop only this run's automation service with `control-openhands service stop automation`, then `control-openhands browser reload`. Once loading settles, `browser count` on `testid=running-automations-list` and `testid=pinned-automations-dashboard` is `0`, while `testid=recommended-automations-rail` is `1`: its cards come from the static catalog even when `/api/automation/health` and `/v1` return 502. The composer remains enabled. `control-openhands browser toasts --history` is `[]`. Click the Custom automation recommendation: it navigates to `/automations/new/custom-automation` and opens the **Before you start** dialog; the toast history is still `[]` (before #17838 a `Request failed with status code 502` toast appeared here). Capture both Home and the pre-setup dialog, then `control-openhands restart` and `browser reload`; doctor should show the owned automation service healthy again.\n\nPhone and cleanup:",
          "source_line": 133,
          "expected": [
            "with the automation service down, the installed list and pinned grid are hidden and the composer still works. The recommendation rail returns after loading from its static catalog; opening a setup card reaches its route and pre-setup dialog. The rail asks for the installed automations only once `/api/automation/health` answers ok (#17838), so home raises no error toast for the stopped service."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F03.phone`).** `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`.",
          "source_line": 137,
          "expected": [
            "at 390 px the banner, launcher, rail, pinned grid, list and the workspace dialogs fit without horizontal overflow, and folder names stay readable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--cleanup",
          "title": "Cleanup",
          "ids": [
            "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.`",
          "raw": "**Cleanup (`F03.pinned-automations-grid`).** 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.`",
          "source_line": 138,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F03--implicit-launch-readiness",
          "title": "Implicit-launch readiness",
          "ids": [
            "F03.composer-blocked-without-llm"
          ],
          "body": "Arrange named OpenHands agent `QA_PINNED` with `tools:[]`, concurrency7, and two LLM profiles on refused loopback `http://127.0.0.1:1/v1`: `QA_READY` has a disposable key and `QA_EMPTY` has no key. Use real `/api/profiles/<name>` and `/api/agent-profiles/<name>` writes, then activate the named agent by its ID. Pin it to QA_EMPTY and activate QA_READY; open Home and inspect the warning/pill. Invert the pinned/active profiles, reopen Home, fill `testid=chat-input` with a bounded no-tools prompt and read `browser enabled testid=submit-button`. Expected: readiness agrees with the effective LLM shown by the launch pill. Known failure (reproduced 2026-10-12, [#16951](https://github.com/OpenHands/OpenHands/issues/16951)): the readiness gate follows the named agent's pinned LLM, so a keyless pin can disable a key-present pill, and the inverse can permit a keyless pill. On current backends with per-launch LLM override, a launch uses the pill's LLM while preserving the named agent's identity, concurrency and tools (`F13.llm-pill-precedence`); that current behavior is separate from the readiness mismatch. Read only credential-presence booleans, without claiming provider authentication. Restore the default agent/active LLM and delete disposable profiles.",
          "raw": "**Implicit-launch readiness (`F03.composer-blocked-without-llm`).** Arrange named OpenHands agent `QA_PINNED` with `tools:[]`, concurrency7, and two LLM profiles on refused loopback `http://127.0.0.1:1/v1`: `QA_READY` has a disposable key and `QA_EMPTY` has no key. Use real `/api/profiles/<name>` and `/api/agent-profiles/<name>` writes, then activate the named agent by its ID. Pin it to QA_EMPTY and activate QA_READY; open Home and inspect the warning/pill. Invert the pinned/active profiles, reopen Home, fill `testid=chat-input` with a bounded no-tools prompt and read `browser enabled testid=submit-button`. Expected: readiness agrees with the effective LLM shown by the launch pill. Known failure (reproduced 2026-10-12, [#16951](https://github.com/OpenHands/OpenHands/issues/16951)): the readiness gate follows the named agent's pinned LLM, so a keyless pin can disable a key-present pill, and the inverse can permit a keyless pill. On current backends with per-launch LLM override, a launch uses the pill's LLM while preserving the named agent's identity, concurrency and tools (`F13.llm-pill-precedence`); that current behavior is separate from the readiness mismatch. Read only credential-presence booleans, without claiming provider authentication. Restore the default agent/active LLM and delete disposable profiles.",
          "source_line": 140,
          "expected": [
            "with no LLM, Send is disabled and Enter creates nothing (the text stays editable)."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16951](https://github.com/OpenHands/OpenHands/issues/16951)): the readiness gate follows the named agent's pinned LLM, so a keyless pin can disable a key-present pill, and the inverse can permit a keyless pill. On current backends with per-launch LLM override, a launch uses the pill's LLM while preserving the named agent's identity, concurrency and tools (`F13.llm-pill-precedence`); that current behavior is separate from the readiness mismatch. Read only credential-presence booleans, without claiming provider authentication. Restore the default agent/active LLM and delete disposable profiles."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F03--target-across-local-backends",
          "title": "Target across Local backends",
          "ids": [
            "F03.selection-after-reload"
          ],
          "body": "In two run-owned Local backends, save distinct QA_A_ONLY and QA_B_ONLY workspaces through real `/api/workspaces` writes. Register the second backend through Add Backend with its key read from its private key file. On QA_Secondary Home, open the workspace dialog, choose QA_B_ONLY and Confirm; switch to Local through `backend-selector` without reload or `browser goto`. Read the preview, submit a bounded no-tools prompt through the real composer, and read the new Local conversation workspace. Expected: an incompatible target is cleared before submission. Known failure (reproduced 2026-10-12, [#16950](https://github.com/OpenHands/OpenHands/issues/16950)): QA_B_ONLY remains visible and its secondary-run path is submitted to Local. This is the Local variant; Cloud repository/organization recents still need an approved Cloud fixture.",
          "raw": "**Target across Local backends (`F03.selection-after-reload`).** In two run-owned Local backends, save distinct QA_A_ONLY and QA_B_ONLY workspaces through real `/api/workspaces` writes. Register the second backend through Add Backend with its key read from its private key file. On QA_Secondary Home, open the workspace dialog, choose QA_B_ONLY and Confirm; switch to Local through `backend-selector` without reload or `browser goto`. Read the preview, submit a bounded no-tools prompt through the real composer, and read the new Local conversation workspace. Expected: an incompatible target is cleared before submission. Known failure (reproduced 2026-10-12, [#16950](https://github.com/OpenHands/OpenHands/issues/16950)): QA_B_ONLY remains visible and its secondary-run path is submitted to Local. This is the Local variant; Cloud repository/organization recents still need an approved Cloud fixture.",
          "source_line": 142,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16950](https://github.com/OpenHands/OpenHands/issues/16950)): QA_B_ONLY remains visible and its secondary-run path is submitted to Local. This is the Local variant; Cloud repository/organization recents still need an approved Cloud fixture."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F03--saved-workspaces-across-local-backends",
          "title": "Saved workspaces across Local backends",
          "ids": [
            "F03.workspace-dropdown-search"
          ],
          "body": "Warm the Local picker with QA_A_ONLY, close its dialog, switch through `backend-selector >> role=option[name=\"Connected QA_Secondary\"]`, then reopen the picker within60seconds, without reload or hard navigation. Expected: only QA_B_ONLY from the active server registry is offered. Known failure (reproduced 2026-10-12, [#16844](https://github.com/OpenHands/OpenHands/issues/16844)): the recorded2.7-second switch keeps QA_A_ONLY in the secondary picker while independent API readbacks show distinct registries. The same-absolute-parent directory variant requires isolated filesystems; two native runs share a host filesystem, so it and Windows remain unverified. These checks used frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, Agent Server/SDK1.54.0, automation1.19.3, run `backfill-18309-coordinator-checks` plus its owned `backfill-18309-local-secondary`. Retain `evidence/F03.selection-after-reload/16950-submission-readback.json`, `evidence/F03.workspace-dropdown-search/16844-local-switch.mp4` and registry readback; remove the registered backend/workspaces and stop both runs afterward.",
          "raw": "**Saved workspaces across Local backends (`F03.workspace-dropdown-search`).** Warm the Local picker with QA_A_ONLY, close its dialog, switch through `backend-selector >> role=option[name=\"Connected QA_Secondary\"]`, then reopen the picker within60seconds, without reload or hard navigation. Expected: only QA_B_ONLY from the active server registry is offered. Known failure (reproduced 2026-10-12, [#16844](https://github.com/OpenHands/OpenHands/issues/16844)): the recorded2.7-second switch keeps QA_A_ONLY in the secondary picker while independent API readbacks show distinct registries. The same-absolute-parent directory variant requires isolated filesystems; two native runs share a host filesystem, so it and Windows remain unverified. These checks used frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, Agent Server/SDK1.54.0, automation1.19.3, run `backfill-18309-coordinator-checks` plus its owned `backfill-18309-local-secondary`. Retain `evidence/F03.selection-after-reload/16950-submission-readback.json`, `evidence/F03.workspace-dropdown-search/16844-local-switch.mp4` and registry readback; remove the registered backend/workspaces and stop both runs afterward.",
          "source_line": 143,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16844](https://github.com/OpenHands/OpenHands/issues/16844)): the recorded2.7-second switch keeps QA_A_ONLY in the secondary picker while independent API readbacks show distinct registries. The same-absolute-parent directory variant requires isolated filesystems; two native runs share a host filesystem, so it and Windows remain unverified. These checks used frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, Agent Server/SDK1.54.0, automation1.19.3, run `backfill-18309-coordinator-checks` plus its owned `backfill-18309-local-secondary`. Retain `evidence/F03.selection-after-reload/16950-submission-readback.json`, `evidence/F03.workspace-dropdown-search/16844-local-switch.mp4` and registry readback; remove the registered backend/workspaces and stop both runs afterward."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F03--invalid-project-sub-agent",
          "title": "Invalid project sub-agent",
          "ids": [
            "F03.launch-in-workspace"
          ],
          "body": "In an owned workspace create `.agents/agents/qa-bad-skill.md` with YAML frontmatter `name: qa-bad-skill`, `description: Bad skill`, `skills: qa-f27-missing-skill`, then body `Body`, without installing that skill. Use an explicit dummy profile pointed at a refused loopback endpoint with autotitle off for this pre-inference check. On Home use `control-openhands workspace open <owned-folder>`, select `testid=workspace-mode-selector-option-local_repo`, fill `testid=chat-input` and click `testid=home-chat-launcher >> testid=submit-button` with `--expect-url \"/conversations/[^/]+$\"`. The invalid sub-agent is skipped with a warning; creation returns201 and opens its conversation instead of500. A later refused-loopback model error is separate from creation. Read `browser network --filter \"api/conversations($|\\?)\"`, capture `browser screenshot --feature F03.create-error-toast --name invalid-subagent`, then remove only the owned bad-agent file.",
          "raw": "**Invalid project sub-agent (`F03.launch-in-workspace`).** In an owned workspace create `.agents/agents/qa-bad-skill.md` with YAML frontmatter `name: qa-bad-skill`, `description: Bad skill`, `skills: qa-f27-missing-skill`, then body `Body`, without installing that skill. Use an explicit dummy profile pointed at a refused loopback endpoint with autotitle off for this pre-inference check. On Home use `control-openhands workspace open <owned-folder>`, select `testid=workspace-mode-selector-option-local_repo`, fill `testid=chat-input` and click `testid=home-chat-launcher >> testid=submit-button` with `--expect-url \"/conversations/[^/]+$\"`. The invalid sub-agent is skipped with a warning; creation returns201 and opens its conversation instead of500. A later refused-loopback model error is separate from creation. Read `browser network --filter \"api/conversations($|\\?)\"`, capture `browser screenshot --feature F03.create-error-toast --name invalid-subagent`, then remove only the owned bad-agent file.",
          "source_line": 145,
          "expected": [
            "submitting with a workspace picked shows `Creating conversation…`, opens `/conversations/<id>`, and the agent runs in that folder."
          ],
          "notes": [
            "Known issue [#15701](https://github.com/OpenHands/OpenHands/issues/15701) (`F03.launch-in-workspace`): Creating Conversation toast stuck visible. Live prerequisite: The original stuck creation request/outcome sequence; the report is intermittent and gives no deterministic trigger."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--contract-f03-open-repository-dialog",
          "title": "Open repository dialog",
          "ids": [
            "F03.open-repository-dialog"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "on a Cloud backend, **Open repository** picks provider, repository and branch (blocked locally)."
          ],
          "notes": [
            "Known issue [#17108](https://github.com/OpenHands/OpenHands/issues/17108) (`F03.open-repository-dialog`): The repository picker lists a search result for input the user has already cleared. Live prerequisite: An authenticated Cloud Git provider and a real repository search response that remains pending after clearing the input.",
            "Known issue [#18480](https://github.com/OpenHands/OpenHands/issues/18480) (`F03.open-repository-dialog`): an Azure DevOps Enterprise user belonging to several organizations can see only one organization's repositories. Live prerequisite: a real Cloud/Enterprise sign-in with two organizations and distinct repositories; the report's mocked two-organization capture is not a real-backend reproduction."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F03--contract-f03-isolated-workspace-notice",
          "title": "Isolated workspace notice",
          "ids": [
            "F03.isolated-workspace-notice"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "on an isolated-runtime backend a status line explains the new isolated workspace and offers **Clear host workspace** (blocked locally)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F03--contract-f03-workspaces-unsupported",
          "title": "Workspaces unsupported",
          "ids": [
            "F03.workspaces-unsupported"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "on an Agent Server without workspace APIs the pill is disabled with a tooltip (blocked locally). Automations on home:"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F03--contract-f03-recommended-prompt-launch",
          "title": "Recommended prompt launch",
          "ids": [
            "F03.recommended-prompt-launch"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "a card with no setup form and no missing integration creates a conversation that sends the automation prompt (blocked locally)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F03--contract-f03-home-automations-view-more",
          "title": "Home automations view more",
          "ids": [
            "F03.home-automations-view-more"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "past 10 rows **View more**/**View all**, past 6 pins **View more**/**View less** (not arranged)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F04",
      "title": "Conversation list and folders",
      "file": "F04-conversation-list.md",
      "sha256": "58cf085074029b7ced5eb8a1f81f26ad71b3253d4161c7ea7344a706669a7861",
      "overview": "The expanded sidebar always shows the user's conversations under a\n**Conversations** header. Each row opens its conversation and carries a pin and\na ⋮ menu (Rename, Edit tags, Stop, Download, Archive or Unarchive, Delete). The\nheader has a folder-plus picker that starts a conversation in a chosen\nworkspace and a **View** menu with layout presets, tag filters, **More\noptions** (organize, sort, scope, archived, age cutoff, automation filter, row\nmetadata) and **Delete all**. Grouped by workspace, folders collapse, preview\nfive rows and start new conversations in place. Preferences, pins, archives and\nthe workspace each conversation was started in live in this browser's\nlocalStorage.",
      "entry": "- Always visible in the expanded sidebar on every page (`/`, the direct URL `/conversations`, `/conversations/<id>`, settings pages): the **Conversations** section under New Chat / Customize / Automate. At phone width it is inside the drawer opened by `sidebar-mobile-menu-toggle`.\n- Header: folder-plus (`conversation-panel-new-thread-picker`, aria \"Create thread folder — choose workspace or repository\"), **View** (`conversation-layouts-toggle`), and in grouped mode the **Conversations** label itself (`conversations-header-toggle`).\n- Row hover: pin (`conversation-pin-toggle-<id>`) and ⋮ (`ellipsis-button`, aria \"More options\"), which opens `context-menu`. A row with more tags than fit shows a `+N` chip (`conversation-card-tag-overflow`). The Pinned section has its own More/Less (`conversation-panel-pinned-view-more`).\n- View → **More options** (`advanced-options-row`) opens `advanced-conversation-options-modal` (Organize, Sort by, Threads `scope-all`/`scope-relevant`, Additional, Automations, Metadata).\n- Grouped mode: folder heading (`thread-folder-drag-<suffix>`), folder `+` (aria \"Start new conversation in <folder>\"), folder **More/Less** (`thread-folder-view-more-<suffix>`).\n- No command-menu entry or keyboard shortcut exists for the list.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done), desktop viewport, `deepseek-flash` active for the one prompted conversation.\n- `F04.empty-state` and the disabled Delete all are first checked on a run with no conversations (a fresh `launch --new`), and again after `F04.delete-all`.\n- Workspaces: `control-openhands fixture git-repo --name qa-f04-repo` and `control-openhands fixture folder --name qa-f04-folder`, then add both through the picker (F03 owns the folder browser): `control-openhands browser click 'testid=conversation-panel-new-thread-picker'`, `control-openhands browser click 'testid=add-workspaces-button'`, `control-openhands browser click 'testid=folder-browser-up'` twice (from `<run>/private/home` to the run directory; check with `browser text 'testid=folder-browser-current-path'`), `control-openhands browser dblclick 'testid=folder-browser-entry-workspace'`, `control-openhands browser click 'testid=folder-browser-add-all-subdirs'`. The picker popover stays open afterwards; `control-openhands browser click 'testid=conversation-panel-new-thread-picker'` closes it.\n- A finished conversation, started right after the View menu bullet (the first two bullets need a run with no conversations): `control-openhands conversation start --prompt \"Reply with only the word pong. Do not run any tools.\" --wait --timeout 240`. The model writes the title (`✅ Reply only with pong`, `✅ Reply with pong only`, ...): select its row with `has-text=pong`.\n- `F04.load-more` and `F04.folder-preview-more` need more than 20 conversations, at least 6 in one folder. Empty conversations from the folder `+` cost no model call (see `F04.folder-new-conversation`).\n- `F04.cloud-picker` and `F04.start-task-cards` are blocked: they need an OpenHands Cloud backend with a connected git provider. The kept rows of `F04.automation-filter` (Only automation runs with real runs) and the hiding effect of `F04.hide-older` need data a fresh run cannot have (an automation-run conversation; a conversation older than one hour).",
      "gotchas": "- A plain `--expect-url '/conversations/'` returns at once when the browser is already on a conversation page. Start creation clicks from `/` (`browser goto /`), and never navigate away before the click returns: leaving early skips the client-side metadata write, so the new conversation lands in **No workspace** although the server has its working directory.\n- Workspace grouping, pins, archives and preferences live in this browser's localStorage (`openhands-agent-server-conversation-metadata`, `pinned-conversations`, `archived-conversations`, `conversation-panel-preferences`). `browser reset` loses them: archived rows reappear and every conversation moves to No workspace.\n- Folder test ids embed the sanitized absolute workspace path (`thread-folder-ws--tmp-...-qa-f04-repo`). Select with `[data-testid^=\"thread-folder-ws-\"][data-testid$=\"<folder>\"]` or by the `+` button's accessible name.\n- Row links carry `?backend=default-local`; anchor URL regexes without `$`.\n- Hover and ⋮ need the row on screen: `browser scroll '<row>'` first, and `browser hover` before clicking `ellipsis-button` or the pin.\n- Escape closes the ⋮ menu and returns focus to its ⋮ button (#18044); an outside `browser mouse-click` closes it too. If Tab has moved focus to the next row's link, that row's preview tooltip opens and takes the first Escape, so press Escape twice. Inside the phone drawer the first Escape closes the menu and the next one the drawer.\n- The Stop, Delete and Delete all dialogs name their buttons `cancel-button` and `confirm-button`, like the Archive and Edit tags dialogs (#17915, fixed by #18151); `role=dialog >> role=button[name=\"...\"]` still works.\n- With the drawer open at phone width there are two `conversation-panel` elements; scope with `testid=sidebar-mobile-drawer >> ...`.\n- Recent is exactly the fresh default layout (By date, Updated, all threads, old conversations shown), so a new user sees Recent checked. Active and Compact also turn on Hide old conversations: Recent plus a manual Threads → Active only reads `More options · Custom`, not Active. Presets never change the hide-older cutoff.\n- In By workspace mode Load more often adds no visible row: later rows join existing folders behind their More button.\n- By workspace never shows `No conversations found` while any workspace is registered: empty workspace folders stay.\n- Pinned rows bypass tag and automation filters and suppress the filtered empty states; unpin before asserting them.\n- The page size is 20; filters, Active scope and archives apply only to loaded pages.\n- The ⋮ menu offers Stop for idle and finished conversations too (they count as active); it disappears only once paused.\n- `service stop agent-server` needs `control-openhands restart` and a `browser reload` before anything else works.\n- The list refreshes on an interval: a row keeps `conversation-status-working` and `Conversation <id5>` for up to about 40 s after the conversation finished. Use `browser wait ... --timeout 60000`, not an immediate `count`.\n- Model-written titles vary from run to run; match finished conversations on a word from the prompt (`has-text=pong`), never on the whole title.\n- Opening a conversation from the sidebar never changes its tags, also right after an automation setup conversation (one tagged `automationsetup: draft`): the other conversation keeps its tags and opens as a normal chat (recipe in F07 `F07.open-by-url`). A conversation that did get the tag loses it with Edit tags (`remove-tag-automationsetup`).\n- Known issue #17919: Delete all deletes only the loaded pages, and its dialog states that smaller count (`F04.delete-all`).\n- Known issue [#18155](https://github.com/OpenHands/OpenHands/issues/18155) (`F04.show-archived-unarchive`): Missing-runtime conversations shown as Archived but never hidden, chipped, or unarchivable. Live prerequisite: A Cloud sandbox or local runtime that becomes MISSING and non-resumable; ordinary local pause remains resumable.\n- Known issue [#17497](https://github.com/OpenHands/OpenHands/issues/17497) (`F04.card`): Corrupted conversation data breaks the entire conversation history list instead of handling individual broken conversations. Live prerequisite: The original corrupted persisted conversation or a deterministic corruption fixture after model-switch failure and fork.\n- Known issue [#17257](https://github.com/OpenHands/OpenHands/issues/17257) (`F04.start-task-cards`): Cloud provisioning has no in-progress start-task cards because the list hook always returns an empty array. Live prerequisite: An authenticated Cloud backend producing an in-progress sandbox/repository start task.\n- Known issue [#16750](https://github.com/OpenHands/OpenHands/issues/16750) (`F04.card`): Conversation list does not load while automation is running. Live prerequisite: A prompt automation or native delegated task that remains running during conversation-list reload; no usable model is configured here.\n- Known issue [#15946](https://github.com/OpenHands/OpenHands/issues/15946) (`F04.card`): Show conversations hidden by active/stale agent-server leases. Live prerequisite: A copied lease-bearing persisted conversation and second owner/expired-lease state; never alter another live run's state.\n- Known issue [OpenHands/software-agent-sdk#4987](https://github.com/OpenHands/software-agent-sdk/issues/4987) (`F04.card`): Autotitle input has broken user-message separators and emoji-list formatting; verify the resulting visible title with a real provider. Live prerequisite: A working autotitle model and captured title-generation input/output; Canvas only consumes the resulting server title.\n- Known issue [OpenHands/software-agent-sdk#4537](https://github.com/OpenHands/software-agent-sdk/issues/4537) (`F04.card`): A native delegated task can freeze the sidebar conversation list and cause request timeouts until it ends. Live prerequisite: A working model with native TaskTool delegation holding the parent state lock during list reload.\n- Known issue [OpenHands/software-agent-sdk#4080](https://github.com/OpenHands/software-agent-sdk/issues/4080) (`F04.card`): One unregistered persisted tool observation can make a whole conversation disappear from the list and return404. Live prerequisite: A copied persisted conversation with its original unregistered observation kind and missing tool-module registration.",
      "features": [
        {
          "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.automation-setup-tag",
          "description": "a conversation started from the automation setup page shows an `Automation setup: Draft` chip (lightning icon) first in its tag row; Filter by tags offers a facet and filter chip of the same name, and the hover preview lists `Automation setup` `draft`."
        },
        {
          "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 and closes the ⋮ menu. When the Agent Server is down behind a plain-text 502 ingress response, the toast uses the generic error fallback rather than raw transport text."
        },
        {
          "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>`."
        }
      ],
      "scenarios": [
        {
          "name": "F04--empty-list",
          "title": "Empty list",
          "ids": [
            "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`.",
          "raw": "**Empty list (`F04.empty-state`).** On a run with no conversations, run `control-openhands browser text 'testid=conversation-panel-empty-state'`. The text is `No conversations found`.",
          "source_line": 80,
          "expected": [
            "with no conversations (By date) the list shows `No conversations found`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--view-menu",
          "title": "View menu",
          "ids": [
            "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`).",
          "raw": "**View menu (`F04.view-menu`).** 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`).",
          "source_line": 81,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--start-in-a-workspace",
          "title": "Start in a workspace",
          "ids": [
            "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. Immediately after creating that empty workspace conversation, `control-openhands conversation status <id>` reads `idle`; inspecting the `conversation-status-active` class shows a green dot with no animation class. Known failure (reproduced 2026-10-12, [#16752](https://github.com/OpenHands/OpenHands/issues/16752)): this empty idle row retains active green styling instead of the expected neutral state; the older report's flashing animation was not observed.",
          "raw": "**Start in a workspace (`F04.new-thread-picker`).** 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. Immediately after creating that empty workspace conversation, `control-openhands conversation status <id>` reads `idle`; inspecting the `conversation-status-active` class shows a green dot with no animation class. Known failure (reproduced 2026-10-12, [#16752](https://github.com/OpenHands/OpenHands/issues/16752)): this empty idle row retains active green styling instead of the expected neutral state; the older report's flashing animation was not observed.",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16752](https://github.com/OpenHands/OpenHands/issues/16752)): this empty idle row retains active green styling instead of the expected neutral state; the older report's flashing animation was not observed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F04--row-opens-its-conversation",
          "title": "Row opens its conversation",
          "ids": [
            "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`).",
          "raw": "**Row opens its conversation (`F04.card`).** 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`).",
          "source_line": 83,
          "expected": [
            "a row shows a status dot, the title and the relative time, links to `/conversations/<id>`, and the open conversation's row is highlighted."
          ],
          "notes": [
            "Known issue [#17497](https://github.com/OpenHands/OpenHands/issues/17497) (`F04.card`): Corrupted conversation data breaks the entire conversation history list instead of handling individual broken conversations. Live prerequisite: The original corrupted persisted conversation or a deterministic corruption fixture after model-switch failure and fork.",
            "Known issue [#16750](https://github.com/OpenHands/OpenHands/issues/16750) (`F04.card`): Conversation list does not load while automation is running. Live prerequisite: A prompt automation or native delegated task that remains running during conversation-list reload; no usable model is configured here.",
            "Known issue [#15946](https://github.com/OpenHands/OpenHands/issues/15946) (`F04.card`): Show conversations hidden by active/stale agent-server leases. Live prerequisite: A copied lease-bearing persisted conversation and second owner/expired-lease state; never alter another live run's state.",
            "Known issue [OpenHands/software-agent-sdk#4987](https://github.com/OpenHands/software-agent-sdk/issues/4987) (`F04.card`): Autotitle input has broken user-message separators and emoji-list formatting; verify the resulting visible title with a real provider. Live prerequisite: A working autotitle model and captured title-generation input/output; Canvas only consumes the resulting server title.",
            "Known issue [OpenHands/software-agent-sdk#4537](https://github.com/OpenHands/software-agent-sdk/issues/4537) (`F04.card`): A native delegated task can freeze the sidebar conversation list and cause request timeouts until it ends. Live prerequisite: A working model with native TaskTool delegation holding the parent state lock during list reload.",
            "Known issue [OpenHands/software-agent-sdk#4080](https://github.com/OpenHands/software-agent-sdk/issues/4080) (`F04.card`): One unregistered persisted tool observation can make a whole conversation disappear from the list and return404. Live prerequisite: A copied persisted conversation with its original unregistered observation kind and missing tool-module registration."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F04--live-status",
          "title": "Live status",
          "ids": [
            "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).",
          "raw": "**Live status (`F04.card-live-status`).** 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).",
          "source_line": 84,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--group-by-workspace",
          "title": "Group by workspace",
          "ids": [
            "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).",
          "raw": "**Group by workspace (`F04.grouped-folders`).** 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).",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "known failure)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F04--collapse-one-folder",
          "title": "Collapse one folder",
          "ids": [
            "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.",
          "raw": "**Collapse one folder (`F04.folder-collapse`).** 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.",
          "source_line": 86,
          "expected": [
            "a folder heading collapses and expands that folder."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--collapse-all",
          "title": "Collapse all",
          "ids": [
            "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.",
          "raw": "**Collapse all (`F04.collapse-all`).** 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.",
          "source_line": 87,
          "expected": [
            "the Conversations header collapses every folder, then expands them all."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--new-conversation-in-a-folder",
          "title": "New conversation in a folder",
          "ids": [
            "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.",
          "raw": "**New conversation in a folder (`F04.folder-new-conversation`).** 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.",
          "source_line": 88,
          "expected": [
            "a folder's `+` starts a conversation in that workspace and opens it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--folder-preview",
          "title": "Folder preview",
          "ids": [
            "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`.",
          "raw": "**Folder preview (`F04.folder-preview-more`).** 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`.",
          "source_line": 89,
          "expected": [
            "a folder shows five rows; More shows every loaded row, Less returns to five."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--reorder-folders",
          "title": "Reorder folders",
          "ids": [
            "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.",
          "raw": "**Reorder folders (`F04.folder-reorder`).** 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.",
          "source_line": 90,
          "expected": [
            "dragging a folder heading onto another reorders the folders and the order persists."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--load-more",
          "title": "Load more",
          "ids": [
            "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.",
          "raw": "**Load more (`F04.load-more`).** 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.",
          "source_line": 91,
          "expected": [
            "20 conversations load first; Load more fetches the rest and disappears when nothing is left."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--organize",
          "title": "Organize",
          "ids": [
            "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.",
          "raw": "**Organize (`F04.organize`).** 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.",
          "source_line": 92,
          "expected": [
            "More options → Organize switches between folders (By workspace) and a flat list (By date); the choice survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--header-divider",
          "title": "Header divider",
          "ids": [
            "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`.",
          "raw": "**Header divider (`F04.list-header`).** 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`.",
          "source_line": 93,
          "expected": [
            "the header divider appears only while the list is scrolled."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--rename",
          "title": "Rename",
          "ids": [
            "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`).",
          "raw": "**Rename (`F04.card-rename`).** 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`).",
          "source_line": 94,
          "expected": [
            "⋮ → Rename edits the title inline; Enter saves, a toast confirms, and the title persists."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--sort",
          "title": "Sort",
          "ids": [
            "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`.",
          "raw": "**Sort (`F04.sort`).** 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`.",
          "source_line": 95,
          "expected": [
            "Sort by Updated or Created reorders the list."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--stop",
          "title": "Stop",
          "ids": [
            "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?`, and `control-openhands browser testids 'role=dialog'` lists `cancel-button` (`Cancel`) and `confirm-button` (`Confirm Close`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` keeps it idle (`control-openhands conversation status <id>`). Repeat and click `'role=dialog >> testid=confirm-button'`; `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`).",
          "raw": "**Stop (`F04.stop`).** 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?`, and `control-openhands browser testids 'role=dialog'` lists `cancel-button` (`Cancel`) and `confirm-button` (`Confirm Close`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` keeps it idle (`control-openhands conversation status <id>`). Repeat and click `'role=dialog >> testid=confirm-button'`; `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`).",
          "source_line": 96,
          "expected": [
            "⋮ → Stop Conversation asks for confirmation and pauses the conversation; the item is gone once paused."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--presets",
          "title": "Presets",
          "ids": [
            "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.",
          "raw": "**Presets (`F04.presets`).** 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.",
          "source_line": 97,
          "expected": [
            "a preset is checked and survives a reload; Active and Compact hide paused conversations; any manual change reads `More options · Custom`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--threads-scope",
          "title": "Threads scope",
          "ids": [
            "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.",
          "raw": "**Threads scope (`F04.thread-scope`).** 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.",
          "source_line": 98,
          "expected": [
            "More options → Threads → Active only hides paused conversations; All threads shows them again."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--hide-older",
          "title": "Hide older",
          "ids": [
            "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).",
          "raw": "**Hide older (`F04.hide-older`).** 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).",
          "source_line": 99,
          "expected": [
            "Hide old conversations offers four cutoffs, persists the choice and hides conversations older than it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--automation-filter",
          "title": "Automation filter",
          "ids": [
            "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.",
          "raw": "**Automation filter (`F04.automation-filter`).** 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.",
          "source_line": 100,
          "expected": [
            "More options → Automations shows all, hides or keeps only automation runs, with its own empty message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--row-metadata",
          "title": "Row metadata",
          "ids": [
            "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).",
          "raw": "**Row metadata (`F04.metadata-toggles`).** 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).",
          "source_line": 101,
          "expected": [
            "Repo and branch, Agent / model and Show tags add or remove row chips."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--hover-preview",
          "title": "Hover preview",
          "ids": [
            "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.",
          "raw": "**Hover preview (`F04.hover-preview`).** 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.",
          "source_line": 102,
          "expected": [
            "hovering a row for a second shows title, directory, model and creation time, unless Details on hover is off."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--pin",
          "title": "Pin",
          "ids": [
            "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`.",
          "raw": "**Pin (`F04.pin`).** 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`.",
          "source_line": 103,
          "expected": [
            "the pin moves a row into a Pinned section above the list, survives a reload and unpins from there."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--pinned-preview",
          "title": "Pinned preview",
          "ids": [
            "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.",
          "raw": "**Pinned preview (`F04.pinned-preview-more`).** 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.",
          "source_line": 104,
          "expected": [
            "the Pinned section previews five rows; its More shows every pinned row and Less returns to five."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--edit-tags",
          "title": "Edit tags",
          "ids": [
            "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` (it uses the `feedback-error` colour, #18330: `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=edit-tags-error]')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(192, 40, 45)` on Light+, readable on the light dialog; switch themes as in `F16.color-theme` and restore OpenHands-Neutral); 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: \"\"`.",
          "raw": "**Edit tags (`F04.edit-tags`).** 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` (it uses the `feedback-error` colour, #18330: `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=edit-tags-error]')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(192, 40, 45)` on Light+, readable on the light dialog; switch themes as in `F16.color-theme` and restore OpenHands-Neutral); 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: \"\"`.",
          "source_line": 105,
          "expected": [
            "⋮ → Edit tags adds and removes `key: value` tags with validation; Save shows `Tags updated` and the chips persist."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--tag-overflow",
          "title": "Tag overflow",
          "ids": [
            "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`).",
          "raw": "**Tag overflow (`F04.tag-overflow`).** 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`).",
          "source_line": 106,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--remove-tags",
          "title": "Remove tags",
          "ids": [
            "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.",
          "raw": "**Remove tags (`F04.edit-tags-remove`).** 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.",
          "source_line": 107,
          "expected": [
            "in Edit tags each row's remove button drops that tag; Save persists the removal on the server."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--tag-filter",
          "title": "Tag filter",
          "ids": [
            "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`.",
          "raw": "**Tag filter (`F04.tag-filter`).** 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`.",
          "source_line": 108,
          "expected": [
            "choosing a tag facet narrows the list to rows with that tag (pins exempt) and survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--filter-chips",
          "title": "Filter chips",
          "ids": [
            "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.",
          "raw": "**Filter chips (`F04.filter-chips`).** 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.",
          "source_line": 109,
          "expected": [
            "active filters show as chips above the list; a chip click removes one, Clear all removes every chip."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--automation-setup-tag",
          "title": "Automation setup tag",
          "ids": [
            "F04.automation-setup-tag"
          ],
          "body": "Arrange one setup conversation through the setup page (F22): `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`, then `control-openhands conversation start --stay --prompt 'Reply with only the word ok. Do not run any tools.' --wait --timeout 180` (one tiny model call; note `<setup-id>`). `control-openhands api GET /api/conversations/<setup-id> --pick tags` has `automationsetup: draft`. In the sidebar, `control-openhands browser text 'testid=conversation-card >> has-text=Reply >> testid=conversation-card-tag-chip'` is `Automation setup: Draft` (the first chip; the internal draft-id keys stay hidden). Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=tag-filters-section'`: the facet `testid=tag-facet-row-automationsetup=draft` reads `Automation setup: Draft`; click it, `control-openhands browser press Escape`, and `control-openhands browser text 'testid=conversation-active-tag-filters'` is `Automation setup: Draft` and `Clear all`, with only setup conversations (and pinned rows) left; `control-openhands browser click 'testid=active-tag-filter-automationsetup=draft'` removes it. After `control-openhands browser mouse-click 900 900`, `control-openhands browser tooltip 'testid=conversation-card >> has-text=Reply'` lists `Automation setup` and the raw value `draft` (`browser screenshot --feature F04.automation-setup-tag --name facet`). End with `control-openhands browser goto /`; the setup send leaves no Home draft, and opening another conversation from the sidebar leaves that conversation's tags alone (see Gotchas).",
          "raw": "**Automation setup tag (`F04.automation-setup-tag`).** Arrange one setup conversation through the setup page (F22): `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`, then `control-openhands conversation start --stay --prompt 'Reply with only the word ok. Do not run any tools.' --wait --timeout 180` (one tiny model call; note `<setup-id>`). `control-openhands api GET /api/conversations/<setup-id> --pick tags` has `automationsetup: draft`. In the sidebar, `control-openhands browser text 'testid=conversation-card >> has-text=Reply >> testid=conversation-card-tag-chip'` is `Automation setup: Draft` (the first chip; the internal draft-id keys stay hidden). Run `control-openhands browser click 'testid=conversation-layouts-toggle'`, `control-openhands browser click 'testid=tag-filters-section'`: the facet `testid=tag-facet-row-automationsetup=draft` reads `Automation setup: Draft`; click it, `control-openhands browser press Escape`, and `control-openhands browser text 'testid=conversation-active-tag-filters'` is `Automation setup: Draft` and `Clear all`, with only setup conversations (and pinned rows) left; `control-openhands browser click 'testid=active-tag-filter-automationsetup=draft'` removes it. After `control-openhands browser mouse-click 900 900`, `control-openhands browser tooltip 'testid=conversation-card >> has-text=Reply'` lists `Automation setup` and the raw value `draft` (`browser screenshot --feature F04.automation-setup-tag --name facet`). End with `control-openhands browser goto /`; the setup send leaves no Home draft, and opening another conversation from the sidebar leaves that conversation's tags alone (see Gotchas).",
          "source_line": 110,
          "expected": [
            "a conversation started from the automation setup page shows an `Automation setup: Draft` chip (lightning icon) first in its tag row; Filter by tags offers a facet and filter chip of the same name, and the hover preview lists `Automation setup` `draft`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--download",
          "title": "Download",
          "ids": [
            "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).",
          "raw": "**Download (`F04.download`).** 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).",
          "source_line": 111,
          "expected": [
            "⋮ → Download conversation data saves `conversation_<id>.zip`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--download-failure",
          "title": "Download failure",
          "ids": [
            "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: An error occurred`, and the menu is closed (`0`). The download request returned a plain-text ingress 502; `getApiErrorMessage` intentionally omits raw transport text and falls back to this generic message when the response body has no usable error field. `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.",
          "raw": "**Download failure (`F04.download-error`).** 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: An error occurred`, and the menu is closed (`0`). The download request returned a plain-text ingress 502; `getApiErrorMessage` intentionally omits raw transport text and falls back to this generic message when the response body has no usable error field. `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.",
          "source_line": 112,
          "expected": [
            "a failed download shows one toast and closes the ⋮ menu. When the Agent Server is down behind a plain-text 502 ingress response, the toast uses the generic error fallback rather than raw transport text."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--archive",
          "title": "Archive",
          "ids": [
            "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.",
          "raw": "**Archive (`F04.archive`).** 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.",
          "source_line": 113,
          "expected": [
            "⋮ → Archive Conversation asks for confirmation, hides and unpins the row, and leaves the conversation if it was open."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--show-archived-and-unarchive",
          "title": "Show archived and Unarchive",
          "ids": [
            "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.",
          "raw": "**Show archived and Unarchive (`F04.show-archived-unarchive`).** 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.",
          "source_line": 114,
          "expected": [
            "Show archived brings archived rows back with an `Archived` chip; Unarchive restores a row without confirmation."
          ],
          "notes": [
            "Known issue [#18155](https://github.com/OpenHands/OpenHands/issues/18155) (`F04.show-archived-unarchive`): Missing-runtime conversations shown as Archived but never hidden, chipped, or unarchivable. Live prerequisite: A Cloud sandbox or local runtime that becomes MISSING and non-resumable; ordinary local pause remains resumable."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F04--delete",
          "title": "Delete",
          "ids": [
            "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.` with `cancel-button` and `confirm-button`. `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(\\?|$)'`; after `browser reload` the row count is `0` and `control-openhands api GET /api/conversations/<id>` is `404`.",
          "raw": "**Delete (`F04.delete`).** 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.` with `cancel-button` and `confirm-button`. `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(\\?|$)'`; after `browser reload` the row count is `0` and `control-openhands api GET /api/conversations/<id>` is `404`.",
          "source_line": 115,
          "expected": [
            "⋮ → Delete Conversation asks for confirmation, deletes it on the server and leaves it if it was open."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F04.phone`).** 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`.",
          "source_line": 116,
          "expected": [
            "at 390 px the list lives in the sidebar drawer, fits the viewport, and picking a row closes the drawer."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--a-later-page-pin",
          "title": "A later-page pin",
          "ids": [
            "F04.pin"
          ],
          "body": "With more than 20 conversations as the pagination precondition already arranges, choose Recent, reload, click `role=button[name=\"Load more\"]`, and pin a conversation from the newly loaded rows (`browser hover` its `conversation-card`, then click its `[data-testid^=conversation-pin-toggle-]`). Read the pinned section, then `control-openhands browser reload` and `control-openhands browser count 'testid=conversation-panel-pinned-section'`. Known failure (reproduced 2026-10-12, [#16399](https://github.com/OpenHands/OpenHands/issues/16399)): the later-page pin is pruned on first-page reload even though the conversation still exists. Tested with 23 empty conversations and one older pin.",
          "raw": "**A later-page pin (`F04.pin`).** With more than 20 conversations as the pagination precondition already arranges, choose Recent, reload, click `role=button[name=\"Load more\"]`, and pin a conversation from the newly loaded rows (`browser hover` its `conversation-card`, then click its `[data-testid^=conversation-pin-toggle-]`). Read the pinned section, then `control-openhands browser reload` and `control-openhands browser count 'testid=conversation-panel-pinned-section'`. Known failure (reproduced 2026-10-12, [#16399](https://github.com/OpenHands/OpenHands/issues/16399)): the later-page pin is pruned on first-page reload even though the conversation still exists. Tested with 23 empty conversations and one older pin.",
          "source_line": 117,
          "expected": [
            "the pin moves a row into a Pinned section above the list, survives a reload and unpins from there."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16399](https://github.com/OpenHands/OpenHands/issues/16399)): the later-page pin is pruned on first-page reload even though the conversation still exists. Tested with 23 empty conversations and one older pin."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F04--fresh-browser-grouping",
          "title": "Fresh-browser grouping",
          "ids": [
            "F04.grouped-folders"
          ],
          "body": "Run this after the other list checks: select By workspace, record its groups, `control-openhands browser reset`, `control-openhands onboard --skip`, then `control-openhands browser click 'testid=conversation-layouts-toggle'` and `control-openhands browser click 'testid=layout-preset-by-workspace'`. Compare `browser snapshot 'testid=conversation-panel'` with `conversation status <id>`. Known failure (reproduced 2026-10-12, [#17386](https://github.com/OpenHands/OpenHands/issues/17386)): existing conversations move to No workspace although their server working directory still matches the registered workspace. The reset clears browser metadata; do not use it before checks that depend on the original browser's grouping.",
          "raw": "**Fresh-browser grouping (`F04.grouped-folders`).** Run this after the other list checks: select By workspace, record its groups, `control-openhands browser reset`, `control-openhands onboard --skip`, then `control-openhands browser click 'testid=conversation-layouts-toggle'` and `control-openhands browser click 'testid=layout-preset-by-workspace'`. Compare `browser snapshot 'testid=conversation-panel'` with `conversation status <id>`. Known failure (reproduced 2026-10-12, [#17386](https://github.com/OpenHands/OpenHands/issues/17386)): existing conversations move to No workspace although their server working directory still matches the registered workspace. The reset clears browser metadata; do not use it before checks that depend on the original browser's grouping.",
          "source_line": 118,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#17386](https://github.com/OpenHands/OpenHands/issues/17386)): existing conversations move to No workspace although their server working directory still matches the registered workspace. The reset clears browser metadata; do not use it before checks that depend on the original browser's grouping."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F04--delete-all",
          "title": "Delete all",
          "ids": [
            "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 (`role=dialog >> testid=cancel-button`) keeps every row. Confirm with `control-openhands browser click 'role=dialog >> testid=confirm-button'` 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).",
          "raw": "**Delete all (`F04.delete-all`).** 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 (`role=dialog >> testid=cancel-button`) keeps every row. Confirm with `control-openhands browser click 'role=dialog >> testid=confirm-button'` 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).",
          "source_line": 119,
          "expected": [
            "View → Delete all asks for confirmation with a count and deletes every conversation."
          ],
          "notes": [
            "Known issue #17919: Delete all deletes only the loaded pages, and its dialog states that smaller count (`F04.delete-all`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F04--collapsed-rail",
          "title": "Collapsed rail",
          "ids": [
            "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`.",
          "raw": "**Collapsed rail (`F04.collapsed-rail`).** 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`.",
          "source_line": 120,
          "expected": [
            "the collapsed sidebar rail shows no conversation list; expanding brings it back."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F04--cloud-picker-and-start-tasks",
          "title": "Cloud picker and start tasks",
          "ids": [
            "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>`.",
          "raw": "**Cloud picker and start tasks (`F04.cloud-picker`, `F04.start-task-cards`).** 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>`.",
          "source_line": 121,
          "expected": [
            "on a Cloud backend the folder-plus picker shows provider tabs and a repository search; a picked repository starts a conversation.",
            "on a Cloud backend in-progress start tasks show as cards linking to `/conversations/task-<id>`."
          ],
          "notes": [
            "Known issue [#17257](https://github.com/OpenHands/OpenHands/issues/17257) (`F04.start-task-cards`): Cloud provisioning has no in-progress start-task cards because the list hook always returns an empty array. Live prerequisite: An authenticated Cloud backend producing an in-progress sandbox/repository start task."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F04--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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.",
          "raw": "**Cleanup.** 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.",
          "source_line": 123,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        }
      ]
    },
    {
      "id": "F05",
      "title": "Composer, slash commands and plan mode",
      "file": "F05-composer.md",
      "sha256": "7180bf9a1dc1967d236f3354ffa88fa3cc91a469e6b8a9d5d858529a55741730",
      "overview": "The composer is the input box at the bottom of the home page and of every\nconversation. A user types a message (Enter sends, Shift+Enter adds a line),\nattaches files and images, picks the LLM profile for the running conversation,\nwatches and compacts the context window, stops and resumes the agent, and types\n`/` for built-in commands (`/btw`, `/model`, `/goal`, `/plan`, `/code`) and\nskills. `/plan` hands the next message to a planning agent whose PLAN.md card\ncan be viewed and built; Ctrl/Cmd+Enter builds it.",
      "entry": "- Home page composer (`/`) and the composer of any conversation (`/conversations/<id>`, reached from the sidebar conversation list or by URL).\n- `+` (More actions, `chat-plus-button`) inside the composer opens the tools menu; its last item **Add Files and Images** opens the native file picker (`upload-image-input`).\n- Model pill (`chat-input-llm-profile`) next to `+`; at 320 px it moves into the vertical-dots **More input actions** button. On the home page it names the default profile (the one Settings → LLM marks as default); in a conversation it names the profile that conversation runs on.\n- Right side of the composer: status text with Stop/Play, context ring (`context-window-meter`), mic (`chat-dictation-button`), Send (`submit-button`).\n- Typing `/` at the start of a word opens the slash menu (`slash-command-menu`); `/plan` and `/code` are the only mode switches on the local backend (the Code/Plan pill is Cloud only).\n- Keyboard: Enter sends, Shift+Enter newline, Ctrl+Enter / Cmd+Enter builds a plan; Escape closes the slash menu.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` (profiles `deepseek-flash` active and `deepseek-pro`). Every recipe except `F05.agent-profile-switch`, the home draft, `F05.image-only-send` and `F05.profile-identity` needs a model. Without a DeepSeek key, `QA_DUMMY_KEY=qa control-openhands llm set --profile qa-zeta --model openai/gpt-4o --api-key-env QA_DUMMY_KEY --no-validate` stands in for the active profile: conversations can still be created (the agent errors at once), which is all those two bullets need, and every bullet that reads a reply is `blocked`.\n- `F05.profile-identity` needs two profiles on one model: its bullet creates `qa-alpha` and `qa-zeta` with `llm set --no-validate` right before it and deletes them afterwards.\n- A conversation to type into: `control-openhands conversation start --prompt \"Reply with only: ok\" --wait --timeout 240` prints `id` (`<id>` below) and leaves the browser on `/conversations/<id>`.\n- Attachment fixtures: `control-openhands fixture image --name qa-image` and `control-openhands fixture file --name qa-note.txt --content \"QA attachment 7731\"` (both land in `$OH_VERIFY_RUN/evidence/_fixtures/`).\n- Desktop viewport unless a bullet says otherwise. Microphone dictation is blocked in the sandbox browser (no microphone); `+` > Git tools needs a conversation on a git workspace (not driven here).",
      "gotchas": "- Always export your own `OH_VERIFY_RUN`. `launch --new` repoints the shared `current` symlink, and another agent relying on `current` drove and then stopped this family's stack mid-run. If the URL jumps to a page you never opened or `fetch failed` appears, run `control-openhands status` before blaming the product.\n- `conversation wait <id>` right after sending returns at once with the previous `finished` status. Use `conversation wait <id> --fresh`; `--until running` then a terminal wait only works for turns longer than the poll interval (a `Reply with only` turn on deepseek-flash can finish first, and `--until running` then times out).\n- The slash menu stays open while you type a command; press Escape before Enter or Enter selects the highlighted option instead of sending.\n- Conversation drafts are saved 500 ms after the last keystroke and flushed on navigation and unmount, so text typed just before leaving comes back.\n- Escape closes the `+` tools menu (`browser count 'testid=tools-context-menu'` is `0`) and returns focus to `chat-plus-button`, whether focus was on the trigger or on a menu item. A menu left open covers attachments in screenshots. Escape also closes the narrow-width \"More input actions\" overflow menu (`browser count 'testid=chat-input-overflow-menu'` is `0`, the trigger's `aria-expanded` is `false`) and returns focus to the trigger, whether focus was on the trigger, on `overflow-model-button` or on a profile row of the open Model submenu (#18174).\n- After a client-side switch between conversations (a sidebar row, or a user-message branch in `F06.branch-from-here`) Send keeps the previous conversation's state (Known failure, [#18202](https://github.com/OpenHands/OpenHands/issues/18202); [draft fix PR #18203](https://github.com/OpenHands/OpenHands/pull/18203)). Switching from an empty composer to one with a saved draft restores the text next to a disabled Send; this is a race (Send came up enabled in 1 of 7 tries), so one enabled reading does not show the bug is gone. Switching from a composer with text to an empty one leaves Send enabled, and clicking it does nothing. Typing or `browser reload` fixes it, and Enter in the field sends the restored text anyway. Read `browser enabled 'testid=submit-button'` after a reload, or after typing.\n- Expected: a prompt that a suggestion chip (`testid=chat-suggestions`) or a `+` > **Macros** item writes into the composer enables Send at once. Known failure (reproduced 2026-10-09, [#18202](https://github.com/OpenHands/OpenHands/issues/18202)): on a new No workspace conversation, `browser click 'testid=chat-suggestions >> text=Fix README'` (or `browser click 'testid=chat-plus-button'`, `browser hover 'testid=macros-button'`, `browser click 'testid=fix-readme-button'`) fills the field, but `browser enabled 'testid=submit-button'` is `false` (5 of 5 tries at desktop; a race at phone) until a character is typed. Both paths call `setMessageToSend`, which writes the text without an input event.\n- Uploaded chips and thumbnails have no test ids. Their remove buttons are named `Remove <file name>` (file chip) and `Remove image` (thumbnail), and Send, Stop and Play read `Send message`, `Stop` and `Resume the agent task` (#18033); select by those names, or by text/alt.\n- `browser upload` feeds the hidden input directly. To drive the real path, `+` > Add Files and Images, use `control-openhands browser upload-via 'testid=add-files-and-images-button' <file>`, which answers the native file chooser.\n- The home composer has no `interactive-chat-box` wrapper: scope its chips and thumbnails with `testid=home-chat-launcher`.\n- `conversation events` rows show a message's text and its attachment count (`images: 1` for the image-only message, whose text is a blank); `--grep 'data:image/png;base64'` searches whole events and quotes the `image` content block in `excerpts`.\n- A user row with `images: 1` proves that the image reached the Agent Server, not the model. Only the reply proves that the model saw it. On agent-server 1.53.0 and older, `deepseek/*` profiles drop the image before the LLM call (`F05.image-only-send`). An image sent with no text then leaves deepseek-flash with an empty task. On 2026-10-08 it ran `env`, read the run directory and other files on the host, and decoded the picture from the event store with PIL to answer. Pause such a conversation (`control-openhands conversation pause <id>`), and use the question-plus-image send for the model check.\n- The pill resolves the conversation's stamped profile before the user-level default, so profiles that share a model are told apart by name. The stamp lives in this browser's localStorage (`openhands-agent-server-conversation-metadata`), not on the server (`api GET /api/conversations/<id>` has no profile name): read it with `browser eval`, not `api GET`.\n- The `Switched to profile` notes are client-side and gone after reload; assert the pill and `conversation status`.\n- `/plan` messages go to a separate planner conversation; the browser stays on the parent `/conversations/<id>`, so wait on the planner id from `conversation list`.\n- Only the newest goal status offers `Resume` (#18036): once a resumed goal has posted a newer status, the old `interrupted` row shows no button, also after a reload. Count `testid=goal-resume` after the resumed goal ends; while it runs the count is `0` anyway.\n- Opening the context popover logs a 404 for `/api/llm/balance` on the local backend in `browser errors --app-only`; it does not break the popover.\n- `--observe` takes the verbs' selector syntax: `testid=` and plain CSS segments are watched by an in-page MutationObserver (`observedBy` `mutation`), while `role=`, `text=` and other engines are polled every 20 ms (`poll-20ms`), which misses a state shorter than that; prefer a `testid=` or CSS form for anything brief.\n- At `narrow` (320 px) the overflow menu's Model submenu opens to the right of the menu and runs off screen: each profile row starts at x about 270 with width 210 (`browser bbox 'testid=chat-input-llm-profile-option-deepseek-flash'`, `insideViewport` `false`), so both names read `deepse…`. Pick profiles by test id: the click lands on the strip of about 50 px that stays on screen. Known issue: #18063 (filed from the follow-up noted in #18015; the submenu also runs past the bottom edge, so the LLM Profiles link cannot be clicked at `narrow`) (`F05.overflow-menu`).\n- Known issue [#17788](https://github.com/OpenHands/OpenHands/issues/17788) (`F05.attach-files`): Cloud attachment upload rejects valid runtime data when cached fields are empty strings. Live prerequisite: A Cloud sandbox whose cached runtime URL/key are blank while the fresh runtime lookup is already ready.\n- Known issue [#17621](https://github.com/OpenHands/OpenHands/issues/17621) (`F05.context-window-meter`): Compact context always fails after exactly 60s — client-side timeout aborts a condensation the server then completes. Live prerequisite: A real summarizing model and conversation whose condensation takes longer than 60 seconds.\n- Known issue [#17476](https://github.com/OpenHands/OpenHands/issues/17476) (`F05.context-window-meter`): Planning socket stats overwrite the main conversation's context meter. Live prerequisite: A real planning sub-conversation and main conversation with distinct nonzero usage stats.\n- Known issue [#17429](https://github.com/OpenHands/OpenHands/issues/17429) (`F05.slash-btw`): 500 error with /btw. Live prerequisite: The exact pending tool/streaming state that triggers the generator-pickle error, plus a working model for the side question.\n- Known issue [#17271](https://github.com/OpenHands/OpenHands/issues/17271) (`F05.context-window-meter`): New chat retains context/state from the previous conversation. Live prerequisite: A completed conversation with nonzero token usage before New Chat; a dummy-key run supplies no genuine context usage.\n- Known issue [#17263](https://github.com/OpenHands/OpenHands/issues/17263) (`F05.context-window-meter`): Context meter stays pinned after a successful compaction once the condenser has its own usage entry. Live prerequisite: A real successful compaction whose condenser usage remains larger than the default agent usage.\n- Known issue [#16851](https://github.com/OpenHands/OpenHands/issues/16851) (`F05.profile-identity`): wrong model is displayed when loading existing conversation. Live prerequisite: The original conversation/model-switch history and usage record showing Opus with a Fable selector; a dummy-key conversation has no usage evidence.\n- Known issue [#16835](https://github.com/OpenHands/OpenHands/issues/16835) (`F05.context-window-meter`): Agent Canvas sidebar shows \"Cache Hit: 0\" while backend correctly records nonzero cache_read_tokens. Live prerequisite: A working prompt-caching model/proxy and a real conversation reporting nonzero cache_read_tokens.\n- Known issue [#16430](https://github.com/OpenHands/OpenHands/issues/16430) (`F05.attach-files`): File upload does not have an indication of progress. Live prerequisite: A remote or throttled real upload that stays in flight long enough to inspect progress and composer state.\n- Known issue [#16339](https://github.com/OpenHands/OpenHands/issues/16339) (`F05.attach-files`): Agent not informed of file location for files uploaded via canvas in cloud. Live prerequisite: An authenticated Cloud upload sandbox and working model to inspect the delivered filename/path notification and read the uploaded file.\n- Known issue [#15782](https://github.com/OpenHands/OpenHands/issues/15782) (`F05.draft-persistence`): Prompt disappears when a conversation is stopped. Live prerequisite: Clarify whether prompt means unsent composer text or the already-sent history message, and capture that state before sidebar Stop.\n- Known issue [OpenHands/software-agent-sdk#5639](https://github.com/OpenHands/software-agent-sdk/issues/5639) (`F05.llm-profile-picker`): A profile switched before the first message can be absent from token/cost statistics after its successful turn. Live prerequisite: A working model, a profile switch before the first run, and nonzero usage/cost after that turn.\n- Known issue [OpenHands/software-agent-sdk#5627](https://github.com/OpenHands/software-agent-sdk/issues/5627) (`F05.slash-goal`): After backend restart the goal chip can remain Running while the conversation is paused, and Stop does not clear it. Live prerequisite: A working model goal loop active during graceful backend restart, then goal-status and resume readback.\n- Known issue [OpenHands/software-agent-sdk#5523](https://github.com/OpenHands/software-agent-sdk/issues/5523) (`F05.image-only-send`): DeepSeek V4.1 Flash images can be sent to another fallback profile because its vision capability is not detected. Live prerequisite: A working DeepSeek V4.1 Flash vision profile and another fallback profile, with an image-bearing turn.\n- Known issue [OpenHands/software-agent-sdk#5492](https://github.com/OpenHands/software-agent-sdk/issues/5492) (`F05.slash-goal`): A goal can stop as Complete when its judge replies with complete as the string false. Live prerequisite: A real goal judge response containing complete as the string false rather than a JSON boolean.\n- Known issue [OpenHands/software-agent-sdk#5330](https://github.com/OpenHands/software-agent-sdk/issues/5330) (`F05.slash-btw`): A /btw side question can prevent reuse of the normal conversation prompt cache. Live prerequisite: A working caching provider and comparable ordinary-message versus /btw prompt/cache records.\n- Known issue [OpenHands/software-agent-sdk#4498](https://github.com/OpenHands/software-agent-sdk/issues/4498) (`F05.context-window-meter`): Compaction can leave an orphaned tool call/result in the live history and make the next provider request fail. Live prerequisite: A real condensation splitting an action/result pair, followed by the next provider request.\n- Known issue [OpenHands/software-agent-sdk#4382](https://github.com/OpenHands/software-agent-sdk/issues/4382) (`F05.context-window-meter`): ACP cost can miss cache/thought usage, silently become zero, or inflate after stalled/reset cost updates. Live prerequisite: An authenticated ACP provider emitting cache/thought token usage or reset/stalled cumulative cost updates.\n\n- Known issue [#17239](https://github.com/OpenHands/OpenHands/issues/17239) (`F05.llm-profile-picker`): The reported model/profile picker can open above the viewport with unreachable rows. A frozen-target Local check with eight LLM profiles passed on Home at 1512x720, 1440x1000, 390x844 and 320x700, and in a genuine conversation at 1512x720 and 390x844: internal scrolling and selecting the first/last profile worked. The Cloud screenshot and real ACP multi-model picker remain unverified; they need an approved Cloud session or ACP account/adapter with a multi-row model catalogue. Local profile geometry is not proof of those branches. Run `backfill-18309-settings-worker`, Agent Server/SDK 1.54.0; evidence includes `F05.llm-profile-picker/17239-conversation-1512x720.png` and `17239-conversation-phone.png`.\n\n- Known issue [OpenHands/software-agent-sdk#5824](https://github.com/OpenHands/software-agent-sdk/issues/5824) (`F05.slash-goal`): resuming after a failed judge audit can show iteration 2 of a maximum of 1 and run beyond the selected goal cap. Live prerequisite: an agent/judge run whose audit fails, followed by a successful incomplete judge verdict after Resume; a controller-only test does not establish the Canvas banner outcome.\n\n- Known issue [OpenHands/software-agent-sdk#5889](https://github.com/OpenHands/software-agent-sdk/issues/5889) (`F05.attach-files`): a same-name attachment upload that fails part-way can replace the previous workspace file with partial content. Live prerequisite: an owned isolated backend with a controlled mid-upload filesystem failure (the original Linux file-size limit or an equivalent bounded child-process limit), a pre-existing target and a real Canvas overwrite. Never change resource limits on a shared runtime; a normal successful upload does not verify failure atomicity.\n\n- Known issue [OpenHands/software-agent-sdk#5820](https://github.com/OpenHands/software-agent-sdk/issues/5820) (`F05.context-window-meter`): manual compaction on a null/no-op condenser can return500. Verification is blocked by genuine populated usage/context metrics: idle and refused-loopback runs leave `usage=null`, so neither composer nor Usage panel offers Compact context. A separate direct endpoint check returned500 but is not a Canvas reproduction. Use an existing real conversation with usage and no condenser, or an authorized successful provider run; never fabricate stats to expose the action.",
      "features": [
        {
          "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; Escape closes the menu and returns focus to its trigger."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F05--type-and-send",
          "title": "Type and send",
          "ids": [
            "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`.",
          "raw": "**Type and send (`F05.send-message`).** 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`.",
          "source_line": 60,
          "expected": [
            "Send is disabled while the field is empty; Shift+Enter inserts a newline; Enter sends, clears the field and the message persists after reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--draft-survives-navigation",
          "title": "Draft survives navigation",
          "ids": [
            "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. A draft typed just before leaving is flushed, not lost: `control-openhands browser type 'testid=chat-input' ' plus fast'` and at once `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url settings`, `control-openhands browser back`: the eval reads `QA draft text plus fast`. The same holds for an immediate sidebar switch to another conversation and back (that other conversation's composer stays empty). Clear with `control-openhands browser fill 'testid=chat-input' ''` and wait a second before navigating.",
          "raw": "**Draft survives navigation (`F05.draft-persistence`).** 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. A draft typed just before leaving is flushed, not lost: `control-openhands browser type 'testid=chat-input' ' plus fast'` and at once `control-openhands browser click 'testid=backend-selector-settings-link' --expect-url settings`, `control-openhands browser back`: the eval reads `QA draft text plus fast`. The same holds for an immediate sidebar switch to another conversation and back (that other conversation's composer stays empty). Clear with `control-openhands browser fill 'testid=chat-input' ''` and wait a second before navigating.",
          "source_line": 61,
          "expected": [
            "unsent text is restored per conversation (after navigation and reload) and on the home composer."
          ],
          "notes": [
            "Known issue [#15782](https://github.com/OpenHands/OpenHands/issues/15782) (`F05.draft-persistence`): Prompt disappears when a conversation is stopped. Live prerequisite: Clarify whether prompt means unsent composer text or the already-sent history message, and capture that state before sidebar Stop."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--home-draft",
          "title": "Home draft",
          "ids": [
            "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`. The automation setup page's docked composer (`/automations/setup`, F22) keeps no draft and never touches the Home one. With `QA home draft` still in the Home composer, run `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'` and `control-openhands browser wait 'testid=chat-input'`: the `innerText` eval is empty. `control-openhands browser type 'testid=chat-input' 'Reply with only the word ok2. Do not run any tools.'`, then `control-openhands browser eval \"new Promise(r=>setTimeout(()=>r(sessionStorage.getItem('oh:home-prompt-draft')),1500))\"` still returns `QA home draft`. `control-openhands browser click 'testid=submit-button' --expect-url '/conversations/'` and `control-openhands browser click 'testid=sidebar-conversations-link' --expect-url '/conversations(\\?|$)'`: the Home composer shows `QA home draft`. Clear it (`fill 'testid=chat-input' ''`, wait a second) and repeat the setup send: New Chat is empty and `browser storage --session --values` has no `oh:home-prompt-draft`. Unsent text in the setup composer is lost when the user leaves that page (by design).",
          "raw": "**Home draft (`F05.draft-persistence`).** 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`. The automation setup page's docked composer (`/automations/setup`, F22) keeps no draft and never touches the Home one. With `QA home draft` still in the Home composer, run `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'` and `control-openhands browser wait 'testid=chat-input'`: the `innerText` eval is empty. `control-openhands browser type 'testid=chat-input' 'Reply with only the word ok2. Do not run any tools.'`, then `control-openhands browser eval \"new Promise(r=>setTimeout(()=>r(sessionStorage.getItem('oh:home-prompt-draft')),1500))\"` still returns `QA home draft`. `control-openhands browser click 'testid=submit-button' --expect-url '/conversations/'` and `control-openhands browser click 'testid=sidebar-conversations-link' --expect-url '/conversations(\\?|$)'`: the Home composer shows `QA home draft`. Clear it (`fill 'testid=chat-input' ''`, wait a second) and repeat the setup send: New Chat is empty and `browser storage --session --values` has no `oh:home-prompt-draft`. Unsent text in the setup composer is lost when the user leaves that page (by design).",
          "source_line": 62,
          "expected": [
            "unsent text is restored per conversation (after navigation and reload) and on the home composer."
          ],
          "notes": [
            "Known issue [#15782](https://github.com/OpenHands/OpenHands/issues/15782) (`F05.draft-persistence`): Prompt disappears when a conversation is stopped. Live prerequisite: Clarify whether prompt means unsent composer text or the already-sent history message, and capture that state before sidebar Stop."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--auto-resize",
          "title": "Auto-resize",
          "ids": [
            "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).",
          "raw": "**Auto-resize (`F05.composer-resize`).** 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).",
          "source_line": 63,
          "expected": [
            "the field grows with its content and shrinks when cleared; a top-edge grip lets the user drag the height."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--tools-menu-and-macros",
          "title": "Tools menu and macros",
          "ids": [
            "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`.",
          "raw": "**Tools menu and macros (`F05.plus-menu`).** 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`.",
          "source_line": 64,
          "expected": [
            "`+` 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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--show-dialogs",
          "title": "Show dialogs",
          "ids": [
            "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`.",
          "raw": "**Show dialogs (`F05.plus-menu`).** 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`.",
          "source_line": 65,
          "expected": [
            "`+` 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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--agent-profile-before-start",
          "title": "Agent profile before start",
          "ids": [
            "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`.",
          "raw": "**Agent profile before start (`F05.agent-profile-switch`).** 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`.",
          "source_line": 66,
          "expected": [
            "before a conversation starts, `+` > Switch agent profile lists agent profiles and links to Manage agent profiles; it is absent in a started conversation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--attach-a-file-and-an-image",
          "title": "Attach a file and an image",
          "ids": [
            "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>`. Attachments added on Home before the conversation exists upload after it is created: with no workspace or a Local Repo workspace they land in it (`browser network --filter upload` shows `200`). Expected: the same in New Worktree mode. Known failure (reproduced 2026-10-16, SDK 1.55.0, [#18421](https://github.com/OpenHands/OpenHands/issues/18421)): run `control-openhands fixture git-repo --name qa-repo`, `control-openhands browser goto /`, `control-openhands workspace open qa-repo`, `control-openhands browser click 'testid=workspace-mode-selector'`, `control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'`, `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-note.txt\"`, type `Reply with only the word received. Do not run any tools.` and `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+$'`: `control-openhands browser toasts --history` shows `HTTP request failed (422 Unprocessable Entity): {\"detail\":\"path must be inside the conversation workspace\"}` and `browser network --filter upload` a `422` for `?path=<run>/workspace/qa-repo/qa-note.txt`, while `conversation status <id>` reports the worktree `/tmp/conversation-worktrees/<id>/qa-repo`; the message is sent without the file. The same file attached inside that conversation afterwards uploads to the worktree (`200`). Restore with `testid=workspace-mode-selector-option-local_repo`.\n  ```sh known-bug F05.attach-files home-new-worktree\n  control-openhands browser viewport desktop\n  control-openhands fixture git-repo --name qa-repo\n  control-openhands fixture file --name qa-note.txt --content \"QA attachment 7731\"\n  control-openhands browser goto /\n  control-openhands browser fill 'testid=chat-input' ''\n  control-openhands workspace open qa-repo\n  control-openhands browser click 'testid=workspace-mode-selector'\n  control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'\n  control-openhands assert --field text --matches 'New Worktree' -- browser text 'testid=home-git-control-bar-preview'\n  control-openhands browser click 'testid=chat-plus-button'\n  control-openhands browser upload-via 'testid=add-files-and-images-button' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\"\n  control-openhands browser type 'testid=chat-input' 'Reply with only the word received. Do not run any tools.'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+$'\n  control-openhands browser wait 'testid=user-message' --timeout 30000\n  control-openhands browser eval \"new Promise(r=>setTimeout(r,5000))\"\n  control-openhands browser screenshot --feature F05.attach-files --name home-new-worktree\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 422 -- browser network --filter file/upload  # bug\n  ```",
          "raw": "**Attach a file and an image (`F05.attach-files`).** 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>`. Attachments added on Home before the conversation exists upload after it is created: with no workspace or a Local Repo workspace they land in it (`browser network --filter upload` shows `200`). Expected: the same in New Worktree mode. Known failure (reproduced 2026-10-16, SDK 1.55.0, [#18421](https://github.com/OpenHands/OpenHands/issues/18421)): run `control-openhands fixture git-repo --name qa-repo`, `control-openhands browser goto /`, `control-openhands workspace open qa-repo`, `control-openhands browser click 'testid=workspace-mode-selector'`, `control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'`, `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-note.txt\"`, type `Reply with only the word received. Do not run any tools.` and `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+$'`: `control-openhands browser toasts --history` shows `HTTP request failed (422 Unprocessable Entity): {\"detail\":\"path must be inside the conversation workspace\"}` and `browser network --filter upload` a `422` for `?path=<run>/workspace/qa-repo/qa-note.txt`, while `conversation status <id>` reports the worktree `/tmp/conversation-worktrees/<id>/qa-repo`; the message is sent without the file. The same file attached inside that conversation afterwards uploads to the worktree (`200`). Restore with `testid=workspace-mode-selector-option-local_repo`.\n  ```sh known-bug F05.attach-files home-new-worktree\n  control-openhands browser viewport desktop\n  control-openhands fixture git-repo --name qa-repo\n  control-openhands fixture file --name qa-note.txt --content \"QA attachment 7731\"\n  control-openhands browser goto /\n  control-openhands browser fill 'testid=chat-input' ''\n  control-openhands workspace open qa-repo\n  control-openhands browser click 'testid=workspace-mode-selector'\n  control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'\n  control-openhands assert --field text --matches 'New Worktree' -- browser text 'testid=home-git-control-bar-preview'\n  control-openhands browser click 'testid=chat-plus-button'\n  control-openhands browser upload-via 'testid=add-files-and-images-button' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\"\n  control-openhands browser type 'testid=chat-input' 'Reply with only the word received. Do not run any tools.'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+$'\n  control-openhands browser wait 'testid=user-message' --timeout 30000\n  control-openhands browser eval \"new Promise(r=>setTimeout(r,5000))\"\n  control-openhands browser screenshot --feature F05.attach-files --name home-new-worktree\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 422 -- browser network --filter file/upload  # bug\n  ```",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17788](https://github.com/OpenHands/OpenHands/issues/17788) (`F05.attach-files`): Cloud attachment upload rejects valid runtime data when cached fields are empty strings. Live prerequisite: A Cloud sandbox whose cached runtime URL/key are blank while the fresh runtime lookup is already ready.",
            "Known issue [#16430](https://github.com/OpenHands/OpenHands/issues/16430) (`F05.attach-files`): File upload does not have an indication of progress. Live prerequisite: A remote or throttled real upload that stays in flight long enough to inspect progress and composer state.",
            "Known issue [#16339](https://github.com/OpenHands/OpenHands/issues/16339) (`F05.attach-files`): Agent not informed of file location for files uploaded via canvas in cloud. Live prerequisite: An authenticated Cloud upload sandbox and working model to inspect the delivered filename/path notification and read the uploaded file.",
            "Known issue [OpenHands/software-agent-sdk#5889](https://github.com/OpenHands/software-agent-sdk/issues/5889) (`F05.attach-files`): a same-name attachment upload that fails part-way can replace the previous workspace file with partial content. Live prerequisite: an owned isolated backend with a controlled mid-upload filesystem failure (the original Linux file-size limit or an equivalent bounded child-process limit), a pre-existing target and a real Canvas overwrite. Never change resource limits on a shared runtime; a normal successful upload does not verify failure atomicity."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-16, SDK 1.55.0, [#18421](https://github.com/OpenHands/OpenHands/issues/18421)): run `control-openhands fixture git-repo --name qa-repo`, `control-openhands browser goto /`, `control-openhands workspace open qa-repo`, `control-openhands browser click 'testid=workspace-mode-selector'`, `control-openhands browser click 'testid=workspace-mode-selector-option-new_worktree'`, `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-note.txt\"`, type `Reply with only the word received. Do not run any tools.` and `control-openhands browser click 'testid=home-chat-launcher >> testid=submit-button' --expect-url '/conversations/[^/]+$'`: `control-openhands browser toasts --history` shows `HTTP request failed (422 Unprocessable Entity): {\"detail\":\"path must be inside the conversation workspace\"}` and `browser network --filter upload` a `422` for `?path=<run>/workspace/qa-repo/qa-note.txt`, while `conversation status <id>` reports the worktree `/tmp/conversation-worktrees/<id>/qa-repo`; the message is sent without the file. The same file attached inside that conversation afterwards uploads to the worktree (`200`). Restore with `testid=workspace-mode-selector-option-local_repo`."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--size-limit",
          "title": "Size limit",
          "ids": [
            "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`.",
          "raw": "**Size limit (`F05.attach-size-limit`).** 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`.",
          "source_line": 88,
          "expected": [
            "a file over 3 MB, or a selection over 3 MB in total, is refused with an error toast."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--image-alone",
          "title": "Image alone",
          "ids": [
            "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).",
          "raw": "**Image alone (`F05.image-only-send`).** 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).",
          "source_line": 89,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5523](https://github.com/OpenHands/software-agent-sdk/issues/5523) (`F05.image-only-send`): DeepSeek V4.1 Flash images can be sent to another fallback profile because its vision capability is not detected. Live prerequisite: A working DeepSeek V4.1 Flash vision profile and another fallback profile, with an image-bearing turn."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--paste-text",
          "title": "Paste text",
          "ids": [
            "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.",
          "raw": "**Paste text (`F05.paste`).** 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.",
          "source_line": 90,
          "expected": [
            "pasted clipboard text is inserted as plain text; pasted images and dropped files attach."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--dictation-failure",
          "title": "Dictation failure",
          "ids": [
            "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.",
          "raw": "**Dictation failure (`F05.dictation`).** 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.",
          "source_line": 91,
          "expected": [
            "the mic button dictates into the composer; without a microphone it toasts a failure and resets."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F05--switch-llm-profile",
          "title": "Switch LLM profile",
          "ids": [
            "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'`.",
          "raw": "**Switch LLM profile (`F05.llm-profile-picker`).** 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'`.",
          "source_line": 92,
          "expected": [
            "the model pill switches the running conversation's LLM profile; the next reply uses the new model."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5639](https://github.com/OpenHands/software-agent-sdk/issues/5639) (`F05.llm-profile-picker`): A profile switched before the first message can be absent from token/cost statistics after its successful turn. Live prerequisite: A working model, a profile switch before the first run, and nonzero usage/cost after that turn.",
            "Known issue [#17239](https://github.com/OpenHands/OpenHands/issues/17239) (`F05.llm-profile-picker`): The reported model/profile picker can open above the viewport with unreachable rows. A frozen-target Local check with eight LLM profiles passed on Home at 1512x720, 1440x1000, 390x844 and 320x700, and in a genuine conversation at 1512x720 and 390x844: internal scrolling and selecting the first/last profile worked. The Cloud screenshot and real ACP multi-model picker remain unverified; they need an approved Cloud session or ACP account/adapter with a multi-row model catalogue. Local profile geometry is not proof of those branches. Run `backfill-18309-settings-worker`, Agent Server/SDK 1.54.0; evidence includes `F05.llm-profile-picker/17239-conversation-1512x720.png` and `17239-conversation-phone.png`."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--profile-identity",
          "title": "Profile identity",
          "ids": [
            "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>`.",
          "raw": "**Profile identity (`F05.profile-identity`).** 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>`.",
          "source_line": 93,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16851](https://github.com/OpenHands/OpenHands/issues/16851) (`F05.profile-identity`): wrong model is displayed when loading existing conversation. Live prerequisite: The original conversation/model-switch history and usage record showing Opus with a Fable selector; a dummy-key conversation has no usage evidence."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--overflow-menu",
          "title": "Overflow menu",
          "ids": [
            "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`). Escape closes it too: record it with `control-openhands browser record start --feature F05.overflow-menu --name escape-narrow`, open the menu, `control-openhands browser press Escape` and `control-openhands browser wait 'testid=chat-input-overflow-menu' --state detached --timeout 3000`; `browser attr 'role=button[name=\"More input actions\"]' aria-expanded` is `false` and `control-openhands browser eval \"document.activeElement.getAttribute('aria-label')\"` is `More input actions`. Repeat with focus on a menu item (`control-openhands browser focus 'testid=overflow-model-button'` before Escape) and with the Model submenu open (`browser focus 'testid=chat-input-llm-profile-option-deepseek-flash'`): the same result. `control-openhands browser record stop --gif`. Return with `control-openhands browser viewport desktop`.",
          "raw": "**Overflow menu (`F05.overflow-menu`).** 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`). Escape closes it too: record it with `control-openhands browser record start --feature F05.overflow-menu --name escape-narrow`, open the menu, `control-openhands browser press Escape` and `control-openhands browser wait 'testid=chat-input-overflow-menu' --state detached --timeout 3000`; `browser attr 'role=button[name=\"More input actions\"]' aria-expanded` is `false` and `control-openhands browser eval \"document.activeElement.getAttribute('aria-label')\"` is `More input actions`. Repeat with focus on a menu item (`control-openhands browser focus 'testid=overflow-model-button'` before Escape) and with the Model submenu open (`browser focus 'testid=chat-input-llm-profile-option-deepseek-flash'`): the same result. `control-openhands browser record stop --gif`. Return with `control-openhands browser viewport desktop`.",
          "source_line": 94,
          "expected": [
            "at very narrow widths the model pill collapses into a \"More input actions\" menu whose Model submenu fits the viewport and switches the profile; Escape closes the menu and returns focus to its trigger."
          ],
          "notes": [
            "At `narrow` (320 px) the overflow menu's Model submenu opens to the right of the menu and runs off screen: each profile row starts at x about 270 with width 210 (`browser bbox 'testid=chat-input-llm-profile-option-deepseek-flash'`, `insideViewport` `false`), so both names read `deepse…`. Pick profiles by test id: the click lands on the strip of about 50 px that stays on screen. Known issue: #18063 (filed from the follow-up noted in #18015; the submenu also runs past the bottom edge, so the LLM Profiles link cannot be clicked at `narrow`) (`F05.overflow-menu`)."
          ],
          "annotations": [
            "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`). Escape closes it too: record it with `control-openhands browser record start --feature F05.overflow-menu --name escape-narrow`, open the menu, `control-openhands browser press Escape` and `control-openhands browser wait 'testid=chat-input-overflow-menu' --state detached --timeout 3000`; `browser attr 'role=button[name=\"More input actions\"]' aria-expanded` is `false` and `control-openhands browser eval \"document.activeElement.getAttribute('aria-label')\"` is `More input actions`. Repeat with focus on a menu item (`control-openhands browser focus 'testid=overflow-model-button'` before Escape) and with the Model submenu open (`browser focus 'testid=chat-input-llm-profile-option-deepseek-flash'`): the same result. `control-openhands browser record stop --gif`. Return with `control-openhands browser viewport desktop`."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F05--context-window",
          "title": "Context window",
          "ids": [
            "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.",
          "raw": "**Context window (`F05.context-window-meter`).** 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.",
          "source_line": 95,
          "expected": [
            "the ring shows context use; its popover opens the Usage tab and compacts the context."
          ],
          "notes": [
            "Known issue [#17621](https://github.com/OpenHands/OpenHands/issues/17621) (`F05.context-window-meter`): Compact context always fails after exactly 60s — client-side timeout aborts a condensation the server then completes. Live prerequisite: A real summarizing model and conversation whose condensation takes longer than 60 seconds.",
            "Known issue [#17476](https://github.com/OpenHands/OpenHands/issues/17476) (`F05.context-window-meter`): Planning socket stats overwrite the main conversation's context meter. Live prerequisite: A real planning sub-conversation and main conversation with distinct nonzero usage stats.",
            "Known issue [#17271](https://github.com/OpenHands/OpenHands/issues/17271) (`F05.context-window-meter`): New chat retains context/state from the previous conversation. Live prerequisite: A completed conversation with nonzero token usage before New Chat; a dummy-key run supplies no genuine context usage.",
            "Known issue [#17263](https://github.com/OpenHands/OpenHands/issues/17263) (`F05.context-window-meter`): Context meter stays pinned after a successful compaction once the condenser has its own usage entry. Live prerequisite: A real successful compaction whose condenser usage remains larger than the default agent usage.",
            "Known issue [#16835](https://github.com/OpenHands/OpenHands/issues/16835) (`F05.context-window-meter`): Agent Canvas sidebar shows \"Cache Hit: 0\" while backend correctly records nonzero cache_read_tokens. Live prerequisite: A working prompt-caching model/proxy and a real conversation reporting nonzero cache_read_tokens.",
            "Known issue [OpenHands/software-agent-sdk#4498](https://github.com/OpenHands/software-agent-sdk/issues/4498) (`F05.context-window-meter`): Compaction can leave an orphaned tool call/result in the live history and make the next provider request fail. Live prerequisite: A real condensation splitting an action/result pair, followed by the next provider request.",
            "Known issue [OpenHands/software-agent-sdk#4382](https://github.com/OpenHands/software-agent-sdk/issues/4382) (`F05.context-window-meter`): ACP cost can miss cache/thought usage, silently become zero, or inflate after stalled/reset cost updates. Live prerequisite: An authenticated ACP provider emitting cache/thought token usage or reset/stalled cumulative cost updates.",
            "Known issue [OpenHands/software-agent-sdk#5820](https://github.com/OpenHands/software-agent-sdk/issues/5820) (`F05.context-window-meter`): manual compaction on a null/no-op condenser can return500. Verification is blocked by genuine populated usage/context metrics: idle and refused-loopback runs leave `usage=null`, so neither composer nor Usage panel offers Compact context. A separate direct endpoint check returned500 but is not a Canvas reproduction. Use an existing real conversation with usage and no condenser, or an authorized successful provider run; never fabricate stats to expose the action."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--stop-and-resume-with-a-queued-follow-up",
          "title": "Stop and resume with a queued follow-up",
          "ids": [
            "F05.stop-resume",
            "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`.",
          "raw": "**Stop and resume (`F05.stop-resume`) with a queued follow-up (`F05.queued-message`).** 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`.",
          "source_line": 96,
          "expected": [
            "Stop pauses a running agent (status `Stopped`, Play button); Play resumes it.",
            "a follow-up typed while the agent runs is accepted and answered."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--slash-menu",
          "title": "Slash menu",
          "ids": [
            "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.",
          "raw": "**Slash menu (`F05.slash-menu`).** 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.",
          "source_line": 97,
          "expected": [
            "`/` opens a filtered command list (built-ins and skills) with arrow, Tab/Enter and Escape handling."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--model",
          "title": "`/model`",
          "ids": [
            "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`.",
          "raw": "**`/model` (`F05.slash-model`).** 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`.",
          "source_line": 98,
          "expected": [
            "`/model` lists saved profiles; `/model <name>` switches profile."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--btw",
          "title": "`/btw`",
          "ids": [
            "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`.",
          "raw": "**`/btw` (`F05.slash-btw`).** 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`.",
          "source_line": 99,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#17429](https://github.com/OpenHands/OpenHands/issues/17429) (`F05.slash-btw`): 500 error with /btw. Live prerequisite: The exact pending tool/streaming state that triggers the generator-pickle error, plus a working model for the side question.",
            "Known issue [OpenHands/software-agent-sdk#5330](https://github.com/OpenHands/software-agent-sdk/issues/5330) (`F05.slash-btw`): A /btw side question can prevent reuse of the normal conversation prompt cache. Live prerequisite: A working caching provider and comparable ordinary-message versus /btw prompt/cache records."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--goal",
          "title": "`/goal`",
          "ids": [
            "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`.",
          "raw": "**`/goal` (`F05.slash-goal`).** 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`.",
          "source_line": 100,
          "expected": [
            "`/goal [--max N] <objective>` runs a judged goal loop with a status banner, Stop and Resume; only the newest goal status offers Resume."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5627](https://github.com/OpenHands/software-agent-sdk/issues/5627) (`F05.slash-goal`): After backend restart the goal chip can remain Running while the conversation is paused, and Stop does not clear it. Live prerequisite: A working model goal loop active during graceful backend restart, then goal-status and resume readback.",
            "Known issue [OpenHands/software-agent-sdk#5492](https://github.com/OpenHands/software-agent-sdk/issues/5492) (`F05.slash-goal`): A goal can stop as Complete when its judge replies with complete as the string false. Live prerequisite: A real goal judge response containing complete as the string false rather than a JSON boolean.",
            "Known issue [OpenHands/software-agent-sdk#5824](https://github.com/OpenHands/software-agent-sdk/issues/5824) (`F05.slash-goal`): resuming after a failed judge audit can show iteration 2 of a maximum of 1 and run beyond the selected goal cap. Live prerequisite: an agent/judge run whose audit fails, followed by a successful incomplete judge verdict after Resume; a controller-only test does not establish the Canvas banner outcome."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--plan-and-code",
          "title": "`/plan` and `/code`",
          "ids": [
            "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).",
          "raw": "**`/plan` and `/code` (`F05.slash-plan-code`).** 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).",
          "source_line": 101,
          "expected": [
            "`/plan` switches to plan mode (a planner sub-conversation), `/code` switches back."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--plan-card",
          "title": "Plan card",
          "ids": [
            "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 ⌘↩`).",
          "raw": "**Plan card (`F05.plan-preview`).** 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 ⌘↩`).",
          "source_line": 102,
          "expected": [
            "the planner's Plan.md card shows the plan with Read more, View (Planner tab) and Build."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--build-shortcut",
          "title": "Build shortcut",
          "ids": [
            "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>`.",
          "raw": "**Build shortcut (`F05.build-plan-shortcut`).** 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>`.",
          "source_line": 103,
          "expected": [
            "Ctrl/Cmd+Enter in plan mode builds the plan with the code agent."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--plan-task-and-code-task",
          "title": "`/plan <task>` and `/code <task>`",
          "ids": [
            "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).",
          "raw": "**`/plan <task>` and `/code <task>` (`F05.slash-plan-code-task`).** 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).",
          "source_line": 104,
          "expected": [
            "`/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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--build-button",
          "title": "Build button",
          "ids": [
            "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.",
          "raw": "**Build button (`F05.plan-preview`).** 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.",
          "source_line": 105,
          "expected": [
            "the planner's Plan.md card shows the plan with Read more, View (Planner tab) and Build."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F05.phone`).** 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`.",
          "source_line": 106,
          "expected": [
            "the composer fits a 390 px viewport with every control inline."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F05--home-file-re-pick",
          "title": "Home file re-pick",
          "ids": [
            "F05.attach-files"
          ],
          "body": "As a separate check after the conversation recipes, run `control-openhands browser goto /`, upload the file fixture with `control-openhands browser upload 'testid=upload-image-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\"`, remove it with `control-openhands browser click 'role=button[name=\"Remove qa-note.txt\"]'`, upload that same path again, then `control-openhands browser count 'role=button[name=\"Remove qa-note.txt\"]'`. Expected: `1`. Known failure (reproduced 2026-10-12, [#17471](https://github.com/OpenHands/OpenHands/issues/17471)): the second pick is ignored and the count is `0`. End with `browser reload` to clear the file input.",
          "raw": "**Home file re-pick (`F05.attach-files`).** As a separate check after the conversation recipes, run `control-openhands browser goto /`, upload the file fixture with `control-openhands browser upload 'testid=upload-image-input' \"$OH_VERIFY_RUN/evidence/_fixtures/qa-note.txt\"`, remove it with `control-openhands browser click 'role=button[name=\"Remove qa-note.txt\"]'`, upload that same path again, then `control-openhands browser count 'role=button[name=\"Remove qa-note.txt\"]'`. Expected: `1`. Known failure (reproduced 2026-10-12, [#17471](https://github.com/OpenHands/OpenHands/issues/17471)): the second pick is ignored and the count is `0`. End with `browser reload` to clear the file input.",
          "source_line": 108,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17788](https://github.com/OpenHands/OpenHands/issues/17788) (`F05.attach-files`): Cloud attachment upload rejects valid runtime data when cached fields are empty strings. Live prerequisite: A Cloud sandbox whose cached runtime URL/key are blank while the fresh runtime lookup is already ready.",
            "Known issue [#16430](https://github.com/OpenHands/OpenHands/issues/16430) (`F05.attach-files`): File upload does not have an indication of progress. Live prerequisite: A remote or throttled real upload that stays in flight long enough to inspect progress and composer state.",
            "Known issue [#16339](https://github.com/OpenHands/OpenHands/issues/16339) (`F05.attach-files`): Agent not informed of file location for files uploaded via canvas in cloud. Live prerequisite: An authenticated Cloud upload sandbox and working model to inspect the delivered filename/path notification and read the uploaded file.",
            "Known issue [OpenHands/software-agent-sdk#5889](https://github.com/OpenHands/software-agent-sdk/issues/5889) (`F05.attach-files`): a same-name attachment upload that fails part-way can replace the previous workspace file with partial content. Live prerequisite: an owned isolated backend with a controlled mid-upload filesystem failure (the original Linux file-size limit or an equivalent bounded child-process limit), a pre-existing target and a real Canvas overwrite. Never change resource limits on a shared runtime; a normal successful upload does not verify failure atomicity."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#17471](https://github.com/OpenHands/OpenHands/issues/17471)): the second pick is ignored and the count is `0`. End with `browser reload` to clear the file input."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F05--unmatched-failed-conversation-profile",
          "title": "Unmatched failed-conversation profile",
          "ids": [
            "F05.profile-identity"
          ],
          "body": "Separate disposable check: record the original active profile with `control-openhands llm show`, then create an empty Local conversation with a new dummy profile pointing to closed loopback `http://127.0.0.1:9` (`llm set --no-validate`, as F06's dummy-profile precondition does); give it model `openai/gpt-4o`. After `browser reset` and `onboard --skip`, reopen it and send `Reply with only QA. Do not run any tools.`. Change the same saved profile to `openai/gpt-4o-mini` with `llm set --no-validate`, then reload and read `browser text 'testid=chat-input-llm-profile'` and `conversation status <id>`. Known failure (reproduced 2026-10-12, [#16263](https://github.com/OpenHands/OpenHands/issues/16263)): once the loopback turn errors, the pill reads Select a model despite a loaded active profile and the persisted conversation model. This check covers the missing browser stamp and unmatched-model case, not successful provider output. Reactivate the original profile with `control-openhands api POST /api/profiles/<original-profile>/activate --write`, then delete the disposable conversation and profile afterwards.",
          "raw": "**Unmatched failed-conversation profile (`F05.profile-identity`).** Separate disposable check: record the original active profile with `control-openhands llm show`, then create an empty Local conversation with a new dummy profile pointing to closed loopback `http://127.0.0.1:9` (`llm set --no-validate`, as F06's dummy-profile precondition does); give it model `openai/gpt-4o`. After `browser reset` and `onboard --skip`, reopen it and send `Reply with only QA. Do not run any tools.`. Change the same saved profile to `openai/gpt-4o-mini` with `llm set --no-validate`, then reload and read `browser text 'testid=chat-input-llm-profile'` and `conversation status <id>`. Known failure (reproduced 2026-10-12, [#16263](https://github.com/OpenHands/OpenHands/issues/16263)): once the loopback turn errors, the pill reads Select a model despite a loaded active profile and the persisted conversation model. This check covers the missing browser stamp and unmatched-model case, not successful provider output. Reactivate the original profile with `control-openhands api POST /api/profiles/<original-profile>/activate --write`, then delete the disposable conversation and profile afterwards.",
          "source_line": 109,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16851](https://github.com/OpenHands/OpenHands/issues/16851) (`F05.profile-identity`): wrong model is displayed when loading existing conversation. Live prerequisite: The original conversation/model-switch history and usage record showing Opus with a Fable selector; a dummy-key conversation has no usage evidence."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16263](https://github.com/OpenHands/OpenHands/issues/16263)): once the loopback turn errors, the pill reads Select a model despite a loaded active profile and the persisted conversation model. This check covers the missing browser stamp and unmatched-model case, not successful provider output. Reactivate the original profile with `control-openhands api POST /api/profiles/<original-profile>/activate --write`, then delete the disposable conversation and profile afterwards."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        }
      ]
    },
    {
      "id": "F06",
      "title": "Agent activity: messages, events and lifecycle",
      "file": "F06-agent-activity.md",
      "sha256": "4896fe659802fd4efea2135c5eefeab2e0fe0a69030249a46cb898bf94b99aae",
      "overview": "Inside a conversation the user watches the agent work: their own messages appear\nat once (optimistically), the agent's replies render as markdown, tool calls are\ngrouped into collapsible \"N actions completed\" blocks whose cards show commands,\noutputs, diffs and task lists, and a live chip says what the agent is doing right\nnow. Around the transcript sit lifecycle controls and states: the starting pill,\nstop and resume, the confirmation prompt when confirmation mode is on, error and\nreconnect banners, the no-LLM banner and the skill-installed banner. History\npages in from the server, survives reloads and backend restarts, and any message\ncan be branched into a new conversation.",
      "entry": "- Open any conversation: a sidebar conversation card, or the direct URL `/conversations/<id>`. Leaving one for the home page: the sidebar's **New Chat** link; coming back: its card again.\n- Start one from the home composer (**New Chat**, `/`): type and press Send. `control-openhands conversation start` drives exactly this path.\n- An empty conversation: the sidebar's create-thread button (`conversation-panel-new-thread-picker`), then **No workspace** (or a workspace).\n- Composer status control (right side of the composer): Stop while running, Play while stopped.\n- Hover a bubble: Branch from here and Copy to clipboard icons; hover a tool title for its timestamp.\n- Confirmation mode and the critic are switched on under Settings → Verification (`/settings/verification`; the page itself belongs to the settings families).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` (deepseek-flash active). Every prompt below is tiny and stays in the conversation's own workspace.\n- Conversation ids are printed as `\"id\"` by `control-openhands conversation start`, or read from `control-openhands browser url` (`/conversations/<id>`). `<id>` below is always the conversation the step just created or opened.\n- `F06.llm-not-configured-banner` needs a second, fresh `control-openhands launch --new --build never` with no `llm preset` (export its run dir as `OH_VERIFY_RUN` for those commands, then `control-openhands stop` it).\n- `F06.critic-result` is blocked: it needs a model and an OpenHands Cloud critic API key (Settings → Verification → Critic API Key); its bullet says what a scored run shows. The automatic-fetch half of `F06.load-older-history` (its 16-command conversation) needs a model; the scroll-to-top half is driven without one, on the dummy profile below, with thirty refused user messages. `F06.hook-events` needs a configured hook; none is set in a fresh run.\n- `F06.clock-skew-send` and `F06.failed-send-persists` need no model reply: a conversation that exists is enough (without a key the agent errors at once, and the user rows still land). Without a key, create the family's conversations with a dummy profile (`QA_DUMMY_KEY=qa control-openhands llm set --profile qa-zeta --model openai/gpt-4o --api-key-env QA_DUMMY_KEY --no-validate`) and `control-openhands conversation start --prompt ...` without `--wait`. `F06.corrective-nudge` is blocked: the nudge needs a model turn with neither text nor a tool call, which a normal prompt to deepseek-flash does not produce. When one happens, `control-openhands browser count 'testid=corrective-nudge-message'` is `1` and `browser count 'testid=user-message >> has-text=Your last response did not include'` is `0`.\n- The harness browser grants clipboard access: `control-openhands browser clipboard` reads what a Copy button wrote and `browser clipboard --write qa-empty` resets it first.\n- A terminal call can come back as \"The terminal session was reset...\" after this run's agent server restarts; the model reruns it, which turns one action into a group of two (see Gotchas).",
      "gotchas": "- `control-openhands conversation events <id>` returns the last 25 events unless you pass `--last N`; it reads the server's 100-event pages until `N` rows are in hand (`pages` says how many it read, `more: true` that older pages remain), so `--last 500` covers a 150-event conversation in two pages (`total` `152`, `pages` `2`, `more` `false` on the Older history conversation; `--last 25` there reads one page, `more` `true`) and `--from-start` reads from the oldest. `--kinds` and `--grep` filter before `--last` counts, and pages are read until `--last` matching rows are in hand: `--kinds MessageEvent --last 3` is the three newest messages, `--kinds MessageEvent --last 500` all thirty, and `--grep 'qa older 1'` reaches `qa older 1` on the oldest page with the default `--last` (`count` `11`, `pages` `2`). Event texts are cut at about 160 characters.\n- `--observe` takes the same selector syntax as the verbs: `testid=` and plain CSS segments (joined with ` >> `) are watched by an in-page MutationObserver (`observedBy` `mutation`; the Older history bullet's `--observe 'testid=loading-older-events'` is one), while other engines (`role=`, `text=`, `nth=`) are polled every 20 ms instead (not driven in this family).\n- `role=button[name=\"Expand\"]` also matches \"Expand thinking\" and \"Expand actions\"; use `[exact]` to target tool rows. Each click flips the row to Collapse, so `nth=0` walks to the next collapsed row.\n- Tool rows show no success icon; the only indicators are the `exit N` badge for non-zero exits and a clock icon (`status-icon`) for timeouts. A single action is not grouped: it renders as a bare row without `event-group`.\n- The model writes the tool titles and may append to commands (it turned `ls /missing` into `ls ...; echo \"EXIT:$?\"`, exit 0). Ask for the command \"verbatim, with nothing appended\" when the exit code matters.\n- The agent itself may open files with `canvas_ui_control` (a third action in the echo/file run). Close the file tab before proving that a path link opens it.\n- On Settings → Verification the Security Analyzer field (All tab) is shown only while Confirmation Mode is on: switch Confirmation Mode on first, then pick the analyzer. Check `api GET /api/settings` after saving.\n- Confirmation mode with the default Security Analyzer `LLM` uses ConfirmRisky(HIGH): harmless commands run without a prompt. Set the analyzer to `None` to confirm every action, and restore `LLM` afterwards. The composer stays editable while waiting (status \"User needed\"); the buttons read Cancel / Continue.\n- The confirm and cancel shortcuts accept Meta or Ctrl and ignore a held key's auto-repeat; once a response is sent the listener is detached, so a second press sends nothing (#18127). Hints read `Ctrl+↩` / `Ctrl+⇧+⌫` off Apple platforms.\n- Known failure, Canvas UI ([#18202](https://github.com/OpenHands/OpenHands/issues/18202), reproduced 2026-10-09; [draft fix PR #18203](https://github.com/OpenHands/OpenHands/pull/18203)): after a user-message branch the composer holds the prefilled text but Send stays disabled (`browser enabled 'testid=submit-button'` is `false`, and a click on it times out with `element is not enabled`). The prefill is a saved draft that `src/hooks/chat/use-draft-persistence.ts` writes into the field without an input event, and the composer is reused across the client-side switch, so its Send state keeps the source conversation's empty field. Editing the text, a reload, or Enter in the field (`browser press Enter --selector 'testid=chat-input'`) all work. A draft restored by a sidebar switch behaves the same (F05 Gotchas).\n- Known failure, likely Agent Server: every message in a branched conversation ends with `ConversationErrorEvent` `LLMAuthenticationError` (\"Your LLM API key appears to be invalid or has expired.\"), while the source conversation keeps working (OpenHands/software-agent-sdk#5465). Do not use branches as fixtures for other checks.\n- \"The terminal session was reset because the underlying tmux server/session disappeared\" (clock `status-icon` on the row) appears after a `service stop agent-server` / `restart` cycle. Since `launch` gives each run its own short `TMUX_TMPDIR`, other runs no longer cause it. The underlying product bug is still open (OpenHands/OpenHands#17946) for plain `agent-canvas` instances: `bin/agent-canvas.mjs` → `scripts/dev-with-automation.mjs` passes `TMUX_TMPDIR=<state>/tmux` to the Agent Server but `ensureDirectories` never creates it (`dev-safe.mjs` does), so tmux 3.4 falls back to the shared `/tmp/tmux-<uid>/openhands` socket and another instance's restart or stop kills this run's windows. The model usually reruns the command; expect an extra action.\n- The `ConversationErrorEvent` banner is not restored after navigating away and back (or a reload); read the error from the events API instead.\n- After `restart`, reconnection takes about 20 s without a click; click `error-message-banner-retry` to reconnect at once. The error banner does not come back after a page reload once the stack is healthy.\n- \"Sending...\" lasts about 100 to 650 ms on a local stack, too short to hover and click `chat-message-stop`; that stop path is not driven here. The `F06.pending-messages` failure path needs `service stop agent-server`.\n- `browser clock` installs a fake clock that stays with the page across reloads and client-side navigation. Install it before the `goto` whose page should see it, and put it back with `browser clock --offset-ms 0` plus `browser reload`; compare `browser eval \"new Date().toISOString()\"` with `date -u` when in doubt.\n- The pending-message queue lives in page memory, so a failed bubble survives sidebar navigation and a backend `restart` but not a `browser reload` or `browser goto` of the conversation (`src/stores/optimistic-user-message-store.ts`). Leave and return through the sidebar when proving `F06.failed-send-persists`. The sidebar card's title is the first message or model-written: click its link by id, `'a[href*=\"/conversations/<id>\"] >> nth=0'`.\n- Both the pending bubble and the echoed message carry `testid=user-message`, so `count 'testid=user-message >> has-text=...'` is one too high while a failed bubble is still shown; count attempts with `conversation events <id> --kinds MessageEvent` (user rows: `\"source\": \"user\"`) and the bubbles with `chat-message-retry`.\n- Local `Stop` calls the Agent Server's interrupt (Cloud pauses the sandbox instead, see `pauseConversation`): a command already running in the terminal is cut off, recorded as `AgentErrorEvent` + `InterruptEvent`, and after Play the model only reports the interruption.\n- The no-LLM banner reuses the home banner's test ids (`home-llm-not-configured-banner`). It exists only in a run where no LLM was ever configured: use a separate `launch --new`, never remove the profile from the shared run.\n- Read the clipboard with `control-openhands browser clipboard`, never `browser eval \"navigator.clipboard.readText()\"`. An agent bubble that contains a code block holds two `copy-to-clipboard` buttons (the code block's is hidden until hovered): scope the bubble's with `>> nth=0`.\n- Plan-mode previews (`plan-preview-*`) and the BTW side messages render in this stream but are reached from the composer's mode controls; they are not mapped here.\n- Known issue [#18117](https://github.com/OpenHands/OpenHands/issues/18117) (`F06.thinking`): Inline thinking leaks into the agent chat bubble. Live prerequisite: A live model that emits a leading inline think block on a tool-call turn, plus its finalized ActionEvent.\n- Known issue [#17559](https://github.com/OpenHands/OpenHands/issues/17559) (`F06.load-older-history`): History pagination can show both an action and its observation (ready-for-dev companion to #16947). Live prerequisite: A real persisted history page boundary splitting an action/observation or ACP started/completed pair; dummy-key errors do not produce these tool pairs.\n- Known issue [#17492](https://github.com/OpenHands/OpenHands/issues/17492) (`F06.tool-visualizers`): Failed browser, glob, grep, and file editor tool calls render as successful in chat. Live prerequisite: A working model emitting failed browser, glob, grep, planning-editor and file-editor observations with is_error=true.\n- Known issue [#17490](https://github.com/OpenHands/OpenHands/issues/17490) (`F06.tool-visualizers`): Terminal commands that fail with an ordinary non-zero exit render as successful in chat. Live prerequisite: A working model producing a completed terminal command with nonzero exit_code and is_error=false.\n- Known issue [#17161](https://github.com/OpenHands/OpenHands/issues/17161) (`F06.markdown-rendering`): markdown sanitizer lets untrusted content set rel=\"opener\" and any target on links. Live prerequisite: A real reasoning, activated-skill, tool-description or error-detail body rendered without includeStandard. The attempted skills dialog exposed public skills only and did not reach the affected chat renderer.\n- Known issue [#17026](https://github.com/OpenHands/OpenHands/issues/17026) (`F06.thinking`): agent-canvas: final-reply thinking block missing for text-only turns (reasoning_content not read for MessageEvents). Live prerequisite: A reasoning-capable live model emitting nonempty MessageEvent.llm_message.reasoning_content on a text-only turn, followed by reload.\n- Known issue [#16842](https://github.com/OpenHands/OpenHands/issues/16842) (`F06.reconnect`): A late close from a replaced WebSocket marks the active connection disconnected. Live prerequisite: A transport fixture delaying an old socket close until its replacement is open; ordinary server restart cannot select that ordering.\n- Known issue [#16719](https://github.com/OpenHands/OpenHands/issues/16719) (`F06.live-activity`): Agent status always says \"thinking\" even when nothing is happening. Live prerequisite: The event/status trace and deterministic stalled or errored state from the original report; its trigger is unspecified.\n- Known issue [#16686](https://github.com/OpenHands/OpenHands/issues/16686) (`F06.error-events`): LLM errors not propagated to the frontend. Live prerequisite: A Nebius credential and its rejected DeepSeek-V3 request, or the original complete emitted error fixture.\n- Known issue [#16582](https://github.com/OpenHands/OpenHands/issues/16582) (`F06.stop-resume`): After reconnect, a maximum-iteration error can fail to pause the agent through the legacy event handler. Live prerequisite: A maximum-iteration legacy error delivered after socket replacement, with a real model run reaching that limit.\n- Known issue [#16455](https://github.com/OpenHands/OpenHands/issues/16455) (`F06.events-match`): Duplicate messages from the agent in 1.12.0. Live prerequisite: The original duplicated event/stream trace or deterministic prompt and timing; the report provides no reproducible trigger.\n- Known issue [#16341](https://github.com/OpenHands/OpenHands/issues/16341) (`F06.tool-visualizers`): Code-explorer subagent frequently errors. Live prerequisite: A working model with native code-explorer delegation and the original repository task; no delegated-tool output can be generated without credentials.\n- Known issue [#15626](https://github.com/OpenHands/OpenHands/issues/15626) (`F06.error-events`): Conversation can fail with LiteLLM auth error when database server at 127.0.0.1:5432 is unreachable. Live prerequisite: The original LiteLLM proxy/database-failure event or a disposable proxy with its PostgreSQL dependency unavailable.\n- Known issue [#15606](https://github.com/OpenHands/OpenHands/issues/15606) (`F06.reload-mid-run`): When re-loading ACP conversations, messages disappear or move. Live prerequisite: An authenticated Codex ACP session emitting intermediate messages while another window reloads history.\n- Known issue [#15527](https://github.com/OpenHands/OpenHands/issues/15527) (`F06.reconnect`): Canvas briefly shows \"disconnected\" when coming back to an idle tab. Live prerequisite: The original custom-domain deployment, idle duration and tab-suspension/network transition; those details are missing.\n- Known failure [OpenHands/software-agent-sdk#5635](https://github.com/OpenHands/software-agent-sdk/issues/5635) (`F06.reconnect`), reproduced 2026-10-12 at `fa1f724ab573` with SDK 1.54.0 and Chromium 153.0.8010.12: sandbox pause returned 204 but an already communicating session socket remained open for more than 10 seconds. A new Canvas tab received a subsequent `run:false` marker while the original tab did not. DELETE returned 200 and GET returned 404, yet both held sockets remained open for more than 10 seconds; Canvas later redirected by metadata polling. Recipe: open an idle dummy-profile conversation and confirm its marker/socket; POST `/api/conversations/prepare-for-sandbox-pause`, retain the original tab for 10 seconds, open a second tab, POST a new unique user event with `run:false`, then compare the two transcripts. DELETE the owned conversation and read lifecycle metadata for another 10 seconds. Use `--run <run>` and `browser events --kinds websocket-created,websocket-open,websocket-close --last 100`; no frame bodies or auth data are recorded. Evidence: `backfill-18309-surface-rechecks/F06.reconnect/pause-delete-sockets-5635.json`, `pause-new-tab-5635.png`, `pause-stale-tab-5635.png`. The expected socket shutdown/reconnect remains unchanged; polling recovery must not be mistaken for socket closure.\n- Known issue [OpenHands/software-agent-sdk#5465](https://github.com/OpenHands/software-agent-sdk/issues/5465) (`F06.branch-from-here`): Branch from here can lose encrypted LLM credentials, so the fork fails its first turn with LLMAuthenticationError. Live prerequisite: A working cipher-backed model conversation, then fork and a successful turn on the fork.\n- Known issue [OpenHands/software-agent-sdk#5447](https://github.com/OpenHands/software-agent-sdk/issues/5447) (`F06.error-events`): An Anthropic Claude Opus5.5 conversation can fail with400 when its next request ends in an assistant prefill. Live prerequisite: Anthropic Claude Opus5.5 credentials and a real trailing-assistant-prefill request.\n- Known issue [OpenHands/software-agent-sdk#5354](https://github.com/OpenHands/software-agent-sdk/issues/5354) (`F06.event-groups`): A mixed valid/invalid tool batch can be split into separate assistant messages, breaking call/result pairing on the next provider request. Live prerequisite: A live model turn mixing valid, invalid and valid tool calls in one response.\n- Known issue [OpenHands/software-agent-sdk#5225](https://github.com/OpenHands/software-agent-sdk/issues/5225) (`F06.error-events`): A provider-rejected image tool result can leave the conversation permanently failing on every resume. Live prerequisite: A real provider-rejected poisoned image tool_result in a disposable persisted history, followed by retry.\n- Known issue [OpenHands/software-agent-sdk#5108](https://github.com/OpenHands/software-agent-sdk/issues/5108) (`F06.tool-visualizers`): The native SDK Gemini editor can report success while corrupting unrelated invalid-UTF8 bytes in the file. Live prerequisite: An Agent Server configured with the native Gemini edit tool set and a real tool edit of an invalid-UTF8 fixture; Gemini ACP uses a different external executor.\n- Known issue [OpenHands/software-agent-sdk#5096](https://github.com/OpenHands/software-agent-sdk/issues/5096) (`F06.tool-visualizers`): The canonical agent file editor can replace a symlink instead of editing its target; Undo does not restore the link. Live prerequisite: A working model invoking canonical file-editor str_replace/insert through a real symlink, then undo.\n- Known issue [OpenHands/software-agent-sdk#5092](https://github.com/OpenHands/software-agent-sdk/issues/5092) (`F06.confirmation-mode`): A revocation message sent while waiting for confirmation can implicitly approve the pending action. Live prerequisite: A working model proposing a real confirmation-gated marker action, then a revocation user message.\n- Known issue [OpenHands/software-agent-sdk#5074](https://github.com/OpenHands/software-agent-sdk/issues/5074) (`F06.tool-visualizers`): After server restart a native delegated child can remain Running while the parent loses its task mapping/result. Live prerequisite: A native delegated child still running during an owned server crash and parent recovery.\n- Known issue [OpenHands/software-agent-sdk#5038](https://github.com/OpenHands/software-agent-sdk/issues/5038) (`F06.tool-visualizers`): The agent file editor can refuse valid Chinese UTF8 text as binary when its sample splits a character; Canvas Files uses a separate preview detector. Live prerequisite: A live canonical file-editor view/edit on the specific UTF8 sample alignment; Canvas Files preview uses a separate NUL sniff, not this validator.\n- Known issue [OpenHands/software-agent-sdk#4990](https://github.com/OpenHands/software-agent-sdk/issues/4990) (`F06.stop-resume`): An ACP turn can keep executing tool calls while the UI already reports Paused. Live prerequisite: An authenticated Claude Code ACP turn executing separate marker-writing tools while Pause is pressed.\n- Known issue [OpenHands/software-agent-sdk#4983](https://github.com/OpenHands/software-agent-sdk/issues/4983) (`F06.tool-visualizers`): Agent grep can silently miss extended-regex matches when its runtime falls back to system grep. Live prerequisite: A runtime without ripgrep and a live agent grep call using extended regex and escaped literal punctuation.\n- Known issue [OpenHands/software-agent-sdk#4965](https://github.com/OpenHands/software-agent-sdk/issues/4965) (`F06.error-events`): A strict OpenAI-compatible provider can reject blank content in the saved history, preventing later turns. Live prerequisite: A strict OpenAI-compatible provider and a real history containing blank text or empty content arrays.\n- Known issue [OpenHands/software-agent-sdk#4777](https://github.com/OpenHands/software-agent-sdk/issues/4777) (`F06.stop-resume`): Stop can still start a queued tool after it finishes waiting on a resource lock. Live prerequisite: A live batch with a second real tool waiting on a declared-resource lock when Stop is pressed.\n- Known issue [OpenHands/software-agent-sdk#4668](https://github.com/OpenHands/software-agent-sdk/issues/4668) (`F06.tool-visualizers`): A planning editor card can lose old/new content and fail to show the complete edit diff. Live prerequisite: A live planner create/edit yielding PlanningFileEditorObservation with old/new contents for the chat diff.\n- Known issue [OpenHands/software-agent-sdk#4663](https://github.com/OpenHands/software-agent-sdk/issues/4663) (`F06.tool-visualizers`): Parallel Python-fallback glob calls can change process cwd and return files from the wrong workspace. Live prerequisite: Concurrent real glob/tool calls in distinct workspaces with the Python fallback active.\n- Known issue [OpenHands/software-agent-sdk#4661](https://github.com/OpenHands/software-agent-sdk/issues/4661) (`F06.tool-visualizers`): Same-name project subagents can resolve to another conversation workspace definition. Live prerequisite: Two native task-enabled conversations with same-name project subagent definitions and a working model for delegation.\n- Known issue [OpenHands/software-agent-sdk#4583](https://github.com/OpenHands/software-agent-sdk/issues/4583) (`F06.tool-visualizers`): An agent editor insertion at EOF can join onto the last line when the file has no trailing newline. Live prerequisite: A working model invoking file-editor insert at EOF on a file with no trailing newline.\n- Known issue [OpenHands/software-agent-sdk#4558](https://github.com/OpenHands/software-agent-sdk/issues/4558) (`F06.error-events`): A saved encrypted fallback profile can send ciphertext as its key and fail authentication, leaving only the primary error visible. Live prerequisite: A working encrypted saved fallback profile and real primary-model failure that triggers fallback resolution.\n- Known issue [OpenHands/software-agent-sdk#4540](https://github.com/OpenHands/software-agent-sdk/issues/4540) (`F06.tool-visualizers`): Synthetic GLM/Kimi tool calls can render as raw tool_call syntax instead of executing. Live prerequisite: Synthetic provider credentials and a GLM/Kimi turn emitting the raw tool_call wrapper.\n- Known issue [OpenHands/software-agent-sdk#4532](https://github.com/OpenHands/software-agent-sdk/issues/4532) (`F06.confirmation-mode`): After restarting while awaiting confirmation, approval can execute the tool but the next model request omits its assistant call. Live prerequisite: A real confirmation-paused action persisted across backend restart, then approval and a working provider call.\n- Known issue [OpenHands/software-agent-sdk#4077](https://github.com/OpenHands/software-agent-sdk/issues/4077) (`F06.reload-mid-run`): Retried or late streaming deltas can duplicate live text; a stalled subscriber can delay Stop and finalization. Live prerequisite: A working stream interrupted/retried or delivered to a controlled stalled subscriber, with delta/state-order capture.\n- Known issue [#16947](https://github.com/OpenHands/OpenHands/issues/16947) (`F06.load-older-history`): Loading older history can leave both the action and its newer observation visible because UI projection depends on arrival order. Live prerequisite: A real long conversation whose REST boundary splits an action/observation pair; keep companion #17559 linked separately.\n\n- Known issue [#18445](https://github.com/OpenHands/OpenHands/issues/18445) (`F06.load-older-history`, `F06.reconnect`): a failed initial Cloud history search can be swallowed into an empty page while the runtime count succeeds, advancing `after_seq` past history that was never displayed. Live prerequisite: a Cloud account and owned conversation with a genuinely failing App API `events/search` and a successful runtime `events/count`. The Local failure path throws instead and is not an equivalent check.\n\n- Known issue [OpenHands/software-agent-sdk#5969](https://github.com/OpenHands/software-agent-sdk/issues/5969) (`F06.stop-resume`): a conversation can report finished but reject Play with already-running, or delay a follow-up, while a slow event webhook is still pending. Live prerequisite: a genuinely successful model run on an owned backend with a bounded slow webhook receiver, then Play or send during that completion window.\n\n- Known issue [OpenHands/software-agent-sdk#5740](https://github.com/OpenHands/software-agent-sdk/issues/5740) (`F06.error-events`): a Responses display delta without `item_id` can terminate the turn with an AttributeError. Live prerequisite: the genuine provider/transformer streaming path producing that incomplete event; the report did not retain its original raw SSE. A credential-free parser diagnostic proves the SDK exception, not a Canvas model turn.\n\n- Known issue [OpenHands/software-agent-sdk#5806](https://github.com/OpenHands/software-agent-sdk/issues/5806) (`F06.branch-from-here`): editing a displayed user message first reads its parent event; if that event has become unreadable/missing on the server, the action can fail with 500 instead of a missing-event response. Live prerequisite: an owned persisted conversation with a still-displayed user message whose event storage is missing, followed by the actual Edit action. A random unknown-ID API call proves neither this UI path nor the batch-get contract, which Canvas does not call.\n\n- Session reconnects use the latest received `after_seq` cursor (`F06.reconnect`). For a local metadata check, retain the open page, send genuine `run:false` user events, record `/events/count`, restart the owned backend and inspect finite WebSocket lifecycle metadata: the reconnect cursor is the previous count minus one and a subsequent recovery marker appears once. This does not measure frame-body replay or Cloud behavior.\n- Stop persists `paused` before an in-flight model request completes (`F06.stop-resume`); the persisted state and reloaded Stopped UI survive an Agent Server restart. A bounded credential-free check can use the tracked accept-only endpoint and an exact listener PID proven to descend from the owned launcher; never kill by pattern or resume the paused conversation.",
      "features": [
        {
          "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 (hints `⇧⌘⌫` / `⌘↩` on macOS, `Ctrl+⇧+⌫` / `Ctrl+↩` elsewhere, hidden below 640 px); a cancelled action is marked Rejected and counts as resolved in its group."
        },
        {
          "id": "F06.confirmation-shortcuts",
          "description": "Cmd+Enter or Ctrl+Enter continues and Shift+Cmd+Backspace or Shift+Ctrl+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."
        }
      ],
      "scenarios": [
        {
          "name": "F06--one-run-that-exercises-the-stream",
          "title": "One run that exercises the stream",
          "ids": [
            "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.",
          "raw": "**One run that exercises the stream (`F06.markdown-rendering`, `F06.event-groups`, `F06.thinking`, `F06.events-match`).** 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.",
          "source_line": 74,
          "expected": [
            "agent replies render markdown: tables inside a horizontal scroller with edge fades, fenced code, inline code.",
            "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.",
            "model reasoning appears as a collapsed \"Thinking\" row that expands.",
            "the stream shows one bubble per message event and one row per action, with no duplicates (compare with `conversation events`)."
          ],
          "notes": [
            "Known issue [#18117](https://github.com/OpenHands/OpenHands/issues/18117) (`F06.thinking`): Inline thinking leaks into the agent chat bubble. Live prerequisite: A live model that emits a leading inline think block on a tool-call turn, plus its finalized ActionEvent.",
            "Known issue [#17161](https://github.com/OpenHands/OpenHands/issues/17161) (`F06.markdown-rendering`): markdown sanitizer lets untrusted content set rel=\"opener\" and any target on links. Live prerequisite: A real reasoning, activated-skill, tool-description or error-detail body rendered without includeStandard. The attempted skills dialog exposed public skills only and did not reach the affected chat renderer.",
            "Known issue [#17026](https://github.com/OpenHands/OpenHands/issues/17026) (`F06.thinking`): agent-canvas: final-reply thinking block missing for text-only turns (reasoning_content not read for MessageEvents). Live prerequisite: A reasoning-capable live model emitting nonempty MessageEvent.llm_message.reasoning_content on a text-only turn, followed by reload.",
            "Known issue [#16455](https://github.com/OpenHands/OpenHands/issues/16455) (`F06.events-match`): Duplicate messages from the agent in 1.12.0. Live prerequisite: The original duplicated event/stream trace or deterministic prompt and timing; the report provides no reproducible trigger.",
            "Known issue [OpenHands/software-agent-sdk#5354](https://github.com/OpenHands/software-agent-sdk/issues/5354) (`F06.event-groups`): A mixed valid/invalid tool batch can be split into separate assistant messages, breaking call/result pairing on the next provider request. Live prerequisite: A live model turn mixing valid, invalid and valid tool calls in one response."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--expand-a-group-and-its-rows",
          "title": "Expand a group and its rows",
          "ids": [
            "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`.",
          "raw": "**Expand a group and its rows (`F06.event-groups`, `F06.thinking`, `F06.tool-visualizers`).** 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`.",
          "source_line": 75,
          "expected": [
            "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.",
            "model reasoning appears as a collapsed \"Thinking\" row that expands.",
            "expanding a tool row shows a rich body: terminal command and output (non-zero exit badge `exit N`), file-editor path chip and content."
          ],
          "notes": [
            "Known issue [#18117](https://github.com/OpenHands/OpenHands/issues/18117) (`F06.thinking`): Inline thinking leaks into the agent chat bubble. Live prerequisite: A live model that emits a leading inline think block on a tool-call turn, plus its finalized ActionEvent.",
            "Known issue [#17492](https://github.com/OpenHands/OpenHands/issues/17492) (`F06.tool-visualizers`): Failed browser, glob, grep, and file editor tool calls render as successful in chat. Live prerequisite: A working model emitting failed browser, glob, grep, planning-editor and file-editor observations with is_error=true.",
            "Known issue [#17490](https://github.com/OpenHands/OpenHands/issues/17490) (`F06.tool-visualizers`): Terminal commands that fail with an ordinary non-zero exit render as successful in chat. Live prerequisite: A working model producing a completed terminal command with nonzero exit_code and is_error=false.",
            "Known issue [#17026](https://github.com/OpenHands/OpenHands/issues/17026) (`F06.thinking`): agent-canvas: final-reply thinking block missing for text-only turns (reasoning_content not read for MessageEvents). Live prerequisite: A reasoning-capable live model emitting nonempty MessageEvent.llm_message.reasoning_content on a text-only turn, followed by reload.",
            "Known issue [#16341](https://github.com/OpenHands/OpenHands/issues/16341) (`F06.tool-visualizers`): Code-explorer subagent frequently errors. Live prerequisite: A working model with native code-explorer delegation and the original repository task; no delegated-tool output can be generated without credentials.",
            "Known issue [OpenHands/software-agent-sdk#5354](https://github.com/OpenHands/software-agent-sdk/issues/5354) (`F06.event-groups`): A mixed valid/invalid tool batch can be split into separate assistant messages, breaking call/result pairing on the next provider request. Live prerequisite: A live model turn mixing valid, invalid and valid tool calls in one response.",
            "Known issue [OpenHands/software-agent-sdk#5108](https://github.com/OpenHands/software-agent-sdk/issues/5108) (`F06.tool-visualizers`): The native SDK Gemini editor can report success while corrupting unrelated invalid-UTF8 bytes in the file. Live prerequisite: An Agent Server configured with the native Gemini edit tool set and a real tool edit of an invalid-UTF8 fixture; Gemini ACP uses a different external executor.",
            "Known issue [OpenHands/software-agent-sdk#5096](https://github.com/OpenHands/software-agent-sdk/issues/5096) (`F06.tool-visualizers`): The canonical agent file editor can replace a symlink instead of editing its target; Undo does not restore the link. Live prerequisite: A working model invoking canonical file-editor str_replace/insert through a real symlink, then undo.",
            "Known issue [OpenHands/software-agent-sdk#5074](https://github.com/OpenHands/software-agent-sdk/issues/5074) (`F06.tool-visualizers`): After server restart a native delegated child can remain Running while the parent loses its task mapping/result. Live prerequisite: A native delegated child still running during an owned server crash and parent recovery.",
            "Known issue [OpenHands/software-agent-sdk#5038](https://github.com/OpenHands/software-agent-sdk/issues/5038) (`F06.tool-visualizers`): The agent file editor can refuse valid Chinese UTF8 text as binary when its sample splits a character; Canvas Files uses a separate preview detector. Live prerequisite: A live canonical file-editor view/edit on the specific UTF8 sample alignment; Canvas Files preview uses a separate NUL sniff, not this validator.",
            "Known issue [OpenHands/software-agent-sdk#4983](https://github.com/OpenHands/software-agent-sdk/issues/4983) (`F06.tool-visualizers`): Agent grep can silently miss extended-regex matches when its runtime falls back to system grep. Live prerequisite: A runtime without ripgrep and a live agent grep call using extended regex and escaped literal punctuation.",
            "Known issue [OpenHands/software-agent-sdk#4668](https://github.com/OpenHands/software-agent-sdk/issues/4668) (`F06.tool-visualizers`): A planning editor card can lose old/new content and fail to show the complete edit diff. Live prerequisite: A live planner create/edit yielding PlanningFileEditorObservation with old/new contents for the chat diff.",
            "Known issue [OpenHands/software-agent-sdk#4663](https://github.com/OpenHands/software-agent-sdk/issues/4663) (`F06.tool-visualizers`): Parallel Python-fallback glob calls can change process cwd and return files from the wrong workspace. Live prerequisite: Concurrent real glob/tool calls in distinct workspaces with the Python fallback active.",
            "Known issue [OpenHands/software-agent-sdk#4661](https://github.com/OpenHands/software-agent-sdk/issues/4661) (`F06.tool-visualizers`): Same-name project subagents can resolve to another conversation workspace definition. Live prerequisite: Two native task-enabled conversations with same-name project subagent definitions and a working model for delegation.",
            "Known issue [OpenHands/software-agent-sdk#4583](https://github.com/OpenHands/software-agent-sdk/issues/4583) (`F06.tool-visualizers`): An agent editor insertion at EOF can join onto the last line when the file has no trailing newline. Live prerequisite: A working model invoking file-editor insert at EOF on a file with no trailing newline.",
            "Known issue [OpenHands/software-agent-sdk#4540](https://github.com/OpenHands/software-agent-sdk/issues/4540) (`F06.tool-visualizers`): Synthetic GLM/Kimi tool calls can render as raw tool_call syntax instead of executing. Live prerequisite: Synthetic provider credentials and a GLM/Kimi turn emitting the raw tool_call wrapper."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--open-files-from-the-stream",
          "title": "Open files from the stream",
          "ids": [
            "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.",
          "raw": "**Open files from the stream (`F06.workspace-path-links`).** 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.",
          "source_line": 76,
          "expected": [
            "a workspace file path in an agent reply, and the path chip of a file-editor card, open that file in the Files tab."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--markdown-file-preview",
          "title": "Markdown file preview",
          "ids": [
            "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.",
          "raw": "**Markdown file preview (`F06.markdown-file-preview`).** 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.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--timestamps",
          "title": "Timestamps",
          "ids": [
            "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`.",
          "raw": "**Timestamps (`F06.timestamps`).** 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`.",
          "source_line": 78,
          "expected": [
            "hovering a bubble or tool title shows the event's local date and time."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--copy-a-message-and-a-code-block",
          "title": "Copy a message and a code block",
          "ids": [
            "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)`.",
          "raw": "**Copy a message and a code block (`F06.message-copy`, `F06.code-block-copy`).** 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)`.",
          "source_line": 79,
          "expected": [
            "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.",
            "hovering a fenced code block in an agent reply shows its own Copy button, which copies only the code."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--long-table-cell",
          "title": "Long table cell",
          "ids": [
            "F06.markdown-rendering"
          ],
          "body": "On the stream conversation write a prompt file whose first line is `Reply with only the word OK. Do not run any tools.`, then a blank line and the table `| Name | Description | Status |`, `|---|---|---|`, `| Alpha | This is a really long sentence in a single cell that goes on and on to explain a very involved aspect of the system, which nobody could read on one line without scrolling forever to the right. | ok |`, `| Beta | brief | fail |`. Run `control-openhands browser fill 'testid=chat-input' x --value-file <file>`, `control-openhands browser click 'testid=submit-button'` and `control-openhands conversation wait <id> --fresh --timeout 120`; the user bubble renders the table. Cells wrap once they reach 28rem (`max-w-md`): `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=markdown-table-scroll] td')).whiteSpace + ' ' + getComputedStyle(document.querySelector('[data-testid=markdown-table-scroll] td')).maxWidth\"` is `normal 448px`. At desktop with the right panel closed, `control-openhands browser bbox 'testid=user-message >> nth=-1 >> testid=markdown-table-scroll'` has `scrollWidth` `582` equal to `clientWidth` and `Status` is visible (`browser screenshot 'testid=chat-interface' --feature F06.markdown-rendering --name long-cell-table`). A table still wider than the conversation keeps the horizontal scroll with its edge fade: with the panel open (`control-openhands browser click 'testid=right-panel-toggle'`) `clientWidth` is `474`, and at phone width `326`, so `Status` is scrolled off there while the long cell stays wrapped. An agent reply with such a table behaves the same.",
          "raw": "**Long table cell (`F06.markdown-rendering`).** On the stream conversation write a prompt file whose first line is `Reply with only the word OK. Do not run any tools.`, then a blank line and the table `| Name | Description | Status |`, `|---|---|---|`, `| Alpha | This is a really long sentence in a single cell that goes on and on to explain a very involved aspect of the system, which nobody could read on one line without scrolling forever to the right. | ok |`, `| Beta | brief | fail |`. Run `control-openhands browser fill 'testid=chat-input' x --value-file <file>`, `control-openhands browser click 'testid=submit-button'` and `control-openhands conversation wait <id> --fresh --timeout 120`; the user bubble renders the table. Cells wrap once they reach 28rem (`max-w-md`): `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=markdown-table-scroll] td')).whiteSpace + ' ' + getComputedStyle(document.querySelector('[data-testid=markdown-table-scroll] td')).maxWidth\"` is `normal 448px`. At desktop with the right panel closed, `control-openhands browser bbox 'testid=user-message >> nth=-1 >> testid=markdown-table-scroll'` has `scrollWidth` `582` equal to `clientWidth` and `Status` is visible (`browser screenshot 'testid=chat-interface' --feature F06.markdown-rendering --name long-cell-table`). A table still wider than the conversation keeps the horizontal scroll with its edge fade: with the panel open (`control-openhands browser click 'testid=right-panel-toggle'`) `clientWidth` is `474`, and at phone width `326`, so `Status` is scrolled off there while the long cell stays wrapped. An agent reply with such a table behaves the same.",
          "source_line": 80,
          "expected": [
            "agent replies render markdown: tables inside a horizontal scroller with edge fades, fenced code, inline code."
          ],
          "notes": [
            "Known issue [#17161](https://github.com/OpenHands/OpenHands/issues/17161) (`F06.markdown-rendering`): markdown sanitizer lets untrusted content set rel=\"opener\" and any target on links. Live prerequisite: A real reasoning, activated-skill, tool-description or error-detail body rendered without includeStandard. The attempted skills dialog exposed public skills only and did not reach the affected chat renderer."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--non-zero-exit-badge",
          "title": "Non-zero exit badge",
          "ids": [
            "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.",
          "raw": "**Non-zero exit badge (`F06.tool-visualizers`).** 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.",
          "source_line": 81,
          "expected": [
            "expanding a tool row shows a rich body: terminal command and output (non-zero exit badge `exit N`), file-editor path chip and content."
          ],
          "notes": [
            "Known issue [#17492](https://github.com/OpenHands/OpenHands/issues/17492) (`F06.tool-visualizers`): Failed browser, glob, grep, and file editor tool calls render as successful in chat. Live prerequisite: A working model emitting failed browser, glob, grep, planning-editor and file-editor observations with is_error=true.",
            "Known issue [#17490](https://github.com/OpenHands/OpenHands/issues/17490) (`F06.tool-visualizers`): Terminal commands that fail with an ordinary non-zero exit render as successful in chat. Live prerequisite: A working model producing a completed terminal command with nonzero exit_code and is_error=false.",
            "Known issue [#16341](https://github.com/OpenHands/OpenHands/issues/16341) (`F06.tool-visualizers`): Code-explorer subagent frequently errors. Live prerequisite: A working model with native code-explorer delegation and the original repository task; no delegated-tool output can be generated without credentials.",
            "Known issue [OpenHands/software-agent-sdk#5108](https://github.com/OpenHands/software-agent-sdk/issues/5108) (`F06.tool-visualizers`): The native SDK Gemini editor can report success while corrupting unrelated invalid-UTF8 bytes in the file. Live prerequisite: An Agent Server configured with the native Gemini edit tool set and a real tool edit of an invalid-UTF8 fixture; Gemini ACP uses a different external executor.",
            "Known issue [OpenHands/software-agent-sdk#5096](https://github.com/OpenHands/software-agent-sdk/issues/5096) (`F06.tool-visualizers`): The canonical agent file editor can replace a symlink instead of editing its target; Undo does not restore the link. Live prerequisite: A working model invoking canonical file-editor str_replace/insert through a real symlink, then undo.",
            "Known issue [OpenHands/software-agent-sdk#5074](https://github.com/OpenHands/software-agent-sdk/issues/5074) (`F06.tool-visualizers`): After server restart a native delegated child can remain Running while the parent loses its task mapping/result. Live prerequisite: A native delegated child still running during an owned server crash and parent recovery.",
            "Known issue [OpenHands/software-agent-sdk#5038](https://github.com/OpenHands/software-agent-sdk/issues/5038) (`F06.tool-visualizers`): The agent file editor can refuse valid Chinese UTF8 text as binary when its sample splits a character; Canvas Files uses a separate preview detector. Live prerequisite: A live canonical file-editor view/edit on the specific UTF8 sample alignment; Canvas Files preview uses a separate NUL sniff, not this validator.",
            "Known issue [OpenHands/software-agent-sdk#4983](https://github.com/OpenHands/software-agent-sdk/issues/4983) (`F06.tool-visualizers`): Agent grep can silently miss extended-regex matches when its runtime falls back to system grep. Live prerequisite: A runtime without ripgrep and a live agent grep call using extended regex and escaped literal punctuation.",
            "Known issue [OpenHands/software-agent-sdk#4668](https://github.com/OpenHands/software-agent-sdk/issues/4668) (`F06.tool-visualizers`): A planning editor card can lose old/new content and fail to show the complete edit diff. Live prerequisite: A live planner create/edit yielding PlanningFileEditorObservation with old/new contents for the chat diff.",
            "Known issue [OpenHands/software-agent-sdk#4663](https://github.com/OpenHands/software-agent-sdk/issues/4663) (`F06.tool-visualizers`): Parallel Python-fallback glob calls can change process cwd and return files from the wrong workspace. Live prerequisite: Concurrent real glob/tool calls in distinct workspaces with the Python fallback active.",
            "Known issue [OpenHands/software-agent-sdk#4661](https://github.com/OpenHands/software-agent-sdk/issues/4661) (`F06.tool-visualizers`): Same-name project subagents can resolve to another conversation workspace definition. Live prerequisite: Two native task-enabled conversations with same-name project subagent definitions and a working model for delegation.",
            "Known issue [OpenHands/software-agent-sdk#4583](https://github.com/OpenHands/software-agent-sdk/issues/4583) (`F06.tool-visualizers`): An agent editor insertion at EOF can join onto the last line when the file has no trailing newline. Live prerequisite: A working model invoking file-editor insert at EOF on a file with no trailing newline.",
            "Known issue [OpenHands/software-agent-sdk#4540](https://github.com/OpenHands/software-agent-sdk/issues/4540) (`F06.tool-visualizers`): Synthetic GLM/Kimi tool calls can render as raw tool_call syntax instead of executing. Live prerequisite: Synthetic provider credentials and a GLM/Kimi turn emitting the raw tool_call wrapper."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--task-list",
          "title": "Task list",
          "ids": [
            "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.",
          "raw": "**Task list (`F06.task-list`, `F06.status-indicator`).** 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.",
          "source_line": 82,
          "expected": [
            "task-tracker calls render a \"Tasks\" card with done / todo icons.",
            "while a new conversation starts, a \"Starting\" pill shows above the composer."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--empty-state",
          "title": "Empty state",
          "ids": [
            "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.",
          "raw": "**Empty state (`F06.empty-state-suggestions`).** 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.",
          "source_line": 83,
          "expected": [
            "an empty conversation shows \"Let's start building!\" with four suggestion chips; a chip fills the composer with its prompt."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--pending-bubble-and-long-messages",
          "title": "Pending bubble and long messages",
          "ids": [
            "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`.",
          "raw": "**Pending bubble and long messages (`F06.pending-messages`, `F06.user-and-agent-messages`).** 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`.",
          "source_line": 84,
          "expected": [
            "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.",
            "user and agent bubbles; a long user message is clipped with a gradient and expands on click (\"View More\")."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--browser-clock-ahead",
          "title": "Browser clock ahead",
          "ids": [
            "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).",
          "raw": "**Browser clock ahead (`F06.clock-skew-send`).** 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).",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--server-lost-banner-failed-send-reconnect",
          "title": "Server lost: banner, failed send, reconnect",
          "ids": [
            "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`.",
          "raw": "**Server lost: banner, failed send, reconnect (`F06.error-banner`, `F06.pending-messages`, `F06.reconnect`).** 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`.",
          "source_line": 86,
          "expected": [
            "losing the server shows \"Unable to connect to server\" with Retry, Copy and Close; Retry reconnects once the server is back.",
            "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.",
            "when the Agent Server goes away the composer shows Disconnected; after it returns the page reconnects without a reload."
          ],
          "notes": [
            "Known issue [#16842](https://github.com/OpenHands/OpenHands/issues/16842) (`F06.reconnect`): A late close from a replaced WebSocket marks the active connection disconnected. Live prerequisite: A transport fixture delaying an old socket close until its replacement is open; ordinary server restart cannot select that ordering.",
            "Known issue [#15527](https://github.com/OpenHands/OpenHands/issues/15527) (`F06.reconnect`): Canvas briefly shows \"disconnected\" when coming back to an idle tab. Live prerequisite: The original custom-domain deployment, idle duration and tab-suspension/network transition; those details are missing.",
            "Known failure [OpenHands/software-agent-sdk#5635](https://github.com/OpenHands/software-agent-sdk/issues/5635) (`F06.reconnect`), reproduced 2026-10-12 at `fa1f724ab573` with SDK 1.54.0 and Chromium 153.0.8010.12: sandbox pause returned 204 but an already communicating session socket remained open for more than 10 seconds. A new Canvas tab received a subsequent `run:false` marker while the original tab did not. DELETE returned 200 and GET returned 404, yet both held sockets remained open for more than 10 seconds; Canvas later redirected by metadata polling. Recipe: open an idle dummy-profile conversation and confirm its marker/socket; POST `/api/conversations/prepare-for-sandbox-pause`, retain the original tab for 10 seconds, open a second tab, POST a new unique user event with `run:false`, then compare the two transcripts. DELETE the owned conversation and read lifecycle metadata for another 10 seconds. Use `--run <run>` and `browser events --kinds websocket-created,websocket-open,websocket-close --last 100`; no frame bodies or auth data are recorded. Evidence: `backfill-18309-surface-rechecks/F06.reconnect/pause-delete-sockets-5635.json`, `pause-new-tab-5635.png`, `pause-stale-tab-5635.png`. The expected socket shutdown/reconnect remains unchanged; polling recovery must not be mistaken for socket closure.",
            "Known issue [#18445](https://github.com/OpenHands/OpenHands/issues/18445) (`F06.load-older-history`, `F06.reconnect`): a failed initial Cloud history search can be swallowed into an empty page while the runtime count succeeds, advancing `after_seq` past history that was never displayed. Live prerequisite: a Cloud account and owned conversation with a genuinely failing App API `events/search` and a successful runtime `events/count`. The Local failure path throws instead and is not an equivalent check."
          ],
          "annotations": [],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--banner-retry",
          "title": "Banner Retry",
          "ids": [
            "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.",
          "raw": "**Banner Retry (`F06.error-banner`).** 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.",
          "source_line": 87,
          "expected": [
            "losing the server shows \"Unable to connect to server\" with Retry, Copy and Close; Retry reconnects once the server is back."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--failed-send-survives-leaving",
          "title": "Failed send survives leaving",
          "ids": [
            "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.",
          "raw": "**Failed send survives leaving (`F06.failed-send-persists`).** 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.",
          "source_line": 88,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--live-chip-stop-and-resume",
          "title": "Live chip, stop and resume",
          "ids": [
            "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).",
          "raw": "**Live chip, stop and resume (`F06.live-activity`, `F06.stop-resume`).** 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).",
          "source_line": 89,
          "expected": [
            "while the agent runs, a chip above the composer names the current action (\"Thinking\", then the action title) and the composer status reads Running.",
            "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."
          ],
          "notes": [
            "Known issue [#16719](https://github.com/OpenHands/OpenHands/issues/16719) (`F06.live-activity`): Agent status always says \"thinking\" even when nothing is happening. Live prerequisite: The event/status trace and deterministic stalled or errored state from the original report; its trigger is unspecified.",
            "Known issue [#16582](https://github.com/OpenHands/OpenHands/issues/16582) (`F06.stop-resume`): After reconnect, a maximum-iteration error can fail to pause the agent through the legacy event handler. Live prerequisite: A maximum-iteration legacy error delivered after socket replacement, with a real model run reaching that limit.",
            "Known issue [OpenHands/software-agent-sdk#4990](https://github.com/OpenHands/software-agent-sdk/issues/4990) (`F06.stop-resume`): An ACP turn can keep executing tool calls while the UI already reports Paused. Live prerequisite: An authenticated Claude Code ACP turn executing separate marker-writing tools while Pause is pressed.",
            "Known issue [OpenHands/software-agent-sdk#4777](https://github.com/OpenHands/software-agent-sdk/issues/4777) (`F06.stop-resume`): Stop can still start a queued tool after it finishes waiting on a resource lock. Live prerequisite: A live batch with a second real tool waiting on a declared-resource lock when Stop is pressed.",
            "Known issue [OpenHands/software-agent-sdk#5969](https://github.com/OpenHands/software-agent-sdk/issues/5969) (`F06.stop-resume`): a conversation can report finished but reject Play with already-running, or delay a follow-up, while a slow event webhook is still pending. Live prerequisite: a genuinely successful model run on an owned backend with a bounded slow webhook receiver, then Play or send during that completion window."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--reload-mid-run",
          "title": "Reload mid-run",
          "ids": [
            "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`.",
          "raw": "**Reload mid-run (`F06.reload-mid-run`).** 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`.",
          "source_line": 90,
          "expected": [
            "reloading while the agent runs reattaches to the live run; it finishes without duplicated events."
          ],
          "notes": [
            "Known issue [#15606](https://github.com/OpenHands/OpenHands/issues/15606) (`F06.reload-mid-run`): When re-loading ACP conversations, messages disappear or move. Live prerequisite: An authenticated Codex ACP session emitting intermediate messages while another window reloads history.",
            "Known issue [OpenHands/software-agent-sdk#4077](https://github.com/OpenHands/software-agent-sdk/issues/4077) (`F06.reload-mid-run`): Retried or late streaming deltas can duplicate live text; a stalled subscriber can delay Stop and finalization. Live prerequisite: A working stream interrupted/retried or delivered to a controlled stalled subscriber, with delta/state-order capture."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--older-history",
          "title": "Older history",
          "ids": [
            "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'`.",
          "raw": "**Older history (`F06.load-older-history`).** 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'`.",
          "source_line": 91,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17559](https://github.com/OpenHands/OpenHands/issues/17559) (`F06.load-older-history`): History pagination can show both an action and its observation (ready-for-dev companion to #16947). Live prerequisite: A real persisted history page boundary splitting an action/observation or ACP started/completed pair; dummy-key errors do not produce these tool pairs.",
            "Known issue [#16947](https://github.com/OpenHands/OpenHands/issues/16947) (`F06.load-older-history`): Loading older history can leave both the action and its newer observation visible because UI projection depends on arrival order. Live prerequisite: A real long conversation whose REST boundary splits an action/observation pair; keep companion #17559 linked separately.",
            "Known issue [#18445](https://github.com/OpenHands/OpenHands/issues/18445) (`F06.load-older-history`, `F06.reconnect`): a failed initial Cloud history search can be swallowed into an empty page while the runtime count succeeds, advancing `after_seq` past history that was never displayed. Live prerequisite: a Cloud account and owned conversation with a genuinely failing App API `events/search` and a successful runtime `events/count`. The Local failure path throws instead and is not an equivalent check."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--scroll-to-bottom",
          "title": "Scroll to bottom",
          "ids": [
            "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`.",
          "raw": "**Scroll to bottom (`F06.scroll-to-bottom`).** 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`.",
          "source_line": 92,
          "expected": [
            "scrolling up shows a scroll-to-bottom button; clicking it returns to the bottom and hides it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--confirmation-mode-on",
          "title": "Confirmation mode on",
          "ids": [
            "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 Ctrl+⇧+⌫ Continue Ctrl+↩` on Linux (`Cancel ⇧⌘⌫ Continue ⌘↩` on macOS, #18127), `control-openhands browser tooltip 'testid=action-confirm-button'` is `Confirm the requested action`, and a lock icon sits above the composer (screenshot). The tooltip stays open over the Cancel button, so move the pointer first: `control-openhands browser hover 'testid=chat-input'`. Run `control-openhands browser click 'testid=action-reject-button'`: status `idle`, the buttons are gone, `conversation events <id> --last 6` ends with `UserRejectObservation`, and the action's row reads `Rejected` (`control-openhands browser count 'testid=rejected-indicator'` is `1`) with no spinner. 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`. A rejection inside a group resolves it too: send `Run the terminal command 'echo qa-g1'. After it finishes, run the terminal command 'echo qa-g2' as a separate tool call. Then reply DONE.`, continue the first action (`browser wait 'testid=action-confirm-button'`, then `control-openhands browser press Control+Enter`, which is also the next bullet's shortcut) and cancel the second (`browser wait 'testid=action-confirm-button' --timeout 60000`, `control-openhands browser press Control+Shift+Backspace`): status `idle`, `control-openhands browser text 'testid=event-group >> nth=-1'` ends `2 actions completed`, `control-openhands browser count 'testid=event-group >> nth=-1 >> .animate-spin'` is `0`, the same after `browser reload`, and the expanded group (`control-openhands browser click 'testid=event-group >> nth=-1 >> button >> nth=0'`) shows `Rejected` on the `qa-g2` row (`browser screenshot 'testid=chat-interface' --feature F06.confirmation-mode --name group-after-reload`). Each button keeps its label and hint on one line: while the prompt is waiting, `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=action-reject-button],[data-testid=action-confirm-button]')].map(b=>b.offsetHeight+'/'+b.scrollHeight).join(' ')\"` is `26/26 26/26` at desktop, also with the side panel open (`testid=right-panel-toggle`). At phone width (`browser viewport phone`) the shortcut hints are hidden (no hardware keyboard), the buttons read `Cancel` and `Continue`, and `browser text 'testid=chat-scroll-container'` ends `Do you want to continue with this action? Cancel Continue` (screenshot the awaiting prompt at phone width).",
          "raw": "**Confirmation mode on (`F06.confirmation-mode`).** 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 Ctrl+⇧+⌫ Continue Ctrl+↩` on Linux (`Cancel ⇧⌘⌫ Continue ⌘↩` on macOS, #18127), `control-openhands browser tooltip 'testid=action-confirm-button'` is `Confirm the requested action`, and a lock icon sits above the composer (screenshot). The tooltip stays open over the Cancel button, so move the pointer first: `control-openhands browser hover 'testid=chat-input'`. Run `control-openhands browser click 'testid=action-reject-button'`: status `idle`, the buttons are gone, `conversation events <id> --last 6` ends with `UserRejectObservation`, and the action's row reads `Rejected` (`control-openhands browser count 'testid=rejected-indicator'` is `1`) with no spinner. 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`. A rejection inside a group resolves it too: send `Run the terminal command 'echo qa-g1'. After it finishes, run the terminal command 'echo qa-g2' as a separate tool call. Then reply DONE.`, continue the first action (`browser wait 'testid=action-confirm-button'`, then `control-openhands browser press Control+Enter`, which is also the next bullet's shortcut) and cancel the second (`browser wait 'testid=action-confirm-button' --timeout 60000`, `control-openhands browser press Control+Shift+Backspace`): status `idle`, `control-openhands browser text 'testid=event-group >> nth=-1'` ends `2 actions completed`, `control-openhands browser count 'testid=event-group >> nth=-1 >> .animate-spin'` is `0`, the same after `browser reload`, and the expanded group (`control-openhands browser click 'testid=event-group >> nth=-1 >> button >> nth=0'`) shows `Rejected` on the `qa-g2` row (`browser screenshot 'testid=chat-interface' --feature F06.confirmation-mode --name group-after-reload`). Each button keeps its label and hint on one line: while the prompt is waiting, `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=action-reject-button],[data-testid=action-confirm-button]')].map(b=>b.offsetHeight+'/'+b.scrollHeight).join(' ')\"` is `26/26 26/26` at desktop, also with the side panel open (`testid=right-panel-toggle`). At phone width (`browser viewport phone`) the shortcut hints are hidden (no hardware keyboard), the buttons read `Cancel` and `Continue`, and `browser text 'testid=chat-scroll-container'` ends `Do you want to continue with this action? Cancel Continue` (screenshot the awaiting prompt at phone width).",
          "source_line": 93,
          "expected": [
            "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 (hints `⇧⌘⌫` / `⌘↩` on macOS, `Ctrl+⇧+⌫` / `Ctrl+↩` elsewhere, hidden below 640 px); a cancelled action is marked Rejected and counts as resolved in its group."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5092](https://github.com/OpenHands/software-agent-sdk/issues/5092) (`F06.confirmation-mode`): A revocation message sent while waiting for confirmation can implicitly approve the pending action. Live prerequisite: A working model proposing a real confirmation-gated marker action, then a revocation user message.",
            "Known issue [OpenHands/software-agent-sdk#4532](https://github.com/OpenHands/software-agent-sdk/issues/4532) (`F06.confirmation-mode`): After restarting while awaiting confirmation, approval can execute the tool but the next model request omits its assistant call. Live prerequisite: A real confirmation-paused action persisted across backend restart, then approval and a working provider call."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--confirmation-shortcuts",
          "title": "Confirmation shortcuts",
          "ids": [
            "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`. `control-openhands browser text 'testid=action-confirm-button'` is `Continue Ctrl+↩` off macOS. Ctrl works off macOS (#17928, fixed by #18127): the previous bullet's `Control+Enter` continued and `Control+Shift+Backspace` cancelled. `control-openhands browser press Meta+Enter` continues too: `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\"`.",
          "raw": "**Confirmation shortcuts (`F06.confirmation-shortcuts`).** 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`. `control-openhands browser text 'testid=action-confirm-button'` is `Continue Ctrl+↩` off macOS. Ctrl works off macOS (#17928, fixed by #18127): the previous bullet's `Control+Enter` continued and `Control+Shift+Backspace` cancelled. `control-openhands browser press Meta+Enter` continues too: `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\"`.",
          "source_line": 94,
          "expected": [
            "Cmd+Enter or Ctrl+Enter continues and Shift+Cmd+Backspace or Shift+Ctrl+Backspace cancels a pending action."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--branch-from-here",
          "title": "Branch from here",
          "ids": [
            "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).",
          "raw": "**Branch from here (`F06.branch-from-here`).** 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).",
          "source_line": 95,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5465](https://github.com/OpenHands/software-agent-sdk/issues/5465) (`F06.branch-from-here`): Branch from here can lose encrypted LLM credentials, so the fork fails its first turn with LLMAuthenticationError. Live prerequisite: A working cipher-backed model conversation, then fork and a successful turn on the fork.",
            "Known issue [OpenHands/software-agent-sdk#5806](https://github.com/OpenHands/software-agent-sdk/issues/5806) (`F06.branch-from-here`): editing a displayed user message first reads its parent event; if that event has become unreadable/missing on the server, the action can fail with 500 instead of a missing-event response. Live prerequisite: an owned persisted conversation with a still-displayed user message whose event storage is missing, followed by the actual Edit action. A random unknown-ID API call proves neither this UI path nor the batch-get contract, which Canvas does not call."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--images",
          "title": "Images",
          "ids": [
            "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`).",
          "raw": "**Images (`F06.image-attachments`).** 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`).",
          "source_line": 96,
          "expected": [
            "images sent with a message show as thumbnails in the bubble; expand opens a lightbox that closes with X or Escape."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--error-events",
          "title": "Error events",
          "ids": [
            "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\"`.",
          "raw": "**Error events (`F06.error-events`).** 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\"`.",
          "source_line": 97,
          "expected": [
            "a conversation error (ConversationErrorEvent) surfaces as a warning banner and the composer status Error."
          ],
          "notes": [
            "Known issue [#16686](https://github.com/OpenHands/OpenHands/issues/16686) (`F06.error-events`): LLM errors not propagated to the frontend. Live prerequisite: A Nebius credential and its rejected DeepSeek-V3 request, or the original complete emitted error fixture.",
            "Known issue [#15626](https://github.com/OpenHands/OpenHands/issues/15626) (`F06.error-events`): Conversation can fail with LiteLLM auth error when database server at 127.0.0.1:5432 is unreachable. Live prerequisite: The original LiteLLM proxy/database-failure event or a disposable proxy with its PostgreSQL dependency unavailable.",
            "Known issue [OpenHands/software-agent-sdk#5447](https://github.com/OpenHands/software-agent-sdk/issues/5447) (`F06.error-events`): An Anthropic Claude Opus5.5 conversation can fail with400 when its next request ends in an assistant prefill. Live prerequisite: Anthropic Claude Opus5.5 credentials and a real trailing-assistant-prefill request.",
            "Known issue [OpenHands/software-agent-sdk#5225](https://github.com/OpenHands/software-agent-sdk/issues/5225) (`F06.error-events`): A provider-rejected image tool result can leave the conversation permanently failing on every resume. Live prerequisite: A real provider-rejected poisoned image tool_result in a disposable persisted history, followed by retry.",
            "Known issue [OpenHands/software-agent-sdk#4965](https://github.com/OpenHands/software-agent-sdk/issues/4965) (`F06.error-events`): A strict OpenAI-compatible provider can reject blank content in the saved history, preventing later turns. Live prerequisite: A strict OpenAI-compatible provider and a real history containing blank text or empty content arrays.",
            "Known issue [OpenHands/software-agent-sdk#4558](https://github.com/OpenHands/software-agent-sdk/issues/4558) (`F06.error-events`): A saved encrypted fallback profile can send ciphertext as its key and fail authentication, leaving only the primary error visible. Live prerequisite: A working encrypted saved fallback profile and real primary-model failure that triggers fallback resolution.",
            "Known issue [OpenHands/software-agent-sdk#5740](https://github.com/OpenHands/software-agent-sdk/issues/5740) (`F06.error-events`): a Responses display delta without `item_id` can terminate the turn with an AttributeError. Live prerequisite: the genuine provider/transformer streaming path producing that incomplete event; the report did not retain its original raw SSE. A credential-free parser diagnostic proves the SDK exception, not a Canvas model turn."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--skill-installed-banner",
          "title": "Skill installed banner",
          "ids": [
            "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).",
          "raw": "**Skill installed banner (`F06.skill-install-banner`).** 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).",
          "source_line": 98,
          "expected": [
            "after the agent installs a skill into the workspace, a banner offers \"Start new conversation with this skill\" (same workspace) and Close."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--no-llm",
          "title": "No LLM",
          "ids": [
            "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`.",
          "raw": "**No LLM (`F06.llm-not-configured-banner`).** 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`.",
          "source_line": 99,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--critic-f06-critic-result-blocked",
          "title": "Critic (`F06.critic-result`, blocked)",
          "ids": [
            "F06.critic-result"
          ],
          "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.",
          "raw": "**Critic (`F06.critic-result`, blocked).** 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.",
          "source_line": 100,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F06--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F06.phone`).** 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`.",
          "source_line": 101,
          "expected": [
            "at 390 px the transcript fits the viewport; wide tables scroll inside their own container."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F06--after-each-group",
          "title": "After each group",
          "ids": [],
          "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`.",
          "raw": "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`.",
          "source_line": 102,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F06--planner-discovery-with-many-children",
          "title": "Planner discovery with many children",
          "ids": [
            "F06.tool-visualizers"
          ],
          "body": "Expected: reloading a parent still discovers its `plannerparent`-tagged planner when it has100 child conversations. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5790](https://github.com/OpenHands/software-agent-sdk/issues/5790)): the unchunked100-ID request returns500, shows `Internal Server Error` and omits the planner session connection. A99-child control returns200 and opens both planner and parent sockets. These are genuine idle API-created children, without a model run or browser-storage changes; a new parent ID has no UI-created planner hint.\n  ```sh known-bug F06.tool-visualizers planner-batch-100\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  workspace = fixture / \"workspace\"\n  workspace.mkdir()\n  parent = create(workspace)\n  for index in range(100):\n      create(workspace, parent, {\"plannerparent\":parent} if index == 0 else None)\n  print(parent)\n  PY\n  )\"\n  control-openhands assert --field value --equals 100 -- api GET \"/api/conversations/$QA_ID\" --pick sub_conversation_ids.length\n  QA_PLANNER=\"$(control-openhands api GET \"/api/conversations/$QA_ID\" --pick sub_conversation_ids.0 | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"value\"])')\"\n  control-openhands browser network --clear\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser wait 'testid=chat-input'\n  control-openhands browser screenshot --feature F06.tool-visualizers --name planner-batch-100\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 500 -- browser network --filter \"ids=$QA_PLANNER\"  # bug\n  ```",
          "raw": "**Planner discovery with many children (`F06.tool-visualizers`).** Expected: reloading a parent still discovers its `plannerparent`-tagged planner when it has100 child conversations. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5790](https://github.com/OpenHands/software-agent-sdk/issues/5790)): the unchunked100-ID request returns500, shows `Internal Server Error` and omits the planner session connection. A99-child control returns200 and opens both planner and parent sockets. These are genuine idle API-created children, without a model run or browser-storage changes; a new parent ID has no UI-created planner hint.\n  ```sh known-bug F06.tool-visualizers planner-batch-100\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  workspace = fixture / \"workspace\"\n  workspace.mkdir()\n  parent = create(workspace)\n  for index in range(100):\n      create(workspace, parent, {\"plannerparent\":parent} if index == 0 else None)\n  print(parent)\n  PY\n  )\"\n  control-openhands assert --field value --equals 100 -- api GET \"/api/conversations/$QA_ID\" --pick sub_conversation_ids.length\n  QA_PLANNER=\"$(control-openhands api GET \"/api/conversations/$QA_ID\" --pick sub_conversation_ids.0 | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"value\"])')\"\n  control-openhands browser network --clear\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser wait 'testid=chat-input'\n  control-openhands browser screenshot --feature F06.tool-visualizers --name planner-batch-100\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 500 -- browser network --filter \"ids=$QA_PLANNER\"  # bug\n  ```",
          "source_line": 104,
          "expected": [
            "expanding a tool row shows a rich body: terminal command and output (non-zero exit badge `exit N`), file-editor path chip and content."
          ],
          "notes": [
            "Known issue [#17492](https://github.com/OpenHands/OpenHands/issues/17492) (`F06.tool-visualizers`): Failed browser, glob, grep, and file editor tool calls render as successful in chat. Live prerequisite: A working model emitting failed browser, glob, grep, planning-editor and file-editor observations with is_error=true.",
            "Known issue [#17490](https://github.com/OpenHands/OpenHands/issues/17490) (`F06.tool-visualizers`): Terminal commands that fail with an ordinary non-zero exit render as successful in chat. Live prerequisite: A working model producing a completed terminal command with nonzero exit_code and is_error=false.",
            "Known issue [#16341](https://github.com/OpenHands/OpenHands/issues/16341) (`F06.tool-visualizers`): Code-explorer subagent frequently errors. Live prerequisite: A working model with native code-explorer delegation and the original repository task; no delegated-tool output can be generated without credentials.",
            "Known issue [OpenHands/software-agent-sdk#5108](https://github.com/OpenHands/software-agent-sdk/issues/5108) (`F06.tool-visualizers`): The native SDK Gemini editor can report success while corrupting unrelated invalid-UTF8 bytes in the file. Live prerequisite: An Agent Server configured with the native Gemini edit tool set and a real tool edit of an invalid-UTF8 fixture; Gemini ACP uses a different external executor.",
            "Known issue [OpenHands/software-agent-sdk#5096](https://github.com/OpenHands/software-agent-sdk/issues/5096) (`F06.tool-visualizers`): The canonical agent file editor can replace a symlink instead of editing its target; Undo does not restore the link. Live prerequisite: A working model invoking canonical file-editor str_replace/insert through a real symlink, then undo.",
            "Known issue [OpenHands/software-agent-sdk#5074](https://github.com/OpenHands/software-agent-sdk/issues/5074) (`F06.tool-visualizers`): After server restart a native delegated child can remain Running while the parent loses its task mapping/result. Live prerequisite: A native delegated child still running during an owned server crash and parent recovery.",
            "Known issue [OpenHands/software-agent-sdk#5038](https://github.com/OpenHands/software-agent-sdk/issues/5038) (`F06.tool-visualizers`): The agent file editor can refuse valid Chinese UTF8 text as binary when its sample splits a character; Canvas Files uses a separate preview detector. Live prerequisite: A live canonical file-editor view/edit on the specific UTF8 sample alignment; Canvas Files preview uses a separate NUL sniff, not this validator.",
            "Known issue [OpenHands/software-agent-sdk#4983](https://github.com/OpenHands/software-agent-sdk/issues/4983) (`F06.tool-visualizers`): Agent grep can silently miss extended-regex matches when its runtime falls back to system grep. Live prerequisite: A runtime without ripgrep and a live agent grep call using extended regex and escaped literal punctuation.",
            "Known issue [OpenHands/software-agent-sdk#4668](https://github.com/OpenHands/software-agent-sdk/issues/4668) (`F06.tool-visualizers`): A planning editor card can lose old/new content and fail to show the complete edit diff. Live prerequisite: A live planner create/edit yielding PlanningFileEditorObservation with old/new contents for the chat diff.",
            "Known issue [OpenHands/software-agent-sdk#4663](https://github.com/OpenHands/software-agent-sdk/issues/4663) (`F06.tool-visualizers`): Parallel Python-fallback glob calls can change process cwd and return files from the wrong workspace. Live prerequisite: Concurrent real glob/tool calls in distinct workspaces with the Python fallback active.",
            "Known issue [OpenHands/software-agent-sdk#4661](https://github.com/OpenHands/software-agent-sdk/issues/4661) (`F06.tool-visualizers`): Same-name project subagents can resolve to another conversation workspace definition. Live prerequisite: Two native task-enabled conversations with same-name project subagent definitions and a working model for delegation.",
            "Known issue [OpenHands/software-agent-sdk#4583](https://github.com/OpenHands/software-agent-sdk/issues/4583) (`F06.tool-visualizers`): An agent editor insertion at EOF can join onto the last line when the file has no trailing newline. Live prerequisite: A working model invoking file-editor insert at EOF on a file with no trailing newline.",
            "Known issue [OpenHands/software-agent-sdk#4540](https://github.com/OpenHands/software-agent-sdk/issues/4540) (`F06.tool-visualizers`): Synthetic GLM/Kimi tool calls can render as raw tool_call syntax instead of executing. Live prerequisite: Synthetic provider credentials and a GLM/Kimi turn emitting the raw tool_call wrapper."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5790](https://github.com/OpenHands/software-agent-sdk/issues/5790)): the unchunked100-ID request returns500, shows `Internal Server Error` and omits the planner session connection. A99-child control returns200 and opens both planner and parent sockets. These are genuine idle API-created children, without a model run or browser-storage changes; a new parent ID has no UI-created planner hint."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F06--contract-f06-hook-events",
          "title": "Hook events",
          "ids": [
            "F06.hook-events"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "hook executions render as their own rows in the stream."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F06--contract-f06-corrective-nudge",
          "title": "Corrective nudge",
          "ids": [
            "F06.corrective-nudge"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F07",
      "title": "Conversation page, header and management menu",
      "file": "F07-conversation-page.md",
      "sha256": "06f2f175260fbdfb29137ebc6881868279ceef74b6cd63f39a72ae50618849a8",
      "overview": "The conversation page (`/conversations/<id>`) shows the chat pane: a header with\nthe runtime status dot, the editable title, the \"...\" management menu and the\nGit actions, Overview and panel toggles, then the message history and the\ncomposer. From the header a user renames, stops or resumes the runtime, opens\nthe skills, hooks and agent-tools dialogs, exports or downloads the\nconversation, jumps to usage and deletes it.",
      "entry": "- Sidebar conversation row (`conversation-card`), or the home composer submit, which navigates to the new conversation.\n- Direct URL `/conversations/<id>`.\n- Direct URL `/conversations/<id>/panel`, or the header panel button, at phone width (up to 1024 px); at desktop width that URL redirects to `/conversations/<id>` with the drawer open.\n- Hovering a message in the history: **Branch from here** (local backends only).\n- Chips under the composer (git control bar) for conversations in a git workspace (home **Open Workspace**).\n- Header controls inside `testid=chat-pane-header`: the status dot (`server-status-menu-trigger`), the title (`conversation-name-title`), the \"...\" button (`ellipsis-button`, aria \"More options\"), `conversation-git-actions-toggle`, `conversation-overview-toggle`, `right-panel-toggle`.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and an active LLM profile (`control-openhands llm preset deepseek`).\n- A finished conversation: `control-openhands conversation start --prompt \"Reply with the single word: pong\" --wait --timeout 240` prints its `<id>`.\n- A running conversation for the active-only items: `control-openhands conversation start --prompt \"Run the shell command: sleep 90 ; then reply with the word done\" --wait --until running --timeout 120` prints `<running-id>`. It finishes after about 90 s, so start it right before `F07.stop-confirm` (after a Stop it stays `paused` until Start Runtime).\n- A conversation in a git workspace for the git control bar: `control-openhands fixture git-repo --name qa-f07-repo`, then `control-openhands browser goto /` (`conversation start` left the browser on `<id>`), `control-openhands browser click 'testid=open-workspace-button'`, `control-openhands browser click 'role=dialog >> role=combobox[name=\"Select a workspace\"]'`, `control-openhands browser click 'role=dialog >> role=button[name=\"+ Add Workspace\"]'`, `control-openhands browser click 'testid=folder-browser-sidebar-workspace'`, `control-openhands browser click 'testid=folder-browser-up'` three times (to the run directory), `control-openhands browser click 'testid=folder-browser-entry-workspace'`, `control-openhands browser click 'testid=folder-browser-entry-qa-f07-repo'`, `control-openhands browser click 'testid=folder-browser-use'`, `control-openhands browser click 'testid=workspace-launch-button'`, then `control-openhands conversation start --prompt \"Reply with the single word: ok\" --stay --wait --timeout 240` prints `<git-id>`.\n- An automation setup conversation for the setup-leaving bullet, made right after the finished conversation: `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'`, `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`, `control-openhands conversation start --stay --prompt 'Reply with only the word ok. Do not run any tools.' --wait --timeout 180` (prints `<setup-id>`), then `control-openhands browser goto /`.\n- Cloud-only rows (`F07.cloud-only`) are blocked without an OpenHands Cloud account.\n- Desktop viewport unless a bullet says otherwise.",
      "gotchas": "- The stop and delete confirmation buttons carry `cancel-button` and `confirm-button`; role names inside `role=dialog` work too.\n- `stop-button`, `show-skills-button`, `show-hooks-button` and `show-agent-tools-button` also exist in the composer, and `ellipsis-button` in sidebar rows: always scope to `testid=chat-pane-header` or `testid=conversation-name-context-menu`.\n- Stop Runtime (status menu) and Stop Conversation (menu) both navigate to `/conversations`. On a finished local conversation the agent server keeps `finished`; use a running conversation to see `paused` and Start Runtime.\n- Rename has no Escape-to-cancel: Escape leaves the input open and the next blur saves whatever was typed.\n- Escape closes the status-dot menu, as it closes the \"...\" and Git actions menus (`browser count 'testid=server-status-context-menu'` is `0`). A menu opened by click or Enter returns focus to `server-status-menu-trigger`; one opened by hover closes without moving focus (focus left on a menu item falls to `body`). Touch taps cannot be driven with the CLI, so the tap path is unverified.\n- The status menu reads `Running` / `Stop Runtime` for a finished local conversation too; only a paused one shows `Server Stopped` / `Start Runtime`.\n- Panel and Overview columns animate: read their state with `browser wait <sel> --state hidden|visible`, not an immediate `browser visible`.\n- The Skills dialog shows the list read with the page or at the last Refresh: a project skill added later appears after Refresh (or `browser reload`), not on reopening the dialog within 10 minutes. Its own text says to click Refresh.\n- `tabs-pane-header` and `conversation-overview-column` stay in the DOM when hidden: assert with `browser visible` or `aria-pressed`, not `count`.\n- `browser visible 'testid=usage-panel'` right after Display Usage and Cost can read `false`; `browser wait` first.\n- Exports and the zip land in `<run>/private/downloads`; the transcript holds the prompts and the zip holds the full event store. Review before sharing.\n- `--observe` accepts the same selector syntax as other verbs (`'testid=chat-messages-skeleton'`) as well as CSS.\n- A sidebar row click lands on `/conversations/<id>?backend=default-local`: end `--expect-url` regexes without `$` there.\n- `role=button[name=\"Pull\"]` also matches `Pull Request`; add `[exact]`.\n- The Open Workspace folder browser has no path field: walk it with `folder-browser-up` and `folder-browser-entry-<name>`. `fixture git-repo` puts the repo in `<run>/workspace`, three levels above the `workspace` favorite.\n- The repo, branch and Pull/Push chips come from `git remote get-url origin` in the workspace, read when the page loads: reload after changing the remote.\n- The Usage tab logs `GET /api/llm/balance` 404 on a local backend (F27); count it in `browser errors --app-only` but it is not an F07 failure.\n- The Overview drawer has no Automations, Skills, MCP or Secrets sections. The Overview menu offers Workspace and Git rows only, and its drawer opens only Pull Requests (`F27.overview-drawer`).\n- Dialogs move focus inside when they open and keep Tab inside: the Skills and Hooks dialogs start on their close button, Stop and Delete on `cancel-button`, Export on `close-transcript-export-modal`, Agent Tools on `close-system-message-modal`. In the Hooks dialog, focusing Refresh with Tab opens its tooltip, which takes the first Escape; a second Escape closes the dialog.\n- The Hooks dialog shows the workspace's `hooks.json` as of its last read (its first open for the conversation after a page load, or Refresh), not the hooks the conversation started with: after the hooks steps above, `browser goto /conversations/<git-id>` (started before the file existed; its `hook_config` is `null`) and opening the dialog lists both events too. A reopen within 5 minutes of that read shows the same list and sends no request: click Refresh after editing `hooks.json`.\n- A prompt- or agent-type hook shows its text under Commands (the `prompt`, or an agent hook's `system_prompt`). With the Hooks dialog closed (the hooks steps above end that way; otherwise `control-openhands browser click 'testid=close-hooks-modal'`), write `<run>/workspace/qa-f07-repo/.openhands/hooks.json` as `{\"stop\":[{\"hooks\":[{\"type\":\"prompt\",\"prompt\":\"QA_F07 prompt hook\"}]}],\"session_start\":[{\"hooks\":[{\"type\":\"agent\",\"system_prompt\":\"QA_F07 agent hook\"}]}]}`, run `control-openhands browser goto /conversations/<hooks-id>`, open the menu and `control-openhands browser click 'testid=conversation-name-context-menu >> testid=show-hooks-button'`, click `testid=hooks-modal >> testid=refresh-hooks`, then `role=button[name=\"Session Start 1 hook\"]` and `role=button[name=\"Stop 1 hook\"]` inside `testid=hooks-modal` (an event expanded before a Refresh stays expanded, and a second click collapses it, so start from a reopened dialog). In `control-openhands browser snapshot 'testid=hooks-modal'` they expand, in that order, to `Matcher * Commands QA_F07 agent hook Type: agent Timeout: 60s` and `Matcher * Commands QA_F07 prompt hook Type: prompt Timeout: 60s`, and `browser text 'testid=hooks-modal >> pre'` returns `QA_F07 agent hook` and `QA_F07 prompt hook` (`control-openhands browser screenshot --feature F07.hooks-modal --name prompt-agent-hooks`). An agent hook without `system_prompt` shows no box: with `{\"session_start\":[{\"hooks\":[{\"type\":\"agent\"}]}]}`, close, reopen and Refresh, `Session Start 1 hook` expands to `Matcher * Commands Type: agent Timeout: 60s` and `browser count 'testid=hooks-modal >> pre'` is `0`.\n- An agent hook shows its `system_prompt`, the text it runs with, even when it also carries a `prompt`, and an agent hook with only a `prompt` shows no box. With `{\"session_start\":[{\"hooks\":[{\"type\":\"agent\",\"prompt\":\"QA_F07 stray prompt\",\"system_prompt\":\"QA_F07 agent system prompt\"}]}],\"stop\":[{\"hooks\":[{\"type\":\"agent\",\"prompt\":\"QA_F07 prompt only\"}]}]}` in `hooks.json`, close, reopen and Refresh the dialog, then expand both events: `Session Start 1 hook` reads `Matcher * Commands QA_F07 agent system prompt Type: agent Timeout: 60s`, `Stop 1 hook` reads `Matcher * Commands Type: agent Timeout: 60s`, and `control-openhands browser count 'text=QA_F07 stray prompt'` and `browser count 'text=QA_F07 prompt only'` are `0` (`browser screenshot --feature F07.hooks-modal --name agent-hook-system-prompt`). Agent hooks in `hooks.json` really run on the next turn in that workspace (the session-start one shows a `Hook: SessionStart failed` row), so write the recipe's two-event `hooks.json` (`pre_tool_use` and `stop`) back and Refresh afterwards.\n- With the Agent Server down, Refresh in the Hooks dialog shows its own fetch error (#18177): after `control-openhands service stop agent-server` on an open dialog, `refresh-hooks` and `browser wait 'testid=hooks-modal >> text=Failed to fetch hooks' --timeout 30000`, the dialog reads `Failed to fetch hooks. Please try again later.` under its explanation (`browser count 'testid=hooks-modal >> text=Failed to fetch skills'` is `0`). The 502s and the closed WebSocket that `browser errors --app-only` then lists are the outage itself. Run it last, or `restart` afterwards.\n- After the user-message branch in `F07.branch-from-message`, Send stays disabled next to the prefilled text (`browser enabled 'testid=submit-button'` is `false`; Known failure #18202, see F05 Gotchas). Enter in the field sends it.\n- Known issue [#18136](https://github.com/OpenHands/OpenHands/issues/18136) (`F07.cloud-only`): Connect Repo on a cloud conversation does not save the repository on the server. Live prerequisite: An authenticated Cloud sandbox/repository provider and a second fresh browser to compare server repository metadata.\n- Known issue [#17494](https://github.com/OpenHands/OpenHands/issues/17494) (`F07.export-transcript`): Transcript export leaks raw i18n template for ACP tool calls without a title. Live prerequisite: An authenticated ACP session that emits a titleless tool call, then Markdown and HTML export.\n- Known issue [#16876](https://github.com/OpenHands/OpenHands/issues/16876) (`F07.history-skeleton`): Planning-agent history flag stays true forever when sub-conversations fetch fails — infinite chat loading skeleton. Live prerequisite: A real planning sub-conversation whose batch lookup fails or yields null, with the main history available.\n- Known issue [#16727](https://github.com/OpenHands/OpenHands/issues/16727) (`F07.open-by-url`): Conversation corrupted after VM restart: \"Missing event file\". Live prerequisite: A disposable VM interrupted at the event-write boundary or the original missing-event persisted fixture.\n- Known issue [#16356](https://github.com/OpenHands/OpenHands/issues/16356) (`F07.open-by-url`): Conversation LLM config is frozen at creation time — resuming after API key rotation fails permanently with LLMAuthenticationError. Live prerequisite: A working provider key that can be rotated/revoked, an existing conversation using it, and a replacement profile.\n- Known issue [#15543](https://github.com/OpenHands/OpenHands/issues/15543) (`F07.git-control-bar`): Bad parsing for branch retrieval. Live prerequisite: The original Git probe stdout/interleaving that temporarily puts a filename in the branch badge; dirty state alone is not a deterministic trigger.\n- Known issue [OpenHands/software-agent-sdk#5640](https://github.com/OpenHands/software-agent-sdk/issues/5640) (`F07.download-zip`): Download conversation data can return404 after a warm-pool server initializes a custom conversation-storage path. Although closed upstream, the pinned SDK 1.55.0 still reads the default configuration path here; this remains a supported-version distinction, not a freshly reproduced failure. Live prerequisite: A deferred-init warm-pool Agent Server initialized with a non-default conversations_path; this launcher starts an already-initialized server.\n- Known failure [OpenHands/software-agent-sdk#5629](https://github.com/OpenHands/software-agent-sdk/issues/5629) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A second server's native first load rewrote a newer saved title with its startup copy; the connected Canvas header showed that older title. Recipe: create an idle conversation on A and PATCH its title to `QA_SHARED_BEFORE_B_5629_REPEAT`; run pinned SDK Python `python .agents/skills/verify-openhands/scripts/fixtures/shared-storage-server.py --run <run> --seconds 300` in the background. It starts only a bounded no-editor SDK B sharing this run's conversation directory/cipher/session key; its private `server.json` supplies the port and owned operator/PGID. After B listens, PATCH A to `QA_SHARED_AFTER_B_5629_REPEAT` and confirm disk. Run `control-openhands service stop agent-server --run <run>`, then `python .../fixtures/shared-storage-server.py --run <run> --load-id <id>`: native B `/events/count` returned 200 and disk reverted to BEFORE_B. Restart A only to serve Canvas, choose default-local and doctor; use the Add Backend recipe from [F25](F25-backends-and-cloud.md) to connect B at its recorded port with the private key file, then open its card: the header reads BEFORE_B. SIGTERM only the recorded helper operator and verify its port/PGID gone; the helper also bounds runtime and terminates remaining group descendants. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-repeat-before-b-load-5629.json`, `metadata-repeat-after-b-load-5629.json`, `shared-first-load-reverted-5629.png`. The native first-load request is explicit; stale catalog display alone is insufficient proof of this overwrite.\n- Known failure [OpenHands/software-agent-sdk#5539](https://github.com/OpenHands/software-agent-sdk/issues/5539) (`F07.download-zip`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0: three real overlapping receivers of one idle conversation made 929 requests in 20 seconds, including 31 completed corrupt ZIPs with status 200, 106 server 500s, 269 ingress 502s and 133 RemoteProtocolErrors. A sequential 2,000-request control returned valid ZIPs. Recipe (use the pinned SDK Python with httpx): `python .agents/skills/verify-openhands/scripts/fixtures/download-trajectory-overlap.py --base-url <own-base-url> --conversation-id <id> --api-key-file <run>/private/session-key --output-dir <run>/private/trajectory-overlap --seconds 20 --clients 3 --rounds 5000 --slow-ms 2`. It enforces loopback, request/duration/network/uncompressed/member budgets and bounded CRC reads; aborts and unsupported archives remain unverified. While it runs, use `browser goto /conversations/<id>`, open `chat-pane-header >> ellipsis-button`, click `download-trajectory-button`, then `browser downloads --last 3 --inspect` (all CLI commands with `--run <run>`). That Canvas click downloaded an inspectable ZIP; its particular result is not claimed corrupt. Private raw samples stay private. Evidence: `backfill-18309-surface-rechecks/F07.download-zip/trajectory-overlap-5539-summary.json` and `overlapping-download-5539.png`. The summary correction excludes two ambiguous Content-Length differences that could be intentional deadline aborts; only completed corrupt ZIPs count as corrupt downloads.\n- Known issue [OpenHands/software-agent-sdk#5094](https://github.com/OpenHands/software-agent-sdk/issues/5094) (`F07.open-by-url`): After ACP session/load fails, the backend can create a fresh model session while Canvas still shows the old context. Live prerequisite: An authenticated Codex ACP session whose original session/load fails while persisted history remains.\n- Known issue [OpenHands/software-agent-sdk#4893](https://github.com/OpenHands/software-agent-sdk/issues/4893) (`F07.status-menu`): A sandbox frozen mid-run can resume with a stale Running state that rejects normal resume requests. Live prerequisite: A disposable sandbox/VM frozen mid-run with a live model and persisted ownership state.\n- Known failure [OpenHands/software-agent-sdk#4800](https://github.com/OpenHands/software-agent-sdk/issues/4800) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A refused rename changed the server's GET title while `meta.json` kept the previous title; graceful shutdown subsequently persisted the rejected title. Recipe: create an idle dummy-profile conversation, then double-click `conversation-name-title`, fill `conversation-name-input` with `QA_META_BEFORE_4800` and press Enter; verify GET/disk. Record the original mode and temporarily `chmod 0500 <run>/private/state/dev_conversations/<id-hex>` to deny an atomic metadata replacement. Rename through that UI to `QA_META_REFUSED_4800`: the permission-denied toast/HTTP 500 appears, GET shows REFUSED, disk still shows BEFORE. Restore the exact original directory mode in `finally`, then `restart`, `doctor`, GET and reload (CLI with `--run <run>`): REFUSED was persisted during graceful shutdown. Only the owned temporary directory is changed; evidence contains title/timestamp fields, not stored secrets. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-before-4800.json`, `metadata-denied-4800.json`, `refused-title-4800.png`, `metadata-after-graceful-restart-4800.json`. Tags/autotitle and ownership-conflict branches remain untested.\n\n- Known issue [#16877](https://github.com/OpenHands/OpenHands/issues/16877) (`F07.open-by-url`): Untitled terminal-status conversations are reported to keep the 3-second title polling interval. On frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, a real Local `autotitle:false`/idle conversation retained `title:null`, while eight browser batch GETs had 30.008–30.031-second intervals over 210 seconds. The direct adapter supplies a default display title. This does not exercise the Cloud null-title IDLE/FINISHED branch, which needs an approved Cloud conversation with a failed/unsupported title job. Run `backfill-18309-coordinator-checks`, Agent Server/SDK 1.54.0, automation 1.19.3; retained `evidence/F07.open-by-url/16877-local-polling.json` and `16877-local-idle.png`. Arrange the empty conversation with `api POST /api/conversations --write --data @<idle-autotitle-disabled-payload>`, open `/conversations/<id>`, clear `browser network`, leave the tab foreground, then read `browser network --filter ids= --last 100` and `conversation status <id>`. No model call or response was used.",
      "features": [
        {
          "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": "the header Git actions menu offers Commits, Pull, Push, Create PR and Create New Branch; each pre-fills the composer without sending, and Send becomes enabled so the user can submit or edit the prompt."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F07--open-by-url",
          "title": "Open by URL",
          "ids": [
            "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>\"}`.",
          "raw": "**Open by URL (`F07.open-by-url`).** 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>\"}`.",
          "source_line": 61,
          "expected": [
            "`/conversations/<id>` shows header, history and composer, and remembers the id as the backend's last conversation."
          ],
          "notes": [
            "Known issue [#16727](https://github.com/OpenHands/OpenHands/issues/16727) (`F07.open-by-url`): Conversation corrupted after VM restart: \"Missing event file\". Live prerequisite: A disposable VM interrupted at the event-write boundary or the original missing-event persisted fixture.",
            "Known issue [#16356](https://github.com/OpenHands/OpenHands/issues/16356) (`F07.open-by-url`): Conversation LLM config is frozen at creation time — resuming after API key rotation fails permanently with LLMAuthenticationError. Live prerequisite: A working provider key that can be rotated/revoked, an existing conversation using it, and a replacement profile.",
            "Known issue [OpenHands/software-agent-sdk#5094](https://github.com/OpenHands/software-agent-sdk/issues/5094) (`F07.open-by-url`): After ACP session/load fails, the backend can create a fresh model session while Canvas still shows the old context. Live prerequisite: An authenticated Codex ACP session whose original session/load fails while persisted history remains.",
            "Known issue [#16877](https://github.com/OpenHands/OpenHands/issues/16877) (`F07.open-by-url`): Untitled terminal-status conversations are reported to keep the 3-second title polling interval. On frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, a real Local `autotitle:false`/idle conversation retained `title:null`, while eight browser batch GETs had 30.008–30.031-second intervals over 210 seconds. The direct adapter supplies a default display title. This does not exercise the Cloud null-title IDLE/FINISHED branch, which needs an approved Cloud conversation with a failed/unsupported title job. Run `backfill-18309-coordinator-checks`, Agent Server/SDK 1.54.0, automation 1.19.3; retained `evidence/F07.open-by-url/16877-local-polling.json` and `16877-local-idle.png`. Arrange the empty conversation with `api POST /api/conversations --write --data @<idle-autotitle-disabled-payload>`, open `/conversations/<id>`, clear `browser network`, leave the tab foreground, then read `browser network --filter ids= --last 100` and `conversation status <id>`. No model call or response was used."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--leaving-an-automation-setup-conversation",
          "title": "Leaving an automation setup conversation",
          "ids": [
            "F07.open-by-url"
          ],
          "body": "A conversation tagged `automationsetup: draft` (started from `/automations/setup`, see `F04.automation-setup-tag`) opens as a setup page (`testid=automation-setup-topbar`, no `chat-pane-header`). Make an empty conversation: `control-openhands browser goto /`, `control-openhands browser click 'testid=conversation-panel-new-thread-picker'`, `control-openhands browser click 'testid=launch-no-workspace' --expect-url '/conversations/'` (note `<plain-id>`; `control-openhands api GET /api/conversations/<plain-id> --pick tags` is only `clientsource`). Open the setup conversation from the sidebar (`control-openhands browser click 'a[href*=\"/conversations/<setup-id>\"] >> nth=0' --expect-url '/conversations/<setup-id>'`), then go back the same way (`'a[href*=\"/conversations/<plain-id>\"] >> nth=0'`). Expected: `<plain-id>` opens as a normal conversation: `control-openhands browser wait 'testid=chat-pane-header'`, `browser count 'testid=chat-pane-header'` is `1`, `browser count 'testid=automation-setup-topbar'` is `0`, and `api GET /api/conversations/<plain-id> --pick tags` is still only `clientsource`, also after `browser reload` and after repeating the round trip; the sidebar row of `<plain-id>` has no `Automation setup: Draft` chip (`browser screenshot --feature F07.open-by-url --name plain-stays-plain`). The setup conversation is in this tab's `openhands-automation-setup-handoffs` sessionStorage entry during the check (`control-openhands browser storage --session --values`). The same holds at phone width through the sidebar drawer (`sidebar-mobile-menu-toggle`, then the rows inside `testid=sidebar-mobile-drawer`).",
          "raw": "**Leaving an automation setup conversation (`F07.open-by-url`).** A conversation tagged `automationsetup: draft` (started from `/automations/setup`, see `F04.automation-setup-tag`) opens as a setup page (`testid=automation-setup-topbar`, no `chat-pane-header`). Make an empty conversation: `control-openhands browser goto /`, `control-openhands browser click 'testid=conversation-panel-new-thread-picker'`, `control-openhands browser click 'testid=launch-no-workspace' --expect-url '/conversations/'` (note `<plain-id>`; `control-openhands api GET /api/conversations/<plain-id> --pick tags` is only `clientsource`). Open the setup conversation from the sidebar (`control-openhands browser click 'a[href*=\"/conversations/<setup-id>\"] >> nth=0' --expect-url '/conversations/<setup-id>'`), then go back the same way (`'a[href*=\"/conversations/<plain-id>\"] >> nth=0'`). Expected: `<plain-id>` opens as a normal conversation: `control-openhands browser wait 'testid=chat-pane-header'`, `browser count 'testid=chat-pane-header'` is `1`, `browser count 'testid=automation-setup-topbar'` is `0`, and `api GET /api/conversations/<plain-id> --pick tags` is still only `clientsource`, also after `browser reload` and after repeating the round trip; the sidebar row of `<plain-id>` has no `Automation setup: Draft` chip (`browser screenshot --feature F07.open-by-url --name plain-stays-plain`). The setup conversation is in this tab's `openhands-automation-setup-handoffs` sessionStorage entry during the check (`control-openhands browser storage --session --values`). The same holds at phone width through the sidebar drawer (`sidebar-mobile-menu-toggle`, then the rows inside `testid=sidebar-mobile-drawer`).",
          "source_line": 62,
          "expected": [
            "`/conversations/<id>` shows header, history and composer, and remembers the id as the backend's last conversation."
          ],
          "notes": [
            "Known issue [#16727](https://github.com/OpenHands/OpenHands/issues/16727) (`F07.open-by-url`): Conversation corrupted after VM restart: \"Missing event file\". Live prerequisite: A disposable VM interrupted at the event-write boundary or the original missing-event persisted fixture.",
            "Known issue [#16356](https://github.com/OpenHands/OpenHands/issues/16356) (`F07.open-by-url`): Conversation LLM config is frozen at creation time — resuming after API key rotation fails permanently with LLMAuthenticationError. Live prerequisite: A working provider key that can be rotated/revoked, an existing conversation using it, and a replacement profile.",
            "Known issue [OpenHands/software-agent-sdk#5094](https://github.com/OpenHands/software-agent-sdk/issues/5094) (`F07.open-by-url`): After ACP session/load fails, the backend can create a fresh model session while Canvas still shows the old context. Live prerequisite: An authenticated Codex ACP session whose original session/load fails while persisted history remains.",
            "Known issue [#16877](https://github.com/OpenHands/OpenHands/issues/16877) (`F07.open-by-url`): Untitled terminal-status conversations are reported to keep the 3-second title polling interval. On frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, a real Local `autotitle:false`/idle conversation retained `title:null`, while eight browser batch GETs had 30.008–30.031-second intervals over 210 seconds. The direct adapter supplies a default display title. This does not exercise the Cloud null-title IDLE/FINISHED branch, which needs an approved Cloud conversation with a failed/unsupported title job. Run `backfill-18309-coordinator-checks`, Agent Server/SDK 1.54.0, automation 1.19.3; retained `evidence/F07.open-by-url/16877-local-polling.json` and `16877-local-idle.png`. Arrange the empty conversation with `api POST /api/conversations --write --data @<idle-autotitle-disabled-payload>`, open `/conversations/<id>`, clear `browser network`, leave the tab foreground, then read `browser network --filter ids= --last 100` and `conversation status <id>`. No model call or response was used."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--unknown-id",
          "title": "Unknown id",
          "ids": [
            "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`.",
          "raw": "**Unknown id (`F07.missing-redirect`).** 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`.",
          "source_line": 63,
          "expected": [
            "an unknown id toasts \"This conversation does not exist…\" and redirects to `/conversations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--rename-inline",
          "title": "Rename inline",
          "ids": [
            "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'`.",
          "raw": "**Rename inline (`F07.rename-inline`).** 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'`.",
          "source_line": 64,
          "expected": [
            "double-click the title (or menu Rename), Enter or blur saves with a toast; an empty value reverts."
          ],
          "notes": [
            "Known failure [OpenHands/software-agent-sdk#5629](https://github.com/OpenHands/software-agent-sdk/issues/5629) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A second server's native first load rewrote a newer saved title with its startup copy; the connected Canvas header showed that older title. Recipe: create an idle conversation on A and PATCH its title to `QA_SHARED_BEFORE_B_5629_REPEAT`; run pinned SDK Python `python .agents/skills/verify-openhands/scripts/fixtures/shared-storage-server.py --run <run> --seconds 300` in the background. It starts only a bounded no-editor SDK B sharing this run's conversation directory/cipher/session key; its private `server.json` supplies the port and owned operator/PGID. After B listens, PATCH A to `QA_SHARED_AFTER_B_5629_REPEAT` and confirm disk. Run `control-openhands service stop agent-server --run <run>`, then `python .../fixtures/shared-storage-server.py --run <run> --load-id <id>`: native B `/events/count` returned 200 and disk reverted to BEFORE_B. Restart A only to serve Canvas, choose default-local and doctor; use the Add Backend recipe from [F25](F25-backends-and-cloud.md) to connect B at its recorded port with the private key file, then open its card: the header reads BEFORE_B. SIGTERM only the recorded helper operator and verify its port/PGID gone; the helper also bounds runtime and terminates remaining group descendants. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-repeat-before-b-load-5629.json`, `metadata-repeat-after-b-load-5629.json`, `shared-first-load-reverted-5629.png`. The native first-load request is explicit; stale catalog display alone is insufficient proof of this overwrite.",
            "Known failure [OpenHands/software-agent-sdk#4800](https://github.com/OpenHands/software-agent-sdk/issues/4800) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A refused rename changed the server's GET title while `meta.json` kept the previous title; graceful shutdown subsequently persisted the rejected title. Recipe: create an idle dummy-profile conversation, then double-click `conversation-name-title`, fill `conversation-name-input` with `QA_META_BEFORE_4800` and press Enter; verify GET/disk. Record the original mode and temporarily `chmod 0500 <run>/private/state/dev_conversations/<id-hex>` to deny an atomic metadata replacement. Rename through that UI to `QA_META_REFUSED_4800`: the permission-denied toast/HTTP 500 appears, GET shows REFUSED, disk still shows BEFORE. Restore the exact original directory mode in `finally`, then `restart`, `doctor`, GET and reload (CLI with `--run <run>`): REFUSED was persisted during graceful shutdown. Only the owned temporary directory is changed; evidence contains title/timestamp fields, not stored secrets. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-before-4800.json`, `metadata-denied-4800.json`, `refused-title-4800.png`, `metadata-after-graceful-restart-4800.json`. Tags/autotitle and ownership-conflict branches remain untested."
          ],
          "annotations": [],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F07--rename-from-the-menu-empty-value",
          "title": "Rename from the menu, empty value",
          "ids": [
            "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.",
          "raw": "**Rename from the menu, empty value (`F07.rename-inline`).** 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.",
          "source_line": 65,
          "expected": [
            "double-click the title (or menu Rename), Enter or blur saves with a toast; an empty value reverts."
          ],
          "notes": [
            "Known failure [OpenHands/software-agent-sdk#5629](https://github.com/OpenHands/software-agent-sdk/issues/5629) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A second server's native first load rewrote a newer saved title with its startup copy; the connected Canvas header showed that older title. Recipe: create an idle conversation on A and PATCH its title to `QA_SHARED_BEFORE_B_5629_REPEAT`; run pinned SDK Python `python .agents/skills/verify-openhands/scripts/fixtures/shared-storage-server.py --run <run> --seconds 300` in the background. It starts only a bounded no-editor SDK B sharing this run's conversation directory/cipher/session key; its private `server.json` supplies the port and owned operator/PGID. After B listens, PATCH A to `QA_SHARED_AFTER_B_5629_REPEAT` and confirm disk. Run `control-openhands service stop agent-server --run <run>`, then `python .../fixtures/shared-storage-server.py --run <run> --load-id <id>`: native B `/events/count` returned 200 and disk reverted to BEFORE_B. Restart A only to serve Canvas, choose default-local and doctor; use the Add Backend recipe from [F25](F25-backends-and-cloud.md) to connect B at its recorded port with the private key file, then open its card: the header reads BEFORE_B. SIGTERM only the recorded helper operator and verify its port/PGID gone; the helper also bounds runtime and terminates remaining group descendants. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-repeat-before-b-load-5629.json`, `metadata-repeat-after-b-load-5629.json`, `shared-first-load-reverted-5629.png`. The native first-load request is explicit; stale catalog display alone is insufficient proof of this overwrite.",
            "Known failure [OpenHands/software-agent-sdk#4800](https://github.com/OpenHands/software-agent-sdk/issues/4800) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A refused rename changed the server's GET title while `meta.json` kept the previous title; graceful shutdown subsequently persisted the rejected title. Recipe: create an idle dummy-profile conversation, then double-click `conversation-name-title`, fill `conversation-name-input` with `QA_META_BEFORE_4800` and press Enter; verify GET/disk. Record the original mode and temporarily `chmod 0500 <run>/private/state/dev_conversations/<id-hex>` to deny an atomic metadata replacement. Rename through that UI to `QA_META_REFUSED_4800`: the permission-denied toast/HTTP 500 appears, GET shows REFUSED, disk still shows BEFORE. Restore the exact original directory mode in `finally`, then `restart`, `doctor`, GET and reload (CLI with `--run <run>`): REFUSED was persisted during graceful shutdown. Only the owned temporary directory is changed; evidence contains title/timestamp fields, not stored secrets. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-before-4800.json`, `metadata-denied-4800.json`, `refused-title-4800.png`, `metadata-after-graceful-restart-4800.json`. Tags/autotitle and ownership-conflict branches remain untested."
          ],
          "annotations": [],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F07--history-skeleton-and-sidebar-entry",
          "title": "History skeleton and sidebar entry",
          "ids": [
            "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`.",
          "raw": "**History skeleton and sidebar entry (`F07.history-skeleton`, `F07.open-by-url`).** 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`.",
          "source_line": 66,
          "expected": [
            "opening a conversation shows a message skeleton until its history has loaded.",
            "`/conversations/<id>` shows header, history and composer, and remembers the id as the backend's last conversation."
          ],
          "notes": [
            "Known issue [#16876](https://github.com/OpenHands/OpenHands/issues/16876) (`F07.history-skeleton`): Planning-agent history flag stays true forever when sub-conversations fetch fails — infinite chat loading skeleton. Live prerequisite: A real planning sub-conversation whose batch lookup fails or yields null, with the main history available.",
            "Known issue [#16727](https://github.com/OpenHands/OpenHands/issues/16727) (`F07.open-by-url`): Conversation corrupted after VM restart: \"Missing event file\". Live prerequisite: A disposable VM interrupted at the event-write boundary or the original missing-event persisted fixture.",
            "Known issue [#16356](https://github.com/OpenHands/OpenHands/issues/16356) (`F07.open-by-url`): Conversation LLM config is frozen at creation time — resuming after API key rotation fails permanently with LLMAuthenticationError. Live prerequisite: A working provider key that can be rotated/revoked, an existing conversation using it, and a replacement profile.",
            "Known issue [OpenHands/software-agent-sdk#5094](https://github.com/OpenHands/software-agent-sdk/issues/5094) (`F07.open-by-url`): After ACP session/load fails, the backend can create a fresh model session while Canvas still shows the old context. Live prerequisite: An authenticated Codex ACP session whose original session/load fails while persisted history remains.",
            "Known issue [#16877](https://github.com/OpenHands/OpenHands/issues/16877) (`F07.open-by-url`): Untitled terminal-status conversations are reported to keep the 3-second title polling interval. On frozen target `fa1f724ab5737a17453ad6081129eb91c5a423c6`, a real Local `autotitle:false`/idle conversation retained `title:null`, while eight browser batch GETs had 30.008–30.031-second intervals over 210 seconds. The direct adapter supplies a default display title. This does not exercise the Cloud null-title IDLE/FINISHED branch, which needs an approved Cloud conversation with a failed/unsupported title job. Run `backfill-18309-coordinator-checks`, Agent Server/SDK 1.54.0, automation 1.19.3; retained `evidence/F07.open-by-url/16877-local-polling.json` and `16877-local-idle.png`. Arrange the empty conversation with `api POST /api/conversations --write --data @<idle-autotitle-disabled-payload>`, open `/conversations/<id>`, clear `browser network`, leave the tab foreground, then read `browser network --filter ids= --last 100` and `conversation status <id>`. No model call or response was used."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--status-menu-stop",
          "title": "Status menu, stop",
          "ids": [
            "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`.",
          "raw": "**Status menu, stop (`F07.status-menu`).** 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`.",
          "source_line": 67,
          "expected": [
            "hovering the status dot (or tapping it on touch) opens a menu with the state and Stop Runtime (active) or Start Runtime (paused)."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4893](https://github.com/OpenHands/software-agent-sdk/issues/4893) (`F07.status-menu`): A sandbox frozen mid-run can resume with a stale Running state that rejects normal resume requests. Live prerequisite: A disposable sandbox/VM frozen mid-run with a live model and persisted ownership state."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--menu",
          "title": "Menu",
          "ids": [
            "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`).",
          "raw": "**Menu (`F07.menu-open`).** 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`).",
          "source_line": 68,
          "expected": [
            "the \"...\" menu opens under the title; Escape closes it and returns focus to \"...\", and so does an outside click (without the focus move)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--stop-with-confirmation",
          "title": "Stop with confirmation",
          "ids": [
            "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`, test ids `cancel-button` and `confirm-button`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` closes it and `control-openhands conversation status <running-id>` stays `running`. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> testid=confirm-button' --expect-url '/conversations$'`; the toasts read `Conversation stopped` and `conversation status <running-id>` is `paused`.",
          "raw": "**Stop with confirmation (`F07.stop-confirm`).** 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`, test ids `cancel-button` and `confirm-button`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` closes it and `control-openhands conversation status <running-id>` stays `running`. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> testid=confirm-button' --expect-url '/conversations$'`; the toasts read `Conversation stopped` and `conversation status <running-id>` is `paused`.",
          "source_line": 69,
          "expected": [
            "Stop Conversation asks for confirmation; Confirm Close pauses the conversation and returns to `/conversations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--branch-from-a-message",
          "title": "Branch from a message",
          "ids": [
            "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`).",
          "raw": "**Branch from a message (`F07.branch-from-message`).** 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`).",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [
            "After the user-message branch in `F07.branch-from-message`, Send stays disabled next to the prefilled text (`browser enabled 'testid=submit-button'` is `false`; Known failure #18202, see F05 Gotchas). Enter in the field sends it."
          ],
          "annotations": [],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F07--delete-with-confirmation",
          "title": "Delete with confirmation",
          "ids": [
            "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` (`cancel-button`) and `Confirm Delete` (`confirm-button`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` keeps the URL. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> testid=confirm-button' --expect-url '/conversations$'`; `control-openhands api GET '/api/conversations?ids=<id>'` returns `[null]` and `browser goto /conversations/<id>` redirects as in `F07.missing-redirect`.",
          "raw": "**Delete with confirmation (`F07.delete-confirm`).** 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` (`cancel-button`) and `Confirm Delete` (`confirm-button`). `control-openhands browser click 'role=dialog >> testid=cancel-button'` keeps the URL. Repeat the two clicks, then `control-openhands browser click 'role=dialog >> testid=confirm-button' --expect-url '/conversations$'`; `control-openhands api GET '/api/conversations?ids=<id>'` returns `[null]` and `browser goto /conversations/<id>` redirects as in `F07.missing-redirect`.",
          "source_line": 71,
          "expected": [
            "Delete Conversation names the title in a confirmation; Cancel keeps it, Confirm Delete removes it and returns to `/conversations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--paused-conversation",
          "title": "Paused conversation",
          "ids": [
            "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`.",
          "raw": "**Paused conversation (`F07.menu-items-by-state`, `F07.status-menu`).** 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`.",
          "source_line": 72,
          "expected": [
            "Show Available Hooks and Stop Conversation appear only while the conversation is active.",
            "hovering the status dot (or tapping it on touch) opens a menu with the state and Stop Runtime (active) or Start Runtime (paused)."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4893](https://github.com/OpenHands/software-agent-sdk/issues/4893) (`F07.status-menu`): A sandbox frozen mid-run can resume with a stale Running state that rejects normal resume requests. Live prerequisite: A disposable sandbox/VM frozen mid-run with a live model and persisted ownership state."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--hooks",
          "title": "Hooks",
          "ids": [
            "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.",
          "raw": "**Hooks (`F07.hooks-modal`).** 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.",
          "source_line": 73,
          "expected": [
            "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.\""
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--skills",
          "title": "Skills",
          "ids": [
            "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`).",
          "raw": "**Skills (`F07.skills-modal`).** 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`).",
          "source_line": 74,
          "expected": [
            "Show Available Skills lists the loaded skills with expandable rows and Refresh."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--project-skills-and-configured-hooks",
          "title": "Project skills and configured hooks",
          "ids": [
            "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'`.",
          "raw": "**Project skills and configured hooks (`F07.skills-modal-project`, `F07.hooks-modal`).** 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'`.",
          "source_line": 75,
          "expected": [
            "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.",
            "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.\""
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--agent-tools",
          "title": "Agent tools",
          "ids": [
            "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'` (backend-provided count; `10` on Agent Server 1.54.0: terminal, file_editor, task_tracker, canvas_ui_control, launch_child_conversation, request_secret (local backends, see `F14.agent-request`), finish, think, switch_llm, invoke_skill), `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'`.",
          "raw": "**Agent tools (`F07.agent-tools-modal`).** 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'` (backend-provided count; `10` on Agent Server 1.54.0: terminal, file_editor, task_tracker, canvas_ui_control, launch_child_conversation, request_secret (local backends, see `F14.agent-request`), finish, think, switch_llm, invoke_skill), `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'`.",
          "source_line": 76,
          "expected": [
            "Show Agent Tools & Metadata shows the system message and the tool list."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--export-transcript",
          "title": "Export transcript",
          "ids": [
            "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.",
          "raw": "**Export transcript (`F07.export-transcript`).** 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.",
          "source_line": 77,
          "expected": [
            "Export… downloads a Markdown or HTML transcript with optional tool details and timestamps."
          ],
          "notes": [
            "Known issue [#17494](https://github.com/OpenHands/OpenHands/issues/17494) (`F07.export-transcript`): Transcript export leaks raw i18n template for ACP tool calls without a title. Live prerequisite: An authenticated ACP session that emits a titleless tool call, then Markdown and HTML export."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--download-data",
          "title": "Download data",
          "ids": [
            "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 (`**********`).",
          "raw": "**Download data (`F07.download-zip`).** 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 (`**********`).",
          "source_line": 78,
          "expected": [
            "Download conversation data downloads `conversation_<id>.zip`."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5640](https://github.com/OpenHands/software-agent-sdk/issues/5640) (`F07.download-zip`): Download conversation data can return404 after a warm-pool server initializes a custom conversation-storage path. Although closed upstream, the pinned SDK 1.55.0 still reads the default configuration path here; this remains a supported-version distinction, not a freshly reproduced failure. Live prerequisite: A deferred-init warm-pool Agent Server initialized with a non-default conversations_path; this launcher starts an already-initialized server.",
            "Known failure [OpenHands/software-agent-sdk#5539](https://github.com/OpenHands/software-agent-sdk/issues/5539) (`F07.download-zip`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0: three real overlapping receivers of one idle conversation made 929 requests in 20 seconds, including 31 completed corrupt ZIPs with status 200, 106 server 500s, 269 ingress 502s and 133 RemoteProtocolErrors. A sequential 2,000-request control returned valid ZIPs. Recipe (use the pinned SDK Python with httpx): `python .agents/skills/verify-openhands/scripts/fixtures/download-trajectory-overlap.py --base-url <own-base-url> --conversation-id <id> --api-key-file <run>/private/session-key --output-dir <run>/private/trajectory-overlap --seconds 20 --clients 3 --rounds 5000 --slow-ms 2`. It enforces loopback, request/duration/network/uncompressed/member budgets and bounded CRC reads; aborts and unsupported archives remain unverified. While it runs, use `browser goto /conversations/<id>`, open `chat-pane-header >> ellipsis-button`, click `download-trajectory-button`, then `browser downloads --last 3 --inspect` (all CLI commands with `--run <run>`). That Canvas click downloaded an inspectable ZIP; its particular result is not claimed corrupt. Private raw samples stay private. Evidence: `backfill-18309-surface-rechecks/F07.download-zip/trajectory-overlap-5539-summary.json` and `overlapping-download-5539.png`. The summary correction excludes two ambiguous Content-Length differences that could be intentional deadline aborts; only completed corrupt ZIPs count as corrupt downloads."
          ],
          "annotations": [],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--usage",
          "title": "Usage",
          "ids": [
            "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).",
          "raw": "**Usage (`F07.display-cost`).** 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).",
          "source_line": 79,
          "expected": [
            "Display Usage and Cost opens the right panel on the Usage tab."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--right-panel",
          "title": "Right panel",
          "ids": [
            "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).",
          "raw": "**Right panel (`F07.right-panel-toggle`).** 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).",
          "source_line": 80,
          "expected": [
            "the header panel button shows and hides the right tabs panel (closed again after a reload)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--overview",
          "title": "Overview",
          "ids": [
            "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.",
          "raw": "**Overview (`F07.overview-toggle`).** 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.",
          "source_line": 81,
          "expected": [
            "the header (i) button shows and hides the Overview column, which is exclusive with the right panel."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--git-actions",
          "title": "Git actions",
          "ids": [
            "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. `control-openhands browser enabled 'testid=submit-button'` should be `true`, without typing. 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' ''`. Known failure ([#18202](https://github.com/OpenHands/OpenHands/issues/18202), reproduced 2026-10-09 on `main@3500d9e5`): after Pull, Send stays disabled until the user types.",
          "raw": "**Git actions (`F07.git-actions-menu`).** 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. `control-openhands browser enabled 'testid=submit-button'` should be `true`, without typing. 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' ''`. Known failure ([#18202](https://github.com/OpenHands/OpenHands/issues/18202), reproduced 2026-10-09 on `main@3500d9e5`): after Pull, Send stays disabled until the user types.",
          "source_line": 82,
          "expected": [
            "the header Git actions menu offers Commits, Pull, Push, Create PR and Create New Branch; each pre-fills the composer without sending, and Send becomes enabled so the user can submit or edit the prompt."
          ],
          "notes": [],
          "annotations": [
            "Known failure ([#18202](https://github.com/OpenHands/OpenHands/issues/18202), reproduced 2026-10-09 on `main@3500d9e5`): after Pull, Send stays disabled until the user types."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F07--panel-route",
          "title": "Panel route",
          "ids": [
            "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.",
          "raw": "**Panel route (`F07.panel-route`).** 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.",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--git-control-bar",
          "title": "Git control bar",
          "ids": [
            "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`.",
          "raw": "**Git control bar (`F07.git-control-bar`).** 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`.",
          "source_line": 84,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#15543](https://github.com/OpenHands/OpenHands/issues/15543) (`F07.git-control-bar`): Bad parsing for branch retrieval. Live prerequisite: The original Git probe stdout/interleaving that temporarily puts a filename in the branch badge; dirty state alone is not a deterministic trigger."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--pull-sends-at-once",
          "title": "Pull sends at once",
          "ids": [
            "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.",
          "raw": "**Pull sends at once (`F07.git-control-bar-send`).** 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.",
          "source_line": 85,
          "expected": [
            "the Pull, Push and Pull Request chips send their git prompt to the agent at once."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F07--cloud-only",
          "title": "Cloud-only",
          "ids": [
            "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).",
          "raw": "**Cloud-only (`F07.cloud-only`).** 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).",
          "source_line": 86,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#18136](https://github.com/OpenHands/OpenHands/issues/18136) (`F07.cloud-only`): Connect Repo on a cloud conversation does not save the repository on the server. Live prerequisite: An authenticated Cloud sandbox/repository provider and a second fresh browser to compare server repository metadata."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F07--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F07.phone`).** 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`.",
          "source_line": 87,
          "expected": [
            "at 390 px the header keeps its controls and the info dialogs fit the viewport."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F07--whitespace-only-header-rename",
          "title": "Whitespace-only header rename",
          "ids": [
            "F07.rename-inline"
          ],
          "body": "After the named-title check, run `control-openhands browser network --clear`, double-click `testid=conversation-name-title`, fill `testid=conversation-name-input` with one space, and `control-openhands browser press Enter`. Read `control-openhands browser network --filter '/api/conversations' --bodies`, then reload and read the title. Expected: restore the previous title with no write. Known failure (reproduced 2026-10-12, [#17061](https://github.com/OpenHands/OpenHands/issues/17061)): Canvas submits `PATCH` with an empty title. Agent Server 1.54.0 rejects it with 422 and restores the prior title; a persistently blank server title was not observed.",
          "raw": "**Whitespace-only header rename (`F07.rename-inline`).** After the named-title check, run `control-openhands browser network --clear`, double-click `testid=conversation-name-title`, fill `testid=conversation-name-input` with one space, and `control-openhands browser press Enter`. Read `control-openhands browser network --filter '/api/conversations' --bodies`, then reload and read the title. Expected: restore the previous title with no write. Known failure (reproduced 2026-10-12, [#17061](https://github.com/OpenHands/OpenHands/issues/17061)): Canvas submits `PATCH` with an empty title. Agent Server 1.54.0 rejects it with 422 and restores the prior title; a persistently blank server title was not observed.",
          "source_line": 89,
          "expected": [
            "double-click the title (or menu Rename), Enter or blur saves with a toast; an empty value reverts."
          ],
          "notes": [
            "Known failure [OpenHands/software-agent-sdk#5629](https://github.com/OpenHands/software-agent-sdk/issues/5629) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A second server's native first load rewrote a newer saved title with its startup copy; the connected Canvas header showed that older title. Recipe: create an idle conversation on A and PATCH its title to `QA_SHARED_BEFORE_B_5629_REPEAT`; run pinned SDK Python `python .agents/skills/verify-openhands/scripts/fixtures/shared-storage-server.py --run <run> --seconds 300` in the background. It starts only a bounded no-editor SDK B sharing this run's conversation directory/cipher/session key; its private `server.json` supplies the port and owned operator/PGID. After B listens, PATCH A to `QA_SHARED_AFTER_B_5629_REPEAT` and confirm disk. Run `control-openhands service stop agent-server --run <run>`, then `python .../fixtures/shared-storage-server.py --run <run> --load-id <id>`: native B `/events/count` returned 200 and disk reverted to BEFORE_B. Restart A only to serve Canvas, choose default-local and doctor; use the Add Backend recipe from [F25](F25-backends-and-cloud.md) to connect B at its recorded port with the private key file, then open its card: the header reads BEFORE_B. SIGTERM only the recorded helper operator and verify its port/PGID gone; the helper also bounds runtime and terminates remaining group descendants. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-repeat-before-b-load-5629.json`, `metadata-repeat-after-b-load-5629.json`, `shared-first-load-reverted-5629.png`. The native first-load request is explicit; stale catalog display alone is insufficient proof of this overwrite.",
            "Known failure [OpenHands/software-agent-sdk#4800](https://github.com/OpenHands/software-agent-sdk/issues/4800) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A refused rename changed the server's GET title while `meta.json` kept the previous title; graceful shutdown subsequently persisted the rejected title. Recipe: create an idle dummy-profile conversation, then double-click `conversation-name-title`, fill `conversation-name-input` with `QA_META_BEFORE_4800` and press Enter; verify GET/disk. Record the original mode and temporarily `chmod 0500 <run>/private/state/dev_conversations/<id-hex>` to deny an atomic metadata replacement. Rename through that UI to `QA_META_REFUSED_4800`: the permission-denied toast/HTTP 500 appears, GET shows REFUSED, disk still shows BEFORE. Restore the exact original directory mode in `finally`, then `restart`, `doctor`, GET and reload (CLI with `--run <run>`): REFUSED was persisted during graceful shutdown. Only the owned temporary directory is changed; evidence contains title/timestamp fields, not stored secrets. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-before-4800.json`, `metadata-denied-4800.json`, `refused-title-4800.png`, `metadata-after-graceful-restart-4800.json`. Tags/autotitle and ownership-conflict branches remain untested."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#17061](https://github.com/OpenHands/OpenHands/issues/17061)): Canvas submits `PATCH` with an empty title. Agent Server 1.54.0 rejects it with 422 and restores the prior title; a persistently blank server title was not observed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F07--already-deleted-conversation",
          "title": "Already-deleted conversation",
          "ids": [
            "F07.delete-confirm",
            "F07.rename-inline"
          ],
          "body": "Expected: a stale action reports that the conversation is missing, and a stale rename never reports success. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5800](https://github.com/OpenHands/software-agent-sdk/issues/5800)): confirming Delete after another client removed this disposable conversation returns400 and displays `Bad Request`, instead of a missing-conversation404. The separate stale-rename control receives PATCH200 but re-fetches missing metadata and displays not-found, so its false-success variant is prevented by Canvas. Pause/interrupt were not tested.\n  ```sh known-bug F07.delete-confirm already-deleted\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  workspace = fixture / \"workspace\"\n  workspace.mkdir()\n  print(create(workspace))\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'\n  control-openhands browser click 'testid=conversation-name-context-menu >> testid=delete-button'\n  control-openhands assert --field status --equals 200 -- api DELETE \"/api/conversations/$QA_ID\" --write\n  control-openhands browser network --clear\n  control-openhands browser click 'role=dialog >> testid=confirm-button' --observe 'role=status' --observe-ms 1000\n  control-openhands browser screenshot --feature F07.delete-confirm --name stale-delete\n  control-openhands assert --field recent.0.status --equals 404 --bug-shows 400 -- browser network --filter \"/api/conversations/$QA_ID\"  # bug\n  ```",
          "raw": "**Already-deleted conversation (`F07.delete-confirm`, `F07.rename-inline`).** Expected: a stale action reports that the conversation is missing, and a stale rename never reports success. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5800](https://github.com/OpenHands/software-agent-sdk/issues/5800)): confirming Delete after another client removed this disposable conversation returns400 and displays `Bad Request`, instead of a missing-conversation404. The separate stale-rename control receives PATCH200 but re-fetches missing metadata and displays not-found, so its false-success variant is prevented by Canvas. Pause/interrupt were not tested.\n  ```sh known-bug F07.delete-confirm already-deleted\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  workspace = fixture / \"workspace\"\n  workspace.mkdir()\n  print(create(workspace))\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=chat-pane-header >> testid=ellipsis-button'\n  control-openhands browser click 'testid=conversation-name-context-menu >> testid=delete-button'\n  control-openhands assert --field status --equals 200 -- api DELETE \"/api/conversations/$QA_ID\" --write\n  control-openhands browser network --clear\n  control-openhands browser click 'role=dialog >> testid=confirm-button' --observe 'role=status' --observe-ms 1000\n  control-openhands browser screenshot --feature F07.delete-confirm --name stale-delete\n  control-openhands assert --field recent.0.status --equals 404 --bug-shows 400 -- browser network --filter \"/api/conversations/$QA_ID\"  # bug\n  ```",
          "source_line": 91,
          "expected": [
            "Delete Conversation names the title in a confirmation; Cancel keeps it, Confirm Delete removes it and returns to `/conversations`.",
            "double-click the title (or menu Rename), Enter or blur saves with a toast; an empty value reverts."
          ],
          "notes": [
            "Known failure [OpenHands/software-agent-sdk#5629](https://github.com/OpenHands/software-agent-sdk/issues/5629) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A second server's native first load rewrote a newer saved title with its startup copy; the connected Canvas header showed that older title. Recipe: create an idle conversation on A and PATCH its title to `QA_SHARED_BEFORE_B_5629_REPEAT`; run pinned SDK Python `python .agents/skills/verify-openhands/scripts/fixtures/shared-storage-server.py --run <run> --seconds 300` in the background. It starts only a bounded no-editor SDK B sharing this run's conversation directory/cipher/session key; its private `server.json` supplies the port and owned operator/PGID. After B listens, PATCH A to `QA_SHARED_AFTER_B_5629_REPEAT` and confirm disk. Run `control-openhands service stop agent-server --run <run>`, then `python .../fixtures/shared-storage-server.py --run <run> --load-id <id>`: native B `/events/count` returned 200 and disk reverted to BEFORE_B. Restart A only to serve Canvas, choose default-local and doctor; use the Add Backend recipe from [F25](F25-backends-and-cloud.md) to connect B at its recorded port with the private key file, then open its card: the header reads BEFORE_B. SIGTERM only the recorded helper operator and verify its port/PGID gone; the helper also bounds runtime and terminates remaining group descendants. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-repeat-before-b-load-5629.json`, `metadata-repeat-after-b-load-5629.json`, `shared-first-load-reverted-5629.png`. The native first-load request is explicit; stale catalog display alone is insufficient proof of this overwrite.",
            "Known failure [OpenHands/software-agent-sdk#4800](https://github.com/OpenHands/software-agent-sdk/issues/4800) (`F07.rename-inline`), reproduced 2026-10-12 at `fa1f724ab573`, SDK 1.54.0. A refused rename changed the server's GET title while `meta.json` kept the previous title; graceful shutdown subsequently persisted the rejected title. Recipe: create an idle dummy-profile conversation, then double-click `conversation-name-title`, fill `conversation-name-input` with `QA_META_BEFORE_4800` and press Enter; verify GET/disk. Record the original mode and temporarily `chmod 0500 <run>/private/state/dev_conversations/<id-hex>` to deny an atomic metadata replacement. Rename through that UI to `QA_META_REFUSED_4800`: the permission-denied toast/HTTP 500 appears, GET shows REFUSED, disk still shows BEFORE. Restore the exact original directory mode in `finally`, then `restart`, `doctor`, GET and reload (CLI with `--run <run>`): REFUSED was persisted during graceful shutdown. Only the owned temporary directory is changed; evidence contains title/timestamp fields, not stored secrets. Evidence: `backfill-18309-surface-rechecks/F07.rename-inline/metadata-before-4800.json`, `metadata-denied-4800.json`, `refused-title-4800.png`, `metadata-after-graceful-restart-4800.json`. Tags/autotitle and ownership-conflict branches remain untested."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5800](https://github.com/OpenHands/software-agent-sdk/issues/5800)): confirming Delete after another client removed this disposable conversation returns400 and displays `Bad Request`, instead of a missing-conversation404. The separate stale-rename control receives PATCH200 but re-fetches missing metadata and displays not-found, so its false-success variant is prevented by Canvas. Pause/interrupt were not tested."
          ],
          "states": [
            "failure"
          ]
        }
      ]
    },
    {
      "id": "F08",
      "title": "Workspace drawer: files and changes",
      "file": "F08-workspace-files-and-changes.md",
      "sha256": "50549f873291663706783c2107f4293af3763e9f34b6c29dc9558bc8c6a79076",
      "overview": "Next to the chat, a conversation has a right-hand drawer with tabs. This family\ncovers the drawer itself (toggle, tab bar, overflow menu with pinning, the phone\npanel page) and its two workspace tabs. **Files** is a read-only browser of the\nconversation's working directory: a path row with copy, a strip of open files,\na collapsible tree, and a Rich/Plain viewer for code, Markdown, HTML, images and\nbinaries. **Commits** lists an \"Uncommitted\" row and the git history, newest\nfirst. Each expands into per-file Monaco diffs with old/diff/new modes. The\nTerminal, Browser, Planner, Task list and Usage tab bodies belong to F27. The\nFiles tab has no upload, download or edit.",
      "entry": "- Chat header: the panel toggle (`right-panel-toggle`, block-drawer icon, right of the overview `i` button). At desktop width it shows or hides the drawer; at 1024 px or narrower it navigates to the panel page.\n- Drawer tab bar (`tabs-pane-header`): `conversation-tab-files`, `conversation-tab-commits`. The ellipsis (`tabs-pane-header >> testid=ellipsis-button`) opens the menu with `conversation-tabs-menu-open-<tab>` and `conversation-tabs-menu-pin-<tab>`.\n- Direct URL `/conversations/<id>/panel` (the phone panel page at 1024 px or narrower; wider, it redirects to `/conversations/<id>` with the drawer open).\n- Overview (`conversation-overview-toggle`) → Changes row (`conversation-overview-diffs`) → Commits tab with Uncommitted expanded.\n- Files toolbar: `File discovery` (`file-discovery-settings`, left of `files-tab-refresh`) opens the File discovery dialog.\n- Workspace paths in agent messages written as inline code (`markdown-file-path-link`), plus path chips in file-editor tool cards (`file-path-chip`) → Files tab with that file selected.\n- There is no command-menu entry or keyboard shortcut for the drawer or these tabs.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` (three short model turns, deepseek-flash).\n- A git fixture: `control-openhands fixture git-repo` (creates `<run>/workspace/qa-repo` with `README.md`, `src/calc.py`, `src/test_calc.py` and one commit by `QA Fixture`).\n- The conversation runs in that repo. From `/` run `control-openhands browser click 'testid=open-workspace-button'`, `control-openhands browser click 'testid=workspace-dropdown'`, `control-openhands browser click 'role=button[name=\"+ Add Workspace\"]'`. The folder browser opens at `<run>/private/home`, so then run `control-openhands browser click 'testid=folder-browser-up'` twice, `control-openhands browser dblclick 'testid=folder-browser-entry-workspace'`, `control-openhands browser dblclick 'testid=folder-browser-entry-qa-repo'`, `control-openhands browser click 'testid=folder-browser-use'` and `control-openhands browser click 'testid=workspace-launch-button'`. The composer now shows a `qa-repo` chip. Start with `--stay`, which keeps that chip; a plain `conversation start` navigates home and loses it.\n- Seed changes with a prompt file, because the command contains quotes. Write `qa-f08-1.txt` (anywhere outside the workspace) containing: ``In the current workspace directory run exactly this one bash command and then reply only \"done\": printf '# QA notes\\n\\nHello **world**\\n' > qa-notes.md && printf '<h1>QA page</h1>\\n' > qa-page.html && printf 'def sub(a, b):\\n    return a - b\\n' >> src/calc.py && rm src/test_calc.py && printf 'PK\\003\\004' > qa-deck.pptx && head -c 64 /dev/urandom > qa-blob.bin && git status --short``. Then run `control-openhands conversation start --stay --prompt \"$(cat qa-f08-1.txt)\" --wait --timeout 240`. Read `<id>` from its `id` (or `control-openhands browser url`); the browser stays on `/conversations/<id>`.\n- Viewport desktop (1440×1000) unless a bullet says phone.",
      "gotchas": "- When the drawer is closed, its tabs stay mounted at width 0. `browser count 'testid=files-tab'` is `1` even then, so assert with `browser visible` or `right-panel-toggle` `aria-pressed`.\n- The tab row is a `tablist \"Conversation panels\"` of `role=tab` controls, each named after its tab even when inactive (#17612), so `role=tab[name=\"Commits\"]` finds the Commits tab whether or not it is active; `role=button[name=\"Commits\"]` finds nothing. `aria-selected` marks the selected tab. The `conversation-tab-*` test ids still work.\n- The tab row ends with the ellipsis menu (`Customize tabs`), then the `VSCode` button (`drawer-vscode-link`, named `Open in VS Code`) when the runtime reports a running editor. #17660 removed that button and #18048 restored it, so on a checkout between the two the drawer has no editor control at all.\n- The overflow menu is not a `role=menu`. Escape closes it and returns focus to the tab bar's `ellipsis-button`, both in the desktop drawer and on the phone `/panel` page; choosing a row or an outside click (`browser mouse-click`) closes it too.\n- The Files list and the open preview do not follow bash edits: `use-auto-refresh-files-on-edit.ts` refreshes the file queries only for file-editor observations, and its comment says shell observations skip them on purpose (so that every shell command does not reload canvas iframes). This map still expects files the agent writes to appear (`F08.files-auto-refresh`), so the bash case stays a fail row. Click `files-tab-refresh` once before asserting on files the agent made with the shell. Since #18032 that one click also re-reads the open file (the content fetch revalidates instead of using the browser HTTP cache, and the rich preview gets a new `?v=`), and a page reload shows the current body without a click.\n- The chat also fetches the file list (to linkify paths). A conversation page left open while the agent works can therefore show a stale tree the moment the drawer first opens.\n- Binary detection is a NUL-byte sniff of the first 8 KB, and the Office message also needs that sniff to trip. A 4-byte `PK\\003\\004` \"pptx\" or random bytes without a NUL render as text in the plain viewer. Fixtures must contain a NUL.\n- `control-openhands fixture folder` writes a plain folder with `notes.md`, but by the time the conversation has run the folder holds an empty `.git`, so a conversation that leaves `notes.md` in place shows `Uncommitted 1 file` rather than the empty state. Delete it first (as in the recipe).\n- Diff views are Monaco. `testid=file-diff-viewer` and `file-single-viewer` are not on the DOM; read text through `testid=editor-container`. The narrow drawer falls back to an inline (not side-by-side) diff. The view-mode buttons are named `Old version`, `Diff` and `New version`, and `collapse` is named `Expand` or `Collapse` after its state (#18033).\n- `browser eval \"navigator.clipboard.readText()\"` hangs for about 2 minutes (no clipboard permission). Use `control-openhands browser clipboard` to read what Copy wrote.\n- `browser click ... --observe` accepts the usual selector syntax (`testid=...`).\n- Two `.cursor-ew-resize` grips sit in the conversation page (chat/drawer, then the Files tree); scope the tree one with `testid=files-tab-tree-resize-handle`.\n- At phone width, chat path links (and the agent's `canvas_ui_control` tab and file requests, through `src/services/canvas-ui.ts`) navigate to `/conversations/<id>/panel` (#18045); at desktop width they open the side drawer and the URL does not change. A `/panel` URL above 1024 px redirects to `/conversations/<id>`, so a bullet that leaves the phone viewport on `/panel` ends on the chat route.\n- If the agent's terminal resets mid-command (\"terminal session was reset\", for example after a `restart`), the agent reruns the command. Check the workspace with `git status` through a second prompt rather than trusting the first observation. Runs no longer share a tmux server (OpenHands/OpenHands#17946).\n- Known issue: [#17567](https://github.com/OpenHands/OpenHands/issues/17567) (Monaco disposed-model page error on diff unmount; it also fires on collapse and on a diff→old switch).\n- Known issue [#18108](https://github.com/OpenHands/OpenHands/issues/18108) (`F08.files-tree`): Files tab shows \"No files in workspace\" on cloud when the agent writes outside /workspace/project. Live prerequisite: An authenticated Enterprise Cloud sandbox, no repository selected, with files outside its default working directory.\n- Known issue [#17773](https://github.com/OpenHands/OpenHands/issues/17773) (`F08.files-tree`): Files tab always shows \"No files in workspace\" on Windows local backend. Live prerequisite: A Windows local Agent Server using cmd.exe; this run is macOS.\n- Known issue [#16949](https://github.com/OpenHands/OpenHands/issues/16949) (`F08.files-refresh`): Keep Files and Changes views source-driven after refresh. Live prerequisite: An authenticated Cloud repository for the refresh-query mismatch, or real file-editor observations causing an in-place M-to-D Git refetch.\n- Known issue [#16716](https://github.com/OpenHands/OpenHands/issues/16716) (`F08.uncommitted-changes`): Diff view is regularly out of sync. Live prerequisite: The original simultaneous-agent/shared-repository state or a deterministic stale-diff trigger; the report only suggests that timing.\n- Known issue [#16689](https://github.com/OpenHands/OpenHands/issues/16689) (`F08.files-load-error`): When `workspace/` paths throw a 401, call the workspace-session endpoint. Live prerequisite: The original fresh-VM URL, auth/cookie state and exact request sequence; the report says the trigger is unclear.\n- Known issue [#16454](https://github.com/OpenHands/OpenHands/issues/16454) (`F08.uncommitted-changes`): Diff list is showing duplicate entries. Live prerequisite: The original Git changes response and update sequence producing duplicate rows; the report provides no trigger.\n- Known issue [#16331](https://github.com/OpenHands/OpenHands/issues/16331) (`F08.files-tree`): file preview panel stops working during streaming. Live prerequisite: A real streaming/tool-running conversation while selecting a second workspace file.\n- Known issue [#15692](https://github.com/OpenHands/OpenHands/issues/15692) (`F08.uncommitted-changes`): Diff still shows after agent has committed and pushed. Live prerequisite: A real agent commit/push terminal observation while the diff remains mounted, plus a disposable Git remote.\n- Known issue [#15567](https://github.com/OpenHands/OpenHands/issues/15567) (`F08.files-load-error`): Workspace file preview returns 401 Unauthorized on non-HTTPS non-loopback hosts. Live prerequisite: A plain-HTTP non-loopback deployment and browser accessing that origin, with workspace-cookie observation.\n- Known issue [#15519](https://github.com/OpenHands/OpenHands/issues/15519) (`F08.files-load-error`): \"files\" tab in Files side panel throws 401 Unauthorized. Live prerequisite: The reported Linux Docker deployment/auth topology; this isolated run uses the macOS source launcher.\n- Known issue [#15501](https://github.com/OpenHands/OpenHands/issues/15501) (`F08.files-rich-plain`): Generated-page JavaScript was blocked by an iframe sandbox. Files Rich intentionally disables scripts; the report does not identify whether Files or Browser was used. Live prerequisite: The original preview panel and file URL or minimal page identifying the intended surface; Files Rich deliberately blocks scripts, while Browser is separate.\n- Known issue [OpenHands/software-agent-sdk#4562](https://github.com/OpenHands/software-agent-sdk/issues/4562) (`F08.files-load-error`): Plain-HTTP non-loopback workspace previews can return401 after the browser rejects the workspace-session cookie. Live prerequisite: A plain-HTTP non-loopback same-origin browser deployment with real workspace-cookie observation.\n- Known issue [#16871](https://github.com/OpenHands/OpenHands/issues/16871) (`F08.vscode-link`): the shared `transformVSCodeUrl` still accepts arbitrary schemes, and the current Cloud `use-unified-vscode-url.ts` branch passes sandbox `exposed_urls` editor values through it without the Local origin gate. Live prerequisite: an authorized Cloud account/sandbox whose editor `exposed_urls` value can be controlled for safe URL-scheme rejection checks. Local inspection on 2026-10-12 at `fa1f724ab573` found no Download via VS Code action in the real card menu: its only production caller does not supply the required `showOptions` prop. The current drawer opens with `noopener,noreferrer`. Neither the old opener exploit nor the remaining Cloud scheme scenario was reproduced. Evidence: `backfill-18309-surface-rechecks/F04.download/vscode-card-action-absent-16871.png` and its ARIA snapshot. Keep this linked to the live editor-link consumer; do not infer the whole issue resolved from the removed card action.\n- Known issue [OpenHands/software-agent-sdk#5845](https://github.com/OpenHands/software-agent-sdk/issues/5845) (`F08.vscode-link`): reserved characters in the workspace path can split the editor URL's unencoded `folder` parameter and open the wrong folder. Live prerequisite: a supported origin serving a genuinely enabled/running editor and an owned conversation in a path containing `&` or `#`. A direct URL-constructor call does not exercise the link; Cloud `exposed_urls` takes another path.\n- Known issue [OpenHands/software-agent-sdk#5784](https://github.com/OpenHands/software-agent-sdk/issues/5784) (`F08.files-rich-plain`, `F08.files-fallbacks`, `F08.files-open-new-window`): a path-prefixed Agent Server can set a workspace cookie whose Path omits the prefix, leaving HTML/image/file previews unauthorized. Live prerequisite: an authenticated path-prefixed owned Local server with `OH_WEB_URL` and a real cookie-authenticated preview; the ordinary root-path launch cannot exercise that cookie Path.",
      "features": [
        {
          "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."
        },
        {
          "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-discovery-settings",
          "description": "the Files toolbar's `File discovery` button opens a dialog (excluded directory patterns, maximum files, symbolic links) whose Save applies to every conversation on that workspace and backend; an excluded directory leaves the tree, Restore defaults brings the default patterns back, an invalid limit disables Save."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F08--open-the-drawer",
          "title": "Open the drawer",
          "ids": [
            "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`).",
          "raw": "**Open the drawer (`F08.panel-toggle`).** 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`).",
          "source_line": 72,
          "expected": [
            "the chat-header toggle (`Show panel`/`Hide panel`, `aria-pressed`) opens and closes the drawer; the first open shows Files."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--switch-and-close-by-tab",
          "title": "Switch and close by tab",
          "ids": [
            "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`.",
          "raw": "**Switch and close by tab (`F08.tab-bar`).** 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`.",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--reload",
          "title": "Reload",
          "ids": [
            "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`.",
          "raw": "**Reload (`F08.drawer-state-reload`).** 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`.",
          "source_line": 74,
          "expected": [
            "after a reload the drawer starts closed, and reopening it restores the last selected tab for that conversation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--overflow-menu",
          "title": "Overflow menu",
          "ids": [
            "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`.",
          "raw": "**Overflow menu (`F08.tabs-menu`).** 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`.",
          "source_line": 75,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--pin-unpin",
          "title": "Pin/unpin",
          "ids": [
            "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.",
          "raw": "**Pin/unpin (`F08.tabs-pin`).** 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.",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--resize",
          "title": "Resize",
          "ids": [
            "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\"`).",
          "raw": "**Resize (`F08.drawer-resize`).** 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\"`).",
          "source_line": 77,
          "expected": [
            "dragging the chat/drawer divider resizes both panes (chat 30–80 %, default 50 %, stored in `desktop-layout-panel-width`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--vs-code-link",
          "title": "VS Code link",
          "ids": [
            "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/`.",
          "raw": "**VS Code link (`F08.vscode-link`).** 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/`.",
          "source_line": 78,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16871](https://github.com/OpenHands/OpenHands/issues/16871) (`F08.vscode-link`): the shared `transformVSCodeUrl` still accepts arbitrary schemes, and the current Cloud `use-unified-vscode-url.ts` branch passes sandbox `exposed_urls` editor values through it without the Local origin gate. Live prerequisite: an authorized Cloud account/sandbox whose editor `exposed_urls` value can be controlled for safe URL-scheme rejection checks. Local inspection on 2026-10-12 at `fa1f724ab573` found no Download via VS Code action in the real card menu: its only production caller does not supply the required `showOptions` prop. The current drawer opens with `noopener,noreferrer`. Neither the old opener exploit nor the remaining Cloud scheme scenario was reproduced. Evidence: `backfill-18309-surface-rechecks/F04.download/vscode-card-action-absent-16871.png` and its ARIA snapshot. Keep this linked to the live editor-link consumer; do not infer the whole issue resolved from the removed card action.",
            "Known issue [OpenHands/software-agent-sdk#5845](https://github.com/OpenHands/software-agent-sdk/issues/5845) (`F08.vscode-link`): reserved characters in the workspace path can split the editor URL's unencoded `folder` parameter and open the wrong folder. Live prerequisite: a supported origin serving a genuinely enabled/running editor and an owned conversation in a path containing `&` or `#`. A direct URL-constructor call does not exercise the link; Cloud `exposed_urls` takes another path."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--archived",
          "title": "Archived",
          "ids": [
            "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`.",
          "raw": "**Archived (`F08.archived-disabled`).** 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`.",
          "source_line": 79,
          "expected": [
            "on an archived conversation the toggle is disabled with the tooltip `Unavailable for Archives`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F08--workspace-path",
          "title": "Workspace path",
          "ids": [
            "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.",
          "raw": "**Workspace path (`F08.files-workspace-path`).** 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.",
          "source_line": 80,
          "expected": [
            "`Workspace: <working_dir>` with the full path in its tooltip; the copy button turns into `Copied to clipboard` for about 2 s."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--tree",
          "title": "Tree",
          "ids": [
            "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.",
          "raw": "**Tree (`F08.files-tree`).** 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.",
          "source_line": 81,
          "expected": [
            "a tree of folders, collapsed at first, and files. Clicking a file selects it and adds it to the open-file strip."
          ],
          "notes": [
            "Known issue [#18108](https://github.com/OpenHands/OpenHands/issues/18108) (`F08.files-tree`): Files tab shows \"No files in workspace\" on cloud when the agent writes outside /workspace/project. Live prerequisite: An authenticated Enterprise Cloud sandbox, no repository selected, with files outside its default working directory.",
            "Known issue [#17773](https://github.com/OpenHands/OpenHands/issues/17773) (`F08.files-tree`): Files tab always shows \"No files in workspace\" on Windows local backend. Live prerequisite: A Windows local Agent Server using cmd.exe; this run is macOS.",
            "Known issue [#16331](https://github.com/OpenHands/OpenHands/issues/16331) (`F08.files-tree`): file preview panel stops working during streaming. Live prerequisite: A real streaming/tool-running conversation while selecting a second workspace file."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--tree-toggle",
          "title": "Tree toggle",
          "ids": [
            "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`).",
          "raw": "**Tree toggle (`F08.files-tree-toggle`).** 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`).",
          "source_line": 82,
          "expected": [
            "the list-tree button hides or shows the tree, and the choice persists per conversation. The tree is 224 px wide by default."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--tree-resize",
          "title": "Tree resize",
          "ids": [
            "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`).",
          "raw": "**Tree resize (`F08.files-tree-resize`).** 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`).",
          "source_line": 83,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--file-discovery",
          "title": "File discovery",
          "ids": [
            "F08.files-discovery-settings"
          ],
          "body": "With the drawer open on Files (open it with `right-panel-toggle` and click `testid=conversation-tab-files` only if Files is not already the active tab: clicking the active tab closes the drawer), run `control-openhands browser click 'testid=file-discovery-settings'` and `control-openhands browser snapshot 'role=dialog[name=\"File discovery\"]'`: `Applies to this workspace on this backend, including other conversations using it.`, textbox `Excluded directories` (`.git`, `node_modules`, `.venv`, … `target`), spinbutton `Maximum files (0 for unlimited)` `2000`, checkbox `Include symbolic links to files`, buttons `Restore defaults`, `Cancel`, `Save`. With `D='role=dialog[name=\"File discovery\"]'`: `control-openhands browser fill \"$D >> role=spinbutton\" '-1'` makes `control-openhands browser enabled \"$D >> role=button[name=\\\"Save\\\"]\"` `false`; `2000` makes it `true`. Run `control-openhands browser fill \"$D >> role=textbox\" \"$(printf '.git\\nnode_modules\\nsrc')\"` and click Save: the dialog closes, `control-openhands api GET /api/settings` holds `workspace_file_discovery` keyed by the workspace path with `excludedPatterns` `.git`, `node_modules`, `src`, and after `control-openhands browser click 'testid=files-tab-refresh'` `control-openhands browser count 'testid=file-tree-dir-src'` is `0`, also after `browser reload` and reopening the drawer (`browser screenshot --feature F08.files-discovery-settings --name dialog` before saving). Restore: reopen the dialog, click `Restore defaults` (the textbox returns to the default list; the dialog stays open), then `Save`; after Refresh the `file-tree-dir-src` count is `1`. `control-openhands browser press Escape` closes the dialog without saving. At phone width the dialog fits the viewport: on the 390 px `/panel` page (`control-openhands browser viewport phone`, `browser goto /conversations/<id>/panel`, `browser click 'testid=conversation-tab-files'`, then `file-discovery-settings`) `control-openhands browser bbox 'testid=file-discovery-settings-modal'` is about 351 px wide at x about 20 with `insideViewport` `true` (520 px at desktop), and `browser screenshot --feature F08.files-discovery-settings --name phone-fits` shows the title, the textbox and `Restore defaults`, `Cancel`, `Save` whole. Measure the `file-discovery-settings-modal` test id: `role=dialog` is the full-screen backdrop. Focus starts in the dialog (the Excluded directories textbox) and Tab cycles its fields and buttons without leaving it.",
          "raw": "**File discovery (`F08.files-discovery-settings`).** With the drawer open on Files (open it with `right-panel-toggle` and click `testid=conversation-tab-files` only if Files is not already the active tab: clicking the active tab closes the drawer), run `control-openhands browser click 'testid=file-discovery-settings'` and `control-openhands browser snapshot 'role=dialog[name=\"File discovery\"]'`: `Applies to this workspace on this backend, including other conversations using it.`, textbox `Excluded directories` (`.git`, `node_modules`, `.venv`, … `target`), spinbutton `Maximum files (0 for unlimited)` `2000`, checkbox `Include symbolic links to files`, buttons `Restore defaults`, `Cancel`, `Save`. With `D='role=dialog[name=\"File discovery\"]'`: `control-openhands browser fill \"$D >> role=spinbutton\" '-1'` makes `control-openhands browser enabled \"$D >> role=button[name=\\\"Save\\\"]\"` `false`; `2000` makes it `true`. Run `control-openhands browser fill \"$D >> role=textbox\" \"$(printf '.git\\nnode_modules\\nsrc')\"` and click Save: the dialog closes, `control-openhands api GET /api/settings` holds `workspace_file_discovery` keyed by the workspace path with `excludedPatterns` `.git`, `node_modules`, `src`, and after `control-openhands browser click 'testid=files-tab-refresh'` `control-openhands browser count 'testid=file-tree-dir-src'` is `0`, also after `browser reload` and reopening the drawer (`browser screenshot --feature F08.files-discovery-settings --name dialog` before saving). Restore: reopen the dialog, click `Restore defaults` (the textbox returns to the default list; the dialog stays open), then `Save`; after Refresh the `file-tree-dir-src` count is `1`. `control-openhands browser press Escape` closes the dialog without saving. At phone width the dialog fits the viewport: on the 390 px `/panel` page (`control-openhands browser viewport phone`, `browser goto /conversations/<id>/panel`, `browser click 'testid=conversation-tab-files'`, then `file-discovery-settings`) `control-openhands browser bbox 'testid=file-discovery-settings-modal'` is about 351 px wide at x about 20 with `insideViewport` `true` (520 px at desktop), and `browser screenshot --feature F08.files-discovery-settings --name phone-fits` shows the title, the textbox and `Restore defaults`, `Cancel`, `Save` whole. Measure the `file-discovery-settings-modal` test id: `role=dialog` is the full-screen backdrop. Focus starts in the dialog (the Excluded directories textbox) and Tab cycles its fields and buttons without leaving it.",
          "source_line": 84,
          "expected": [
            "the Files toolbar's `File discovery` button opens a dialog (excluded directory patterns, maximum files, symbolic links) whose Save applies to every conversation on that workspace and backend; an excluded directory leaves the tree, Restore defaults brings the default patterns back, an invalid limit disables Save."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--rich-plain",
          "title": "Rich/Plain",
          "ids": [
            "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`.",
          "raw": "**Rich/Plain (`F08.files-rich-plain`).** 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`.",
          "source_line": 85,
          "expected": [
            "Rich highlights code, renders Markdown and shows HTML in an iframe sandboxed without scripts. Plain shows highlighted source. The mode persists across reloads."
          ],
          "notes": [
            "Known issue [#15501](https://github.com/OpenHands/OpenHands/issues/15501) (`F08.files-rich-plain`): Generated-page JavaScript was blocked by an iframe sandbox. Files Rich intentionally disables scripts; the report does not identify whether Files or Browser was used. Live prerequisite: The original preview panel and file URL or minimal page identifying the intended surface; Files Rich deliberately blocks scripts, while Browser is separate.",
            "Known issue [OpenHands/software-agent-sdk#5784](https://github.com/OpenHands/software-agent-sdk/issues/5784) (`F08.files-rich-plain`, `F08.files-fallbacks`, `F08.files-open-new-window`): a path-prefixed Agent Server can set a workspace cookie whose Path omits the prefix, leaving HTML/image/file previews unauthorized. Live prerequisite: an authenticated path-prefixed owned Local server with `OH_WEB_URL` and a real cookie-authenticated preview; the ordinary root-path launch cannot exercise that cookie Path."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--open-in-new-window",
          "title": "Open in new window",
          "ids": [
            "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`.",
          "raw": "**Open in new window (`F08.files-open-new-window`).** 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`.",
          "source_line": 86,
          "expected": [
            "the external-link icon opens the file from the workspace fileserver in a new browser tab."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5784](https://github.com/OpenHands/software-agent-sdk/issues/5784) (`F08.files-rich-plain`, `F08.files-fallbacks`, `F08.files-open-new-window`): a path-prefixed Agent Server can set a workspace cookie whose Path omits the prefix, leaving HTML/image/file previews unauthorized. Live prerequisite: an authenticated path-prefixed owned Local server with `OH_WEB_URL` and a real cookie-authenticated preview; the ordinary root-path launch cannot exercise that cookie Path."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--open-file-strip",
          "title": "Open-file strip",
          "ids": [
            "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\"`.",
          "raw": "**Open-file strip (`F08.files-open-tabs`).** 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\"`.",
          "source_line": 87,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--no-selection",
          "title": "No selection",
          "ids": [
            "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`.",
          "raw": "**No selection (`F08.files-no-selection`).** 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`.",
          "source_line": 88,
          "expected": [
            "with no file selected the pane reads `Select a file to view its contents` and the Rich/Plain toggle is absent."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--second-agent-turn-commit-binaries-auto-refresh",
          "title": "Second agent turn: commit, binaries, auto-refresh",
          "ids": [
            "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`.",
          "raw": "**Second agent turn: commit, binaries, auto-refresh (`F08.files-auto-refresh`, `F08.files-refresh`).** 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`.",
          "source_line": 89,
          "expected": [
            "files the agent writes appear without a manual refresh.",
            "`Refresh files` re-reads the file list and the selected file's content."
          ],
          "notes": [
            "Known issue [#16949](https://github.com/OpenHands/OpenHands/issues/16949) (`F08.files-refresh`): Keep Files and Changes views source-driven after refresh. Live prerequisite: An authenticated Cloud repository for the refresh-query mismatch, or real file-editor observations causing an in-place M-to-D Git refetch."
          ],
          "annotations": [
            "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`."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--fallbacks",
          "title": "Fallbacks",
          "ids": [
            "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).",
          "raw": "**Fallbacks (`F08.files-fallbacks`).** 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).",
          "source_line": 90,
          "expected": [
            "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)."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5784](https://github.com/OpenHands/software-agent-sdk/issues/5784) (`F08.files-rich-plain`, `F08.files-fallbacks`, `F08.files-open-new-window`): a path-prefixed Agent Server can set a workspace cookie whose Path omits the prefix, leaving HTML/image/file previews unauthorized. Live prerequisite: an authenticated path-prefixed owned Local server with `OH_WEB_URL` and a real cookie-authenticated preview; the ordinary root-path launch cannot exercise that cookie Path."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--file-editor-edits-auto-refresh-deleted-file-errors",
          "title": "File-editor edits auto-refresh; deleted file errors",
          "ids": [
            "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`).",
          "raw": "**File-editor edits auto-refresh; deleted file errors (`F08.files-auto-refresh`, `F08.files-load-error`).** 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`).",
          "source_line": 91,
          "expected": [
            "files the agent writes appear without a manual refresh.",
            "a selected file that can no longer be read shows the error (`Failed to read <path>: 404`)."
          ],
          "notes": [
            "Known issue [#16689](https://github.com/OpenHands/OpenHands/issues/16689) (`F08.files-load-error`): When `workspace/` paths throw a 401, call the workspace-session endpoint. Live prerequisite: The original fresh-VM URL, auth/cookie state and exact request sequence; the report says the trigger is unclear.",
            "Known issue [#15567](https://github.com/OpenHands/OpenHands/issues/15567) (`F08.files-load-error`): Workspace file preview returns 401 Unauthorized on non-HTTPS non-loopback hosts. Live prerequisite: A plain-HTTP non-loopback deployment and browser accessing that origin, with workspace-cookie observation.",
            "Known issue [#15519](https://github.com/OpenHands/OpenHands/issues/15519) (`F08.files-load-error`): \"files\" tab in Files side panel throws 401 Unauthorized. Live prerequisite: The reported Linux Docker deployment/auth topology; this isolated run uses the macOS source launcher.",
            "Known issue [OpenHands/software-agent-sdk#4562](https://github.com/OpenHands/software-agent-sdk/issues/4562) (`F08.files-load-error`): Plain-HTTP non-loopback workspace previews can return401 after the browser rejects the workspace-session cookie. Live prerequisite: A plain-HTTP non-loopback same-origin browser deployment with real workspace-cookie observation."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--open-from-chat",
          "title": "Open from chat",
          "ids": [
            "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.",
          "raw": "**Open from chat (`F08.files-open-from-chat`).** 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.",
          "source_line": 92,
          "expected": [
            "clicking a workspace path in an agent (or user) message opens the drawer on Files with that file selected."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--tool-card-path-chip",
          "title": "Tool-card path chip",
          "ids": [
            "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`.",
          "raw": "**Tool-card path chip (`F08.files-open-from-tool-chip`).** 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`.",
          "source_line": 93,
          "expected": [
            "the path chip on a file-editor tool card (absolute path) opens the drawer on Files with that file selected."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--commits-list",
          "title": "Commits list",
          "ids": [
            "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`.",
          "raw": "**Commits list (`F08.uncommitted-changes`, `F08.commit-rows`).** 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`.",
          "source_line": 94,
          "expected": [
            "the top row reads `-`, `Uncommitted` and a file count; expanding it lists the changed paths.",
            "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."
          ],
          "notes": [
            "Known issue [#16716](https://github.com/OpenHands/OpenHands/issues/16716) (`F08.uncommitted-changes`): Diff view is regularly out of sync. Live prerequisite: The original simultaneous-agent/shared-repository state or a deterministic stale-diff trigger; the report only suggests that timing.",
            "Known issue [#16454](https://github.com/OpenHands/OpenHands/issues/16454) (`F08.uncommitted-changes`): Diff list is showing duplicate entries. Live prerequisite: The original Git changes response and update sequence producing duplicate rows; the report provides no trigger.",
            "Known issue [#15692](https://github.com/OpenHands/OpenHands/issues/15692) (`F08.uncommitted-changes`): Diff still shows after agent has committed and pushed. Live prerequisite: A real agent commit/push terminal observation while the diff remains mounted, plus a disposable Git remote."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--diff-modes",
          "title": "Diff modes",
          "ids": [
            "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).",
          "raw": "**Diff modes (`F08.diff-view-modes`).** 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).",
          "source_line": 95,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--markdown-preview",
          "title": "Markdown preview",
          "ids": [
            "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.",
          "raw": "**Markdown preview (`F08.diff-markdown-preview`).** 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.",
          "source_line": 96,
          "expected": [
            "a Markdown file in new (or old) mode renders as a prose preview."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--deleted-file",
          "title": "Deleted file",
          "ids": [
            "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.`",
          "raw": "**Deleted file (`F08.diff-deleted-file`).** 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.`",
          "source_line": 97,
          "expected": [
            "a deleted working-tree file reads `This file was deleted. Its previous contents are no longer available for preview.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--from-the-overview",
          "title": "From the overview",
          "ids": [
            "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.",
          "raw": "**From the overview (`F08.changes-from-overview`).** 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.",
          "source_line": 98,
          "expected": [
            "the overview's Changes row opens the drawer on Commits with Uncommitted expanded and closes the overview."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--empty-states",
          "title": "Empty states",
          "ids": [
            "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.",
          "raw": "**Empty states (`F08.commits-states`, `F08.files-empty-workspace`).** 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.",
          "source_line": 99,
          "expected": [
            "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...`.",
            "a workspace with no files shows `No files in workspace`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--commit-cap",
          "title": "Commit cap",
          "ids": [
            "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.",
          "raw": "**Commit cap (`F08.commits-cap`).** 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.",
          "source_line": 100,
          "expected": [
            "the history loads the latest 50 commits; a longer history ends with `Showing the latest 50 commits`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--phone-panel-page",
          "title": "Phone panel page",
          "ids": [
            "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`.",
          "raw": "**Phone panel page (`F08.phone-panel-page`).** 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`.",
          "source_line": 101,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--direct-panel-url",
          "title": "Direct panel URL",
          "ids": [
            "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`.",
          "raw": "**Direct panel URL (`F08.panel-direct-url`).** 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`.",
          "source_line": 102,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F08--errors",
          "title": "Errors",
          "ids": [],
          "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).",
          "raw": "**Errors.** 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).",
          "source_line": 103,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F08--rich-fenced-code",
          "title": "Rich fenced code",
          "ids": [
            "F08.files-rich-plain"
          ],
          "body": "Arrange a Markdown file in the selected workspace containing a heading and a fenced Python `print(\"qa\")` block. Open it from Files in Rich mode and take `browser screenshot --feature F08.files-rich-plain --name fenced-code`. Known failure (reproduced 2026-10-12, [#16802](https://github.com/OpenHands/OpenHands/issues/16802)): the code renders in nested dark/gray containers with repeated padding. This is a layout-only observation; the measured `pre` and prose ancestor had zero border width, so it is not a claim of visible CSS borders.",
          "raw": "**Rich fenced code (`F08.files-rich-plain`).** Arrange a Markdown file in the selected workspace containing a heading and a fenced Python `print(\"qa\")` block. Open it from Files in Rich mode and take `browser screenshot --feature F08.files-rich-plain --name fenced-code`. Known failure (reproduced 2026-10-12, [#16802](https://github.com/OpenHands/OpenHands/issues/16802)): the code renders in nested dark/gray containers with repeated padding. This is a layout-only observation; the measured `pre` and prose ancestor had zero border width, so it is not a claim of visible CSS borders.",
          "source_line": 105,
          "expected": [
            "Rich highlights code, renders Markdown and shows HTML in an iframe sandboxed without scripts. Plain shows highlighted source. The mode persists across reloads."
          ],
          "notes": [
            "Known issue [#15501](https://github.com/OpenHands/OpenHands/issues/15501) (`F08.files-rich-plain`): Generated-page JavaScript was blocked by an iframe sandbox. Files Rich intentionally disables scripts; the report does not identify whether Files or Browser was used. Live prerequisite: The original preview panel and file URL or minimal page identifying the intended surface; Files Rich deliberately blocks scripts, while Browser is separate.",
            "Known issue [OpenHands/software-agent-sdk#5784](https://github.com/OpenHands/software-agent-sdk/issues/5784) (`F08.files-rich-plain`, `F08.files-fallbacks`, `F08.files-open-new-window`): a path-prefixed Agent Server can set a workspace cookie whose Path omits the prefix, leaving HTML/image/file previews unauthorized. Live prerequisite: an authenticated path-prefixed owned Local server with `OH_WEB_URL` and a real cookie-authenticated preview; the ordinary root-path launch cannot exercise that cookie Path."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16802](https://github.com/OpenHands/OpenHands/issues/16802)): the code renders in nested dark/gray containers with repeated padding. This is a layout-only observation; the measured `pre` and prose ancestor had zero border width, so it is not a claim of visible CSS borders."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--git-filename-with-a-space",
          "title": "Git filename with a space",
          "ids": [
            "F08.uncommitted-changes"
          ],
          "body": "In a disposable workspace Git fixture, create and commit `my notes.md`, then modify it (plain shell Git/file commands are fixture setup, not UI proof). Open its empty Local conversation's Commits tab and read `browser network --filter '/git/'`. Expected: the modified path and other changes stay listed. Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5642](https://github.com/OpenHands/software-agent-sdk/issues/5642)): the scoped changes call returns 400 and hides the Uncommitted changes. Only the space-name case was tested; the same issue's non-ASCII and rename cases still require their own checks.",
          "raw": "**Git filename with a space (`F08.uncommitted-changes`).** In a disposable workspace Git fixture, create and commit `my notes.md`, then modify it (plain shell Git/file commands are fixture setup, not UI proof). Open its empty Local conversation's Commits tab and read `browser network --filter '/git/'`. Expected: the modified path and other changes stay listed. Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5642](https://github.com/OpenHands/software-agent-sdk/issues/5642)): the scoped changes call returns 400 and hides the Uncommitted changes. Only the space-name case was tested; the same issue's non-ASCII and rename cases still require their own checks.",
          "source_line": 106,
          "expected": [
            "the top row reads `-`, `Uncommitted` and a file count; expanding it lists the changed paths."
          ],
          "notes": [
            "Known issue [#16716](https://github.com/OpenHands/OpenHands/issues/16716) (`F08.uncommitted-changes`): Diff view is regularly out of sync. Live prerequisite: The original simultaneous-agent/shared-repository state or a deterministic stale-diff trigger; the report only suggests that timing.",
            "Known issue [#16454](https://github.com/OpenHands/OpenHands/issues/16454) (`F08.uncommitted-changes`): Diff list is showing duplicate entries. Live prerequisite: The original Git changes response and update sequence producing duplicate rows; the report provides no trigger.",
            "Known issue [#15692](https://github.com/OpenHands/OpenHands/issues/15692) (`F08.uncommitted-changes`): Diff still shows after agent has committed and pushed. Live prerequisite: A real agent commit/push terminal observation while the diff remains mounted, plus a disposable Git remote."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5642](https://github.com/OpenHands/software-agent-sdk/issues/5642)): the scoped changes call returns 400 and hides the Uncommitted changes. Only the space-name case was tested; the same issue's non-ASCII and rename cases still require their own checks."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--file-listing-above-one-mib",
          "title": "File listing above one MiB",
          "ids": [
            "F08.files-tree"
          ],
          "body": "In a disposable fixture, arrange 1800 tiny files beneath three 220-character directory segments so the sorted relative-path listing exceeds one MiB while remaining below the 2000-file cap. Keep a normal tracked README at the root. Open Files, click `testid=files-tab-refresh`, then count `role=button[name=\"README.md\"][exact]` and inspect the tree. Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5638](https://github.com/OpenHands/software-agent-sdk/issues/5638)): the README disappears and a spurious partial-path folder appears because the command API returns the tail output chunk only. The greater-than-100-chunk/null-exit case was not driven.",
          "raw": "**File listing above one MiB (`F08.files-tree`).** In a disposable fixture, arrange 1800 tiny files beneath three 220-character directory segments so the sorted relative-path listing exceeds one MiB while remaining below the 2000-file cap. Keep a normal tracked README at the root. Open Files, click `testid=files-tab-refresh`, then count `role=button[name=\"README.md\"][exact]` and inspect the tree. Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5638](https://github.com/OpenHands/software-agent-sdk/issues/5638)): the README disappears and a spurious partial-path folder appears because the command API returns the tail output chunk only. The greater-than-100-chunk/null-exit case was not driven.",
          "source_line": 107,
          "expected": [
            "a tree of folders, collapsed at first, and files. Clicking a file selects it and adds it to the open-file strip."
          ],
          "notes": [
            "Known issue [#18108](https://github.com/OpenHands/OpenHands/issues/18108) (`F08.files-tree`): Files tab shows \"No files in workspace\" on cloud when the agent writes outside /workspace/project. Live prerequisite: An authenticated Enterprise Cloud sandbox, no repository selected, with files outside its default working directory.",
            "Known issue [#17773](https://github.com/OpenHands/OpenHands/issues/17773) (`F08.files-tree`): Files tab always shows \"No files in workspace\" on Windows local backend. Live prerequisite: A Windows local Agent Server using cmd.exe; this run is macOS.",
            "Known issue [#16331](https://github.com/OpenHands/OpenHands/issues/16331) (`F08.files-tree`): file preview panel stops working during streaming. Live prerequisite: A real streaming/tool-running conversation while selecting a second workspace file."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [OpenHands/software-agent-sdk#5638](https://github.com/OpenHands/software-agent-sdk/issues/5638)): the README disappears and a spurious partial-path folder appears because the command API returns the tail output chunk only. The greater-than-100-chunk/null-exit case was not driven."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--subdirectory-git-paths",
          "title": "Subdirectory Git paths",
          "ids": [
            "F08.uncommitted-changes",
            "F08.diff-view-modes"
          ],
          "body": "Expected: a workspace at `repo/src` lists changes beneath that folder relative to it, and its diffs open. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5843](https://github.com/OpenHands/software-agent-sdk/issues/5843)): tracked paths retain the repository prefix while untracked paths are relative to `src`; opening displayed `src/app.py` requests `repo/src/src/app.py` and returns400. The ordinary repository-root control lists correct paths. The recipe creates only disposable, idle offline conversations.\n  ```sh known-bug F08.diff-view-modes subdirectory-paths\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  repo = fixture / \"repo\"\n  (repo / \"src\").mkdir(parents=True)\n  git(repo, \"init\", \"-q\", \"-b\", \"main\")\n  (repo / \"README.md\").write_text(\"root before\\n\")\n  (repo / \"src/app.py\").write_text(\"x = 1\\n\")\n  git(repo, \"add\", \".\")\n  git(repo, \"-c\", \"user.name=QA\", \"-c\", \"user.email=qa@example.invalid\", \"commit\", \"-qm\", \"Owned fixture\")\n  (repo / \"README.md\").write_text(\"root after\\n\")\n  (repo / \"src/app.py\").write_text(\"x = 2\\n\")\n  (repo / \"src/new.py\").write_text(\"new\\n\")\n  print(create(repo / \"src\"))\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=right-panel-toggle'\n  control-openhands browser click 'testid=conversation-tab-commits'\n  control-openhands browser wait-text Uncommitted --timeout 10000\n  control-openhands browser click 'testid=uncommitted-changes-row-toggle'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=file-diff-viewer-outer >> has-text=src/app.py >> testid=collapse' --observe 'testid=uncommitted-changes-row-content' --observe-ms 1000\n  control-openhands browser screenshot --feature F08.diff-view-modes --name subdir-mixed-paths\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 400 -- browser network --filter '/git/diff'  # bug\n  ```",
          "raw": "**Subdirectory Git paths (`F08.uncommitted-changes`, `F08.diff-view-modes`).** Expected: a workspace at `repo/src` lists changes beneath that folder relative to it, and its diffs open. Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5843](https://github.com/OpenHands/software-agent-sdk/issues/5843)): tracked paths retain the repository prefix while untracked paths are relative to `src`; opening displayed `src/app.py` requests `repo/src/src/app.py` and returns400. The ordinary repository-root control lists correct paths. The recipe creates only disposable, idle offline conversations.\n  ```sh known-bug F08.diff-view-modes subdirectory-paths\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  repo = fixture / \"repo\"\n  (repo / \"src\").mkdir(parents=True)\n  git(repo, \"init\", \"-q\", \"-b\", \"main\")\n  (repo / \"README.md\").write_text(\"root before\\n\")\n  (repo / \"src/app.py\").write_text(\"x = 1\\n\")\n  git(repo, \"add\", \".\")\n  git(repo, \"-c\", \"user.name=QA\", \"-c\", \"user.email=qa@example.invalid\", \"commit\", \"-qm\", \"Owned fixture\")\n  (repo / \"README.md\").write_text(\"root after\\n\")\n  (repo / \"src/app.py\").write_text(\"x = 2\\n\")\n  (repo / \"src/new.py\").write_text(\"new\\n\")\n  print(create(repo / \"src\"))\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=right-panel-toggle'\n  control-openhands browser click 'testid=conversation-tab-commits'\n  control-openhands browser wait-text Uncommitted --timeout 10000\n  control-openhands browser click 'testid=uncommitted-changes-row-toggle'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=file-diff-viewer-outer >> has-text=src/app.py >> testid=collapse' --observe 'testid=uncommitted-changes-row-content' --observe-ms 1000\n  control-openhands browser screenshot --feature F08.diff-view-modes --name subdir-mixed-paths\n  control-openhands assert --field recent.0.status --equals 200 --bug-shows 400 -- browser network --filter '/git/diff'  # bug\n  ```",
          "source_line": 109,
          "expected": [
            "the top row reads `-`, `Uncommitted` and a file count; expanding it lists the changed paths.",
            "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."
          ],
          "notes": [
            "Known issue [#16716](https://github.com/OpenHands/OpenHands/issues/16716) (`F08.uncommitted-changes`): Diff view is regularly out of sync. Live prerequisite: The original simultaneous-agent/shared-repository state or a deterministic stale-diff trigger; the report only suggests that timing.",
            "Known issue [#16454](https://github.com/OpenHands/OpenHands/issues/16454) (`F08.uncommitted-changes`): Diff list is showing duplicate entries. Live prerequisite: The original Git changes response and update sequence producing duplicate rows; the report provides no trigger.",
            "Known issue [#15692](https://github.com/OpenHands/OpenHands/issues/15692) (`F08.uncommitted-changes`): Diff still shows after agent has committed and pushed. Live prerequisite: A real agent commit/push terminal observation while the diff remains mounted, plus a disposable Git remote."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-16, SDK 1.55.0, [OpenHands/software-agent-sdk#5843](https://github.com/OpenHands/software-agent-sdk/issues/5843)): tracked paths retain the repository prefix while untracked paths are relative to `src`; opening displayed `src/app.py` requests `repo/src/src/app.py` and returns400. The ordinary repository-root control lists correct paths. The recipe creates only disposable, idle offline conversations."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F08--plain-parent-containing-a-repository",
          "title": "Plain parent containing a repository",
          "ids": [
            "F08.uncommitted-changes"
          ],
          "body": "Expected: a plain workspace holding one dirty repository exposes that repository's changes. Known failure (reproduced 2026-10-16, pinned SDK 1.55.0, [OpenHands/software-agent-sdk#5641](https://github.com/OpenHands/software-agent-sdk/issues/5641)): the changes request returns200 with no changes and Commits reads `No commits yet` although the child repository is dirty. The upstream fix is closed but not included in the current pin. The recipe removes only its own verified empty parent `.git`; never remove a user's repository.\n  ```sh known-bug F08.uncommitted-changes nested-repository\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  import shutil\n  parent = fixture / \"parent\"\n  child = parent / \"proj\"\n  child.mkdir(parents=True)\n  git(child, \"init\", \"-q\", \"-b\", \"main\")\n  (child / \"README.md\").write_text(\"before\\n\")\n  git(child, \"add\", \".\")\n  git(child, \"-c\", \"user.name=QA\", \"-c\", \"user.email=qa@example.invalid\", \"commit\", \"-qm\", \"Owned nested fixture\")\n  (child / \"README.md\").write_text(\"after\\n\")\n  (child / \"notes.txt\").write_text(\"untracked\\n\")\n  conversation_id = create(parent)\n  empty_git = parent / \".git\"\n  if empty_git.exists():\n      assert empty_git.is_dir() and not empty_git.is_symlink()\n      top = pathlib.Path(subprocess.check_output([\"git\", \"-C\", str(parent), \"rev-parse\", \"--show-toplevel\"], text=True).strip())\n      assert top.resolve() == parent.resolve()\n      assert not subprocess.check_output([\"git\", \"-C\", str(parent), \"ls-files\"], text=True).strip()\n      shutil.rmtree(empty_git)\n  print(conversation_id)\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=right-panel-toggle'\n  control-openhands browser click 'testid=conversation-tab-commits'\n  control-openhands browser wait '#conversation-tab-panel main'\n  control-openhands browser wait 'testid=commits-tab-status' --state detached --timeout 10000\n  control-openhands assert --field recent.0.status --equals 200 -- browser network --filter '/git/changes'\n  control-openhands browser screenshot --feature F08.uncommitted-changes --name nested-repository\n  control-openhands assert --field count --equals 1 --bug-shows 0 -- browser count 'testid=uncommitted-changes-row'  # bug\n  ```",
          "raw": "**Plain parent containing a repository (`F08.uncommitted-changes`).** Expected: a plain workspace holding one dirty repository exposes that repository's changes. Known failure (reproduced 2026-10-16, pinned SDK 1.55.0, [OpenHands/software-agent-sdk#5641](https://github.com/OpenHands/software-agent-sdk/issues/5641)): the changes request returns200 with no changes and Commits reads `No commits yet` although the child repository is dirty. The upstream fix is closed but not included in the current pin. The recipe removes only its own verified empty parent `.git`; never remove a user's repository.\n  ```sh known-bug F08.uncommitted-changes nested-repository\n  control-openhands browser viewport desktop\n  QA_ID=\"$(python3 - <<'PY'\n  import json, os, pathlib, subprocess, tempfile\n  base = pathlib.Path(os.environ[\"OH_VERIFY_RUN\"]) / \"workspace\"\n  base.mkdir(exist_ok=True)\n  fixture = pathlib.Path(tempfile.mkdtemp(prefix=\"qa-surface-\", dir=base))\n  llm = {\"model\":\"openai/gpt-4o\", \"base_url\":\"http://127.0.0.1:1/v1\", \"api_key\":\"qa-not-real\", \"num_retries\":0, \"timeout\":2}\n  def create(workspace, parent=None, tags=None):\n      payload = {\"agent\":{\"kind\":\"Agent\", \"llm\":llm, \"tools\":[]}, \"workspace\":{\"working_dir\":str(workspace)}, \"autotitle\":False}\n      if parent: payload[\"parent_conversation_id\"] = parent\n      if tags: payload[\"tags\"] = tags\n      request = fixture / \"request.json\"\n      request.write_text(json.dumps(payload))\n      result = subprocess.run([\"control-openhands\", \"api\", \"POST\", \"/api/conversations\", \"--write\", \"--data\", \"@\"+str(request), \"--pick\", \"id\"], check=True, capture_output=True, text=True)\n      return json.loads(result.stdout)[\"value\"]\n  def git(directory, *args):\n      subprocess.run([\"git\", \"-C\", str(directory), *args], check=True, capture_output=True)\n  import shutil\n  parent = fixture / \"parent\"\n  child = parent / \"proj\"\n  child.mkdir(parents=True)\n  git(child, \"init\", \"-q\", \"-b\", \"main\")\n  (child / \"README.md\").write_text(\"before\\n\")\n  git(child, \"add\", \".\")\n  git(child, \"-c\", \"user.name=QA\", \"-c\", \"user.email=qa@example.invalid\", \"commit\", \"-qm\", \"Owned nested fixture\")\n  (child / \"README.md\").write_text(\"after\\n\")\n  (child / \"notes.txt\").write_text(\"untracked\\n\")\n  conversation_id = create(parent)\n  empty_git = parent / \".git\"\n  if empty_git.exists():\n      assert empty_git.is_dir() and not empty_git.is_symlink()\n      top = pathlib.Path(subprocess.check_output([\"git\", \"-C\", str(parent), \"rev-parse\", \"--show-toplevel\"], text=True).strip())\n      assert top.resolve() == parent.resolve()\n      assert not subprocess.check_output([\"git\", \"-C\", str(parent), \"ls-files\"], text=True).strip()\n      shutil.rmtree(empty_git)\n  print(conversation_id)\n  PY\n  )\"\n  control-openhands browser goto \"/conversations/$QA_ID\"\n  control-openhands browser click 'testid=right-panel-toggle'\n  control-openhands browser click 'testid=conversation-tab-commits'\n  control-openhands browser wait '#conversation-tab-panel main'\n  control-openhands browser wait 'testid=commits-tab-status' --state detached --timeout 10000\n  control-openhands assert --field recent.0.status --equals 200 -- browser network --filter '/git/changes'\n  control-openhands browser screenshot --feature F08.uncommitted-changes --name nested-repository\n  control-openhands assert --field count --equals 1 --bug-shows 0 -- browser count 'testid=uncommitted-changes-row'  # bug\n  ```",
          "source_line": 152,
          "expected": [
            "the top row reads `-`, `Uncommitted` and a file count; expanding it lists the changed paths."
          ],
          "notes": [
            "Known issue [#16716](https://github.com/OpenHands/OpenHands/issues/16716) (`F08.uncommitted-changes`): Diff view is regularly out of sync. Live prerequisite: The original simultaneous-agent/shared-repository state or a deterministic stale-diff trigger; the report only suggests that timing.",
            "Known issue [#16454](https://github.com/OpenHands/OpenHands/issues/16454) (`F08.uncommitted-changes`): Diff list is showing duplicate entries. Live prerequisite: The original Git changes response and update sequence producing duplicate rows; the report provides no trigger.",
            "Known issue [#15692](https://github.com/OpenHands/OpenHands/issues/15692) (`F08.uncommitted-changes`): Diff still shows after agent has committed and pushed. Live prerequisite: A real agent commit/push terminal observation while the diff remains mounted, plus a disposable Git remote."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-16, pinned SDK 1.55.0, [OpenHands/software-agent-sdk#5641](https://github.com/OpenHands/software-agent-sdk/issues/5641)): the changes request returns200 with no changes and Commits reads `No commits yet` although the child repository is dirty. The upstream fix is closed but not included in the current pin. The recipe removes only its own verified empty parent `.git`; never remove a user's repository."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        }
      ]
    },
    {
      "id": "F09",
      "title": "Settings shell and navigation",
      "file": "F09-settings-shell.md",
      "sha256": "d177599f878ea879eda909be2770766747eed9cdeea5c6650c79518f849bedf5",
      "overview": "Settings is one shell around eight pages (Agent, LLM, Model Router, Condenser,\nAgent Context, Verification, Application, Secrets). On desktop a pinned left\ncolumn lists the pages, highlights the current one and ends with an \"Agent\nCanvas\" version card and a \"synced from … backend\" note; each page gets a title\nand a grey subline. Below 1024 px (phones and tablets) `/settings` is a hub list\ninstead, at tablet widths (768–1023 px) each subpage has a compact Settings selector, and at phone width every page shows a Back chevron to the hub. Users reach Settings from the sidebar gear,\nthe collapsed rail, the command menu and several deep links (LLM banner, getting\nstarted checklist, composer pickers). The individual pages are other families\n(F10–F16; Secrets is F14).",
      "entry": "- Sidebar footer gear next to the backend selector (`backend-selector-settings-link`), on desktop or inside the phone drawer (`sidebar-mobile-menu-toggle`, then `sidebar-mobile-drawer >> backend-selector-settings-link`).\n- Collapsed sidebar: the gear icon at the bottom of the rail (`collapsed-settings-link`).\n- Command menu (`Control+k`/`Meta+k` or `command-menu-trigger`): group **Settings** with Settings, Agent settings, LLM profiles, Model Router, Condenser settings, Agent Context, Verification settings, Application settings, Secrets settings.\n- Deep links: Home banner **Set up LLM** (`home-llm-not-configured-action`, only while no LLM key is configured); getting-started checklist **Add LLM API key** and **Customize your agent**; composer LLM pill (`chat-input-llm-profile`) → **LLM Profiles**; composer **+** (`chat-plus-button`) → **Switch agent profile** → **Manage agent profiles**. Application settings also links \"Manage LLM profiles\" (F15's page).\n- Direct URLs: `/settings`, `/settings/agents`, `/settings/llm`, `/settings/meta-llm`, `/settings/condenser`, `/settings/agent-context`, `/settings/verification`, `/settings/app`, `/settings/secrets`, and the legacy `/settings/agent`.\n- Inside Settings: the left nav (`sidebar-settings-/settings/<page>`) on desktop; the hub list (`settings-mobile-hub`) plus the top-bar Back chevron (`sidebar-mobile-back-button`) on phones.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done), desktop viewport.\n- Run the banner and checklist deep links (`F09.entry-deep-links`, first bullet) **before** configuring a model: the banner only exists while no LLM key is set.\n- The composer picker deep links need a configured profile: `control-openhands llm preset deepseek` (no model call is made).\n- `F09.cloud-links` positive case and `F09.locked-cloud-landing` need a Cloud backend/account (blocked in a local run). `F09.hidden-llm-redirect` is blocked: the frontend hardcodes `hide_llm_settings: false`. The update states depend on the browser reaching `registry.npmjs.org`: with access and a current client you get `F09.update-up-to-date`; `F09.update-available` and the version tile also need an npm `latest` newer than the client (blocked while the client is the latest release); the failure text of `F09.update-modal` needs the registry to be unreachable (no mocks).",
      "gotchas": "- `browser click` returns the URL *before* client-side navigation and redirects settle (it prints `/` or `/settings`). Always follow a navigating click with `browser wait-url '<regex>$'` before asserting.\n- At phone width there are two `backend-selector-settings-link` elements (hidden desktop sidebar plus the drawer); scope with `testid=sidebar-mobile-drawer >> ...` or the click fails in strict mode. The hub's nav links share testids with the desktop nav, so scope them with `testid=settings-mobile-hub >> ...`.\n- The page does not scroll inside the settings `<main>`; the app shell column does. `browser scroll <selector> --by N` only scrolls the element into view and ignores `--by`; hover the content first, then `browser scroll --by N` (mouse wheel at the pointer).\n- Only `/settings/app` is taller than a 1000 px viewport in a fresh run; use it for the pinned-nav check.\n- Visiting `/settings/agents` ticks the checklist item \"Customize your agent\" for good (struck through, still a working link, so the bullet order above is fine), and configuring a model hides the Home banner. Run the banner deep link before `llm preset` on a fresh run.\n- Registry reachability depends on the sandbox and changes between runs. When the browser cannot reach `registry.npmjs.org` (seen as `ERR_CERT_AUTHORITY_INVALID` through a proxy), the version badge and sidebar tile stay hidden and the dialog shows the failure state; that is environment, not product. When it can, a checkout at the latest release shows \"Up to date\" and the failure text cannot be produced. Check `browser network` for the registry request before deciding which update rows are `blocked`.\n- The update dialog's `role=\"dialog\"` wrapper has no accessible name, so `role=dialog[name=...]` does not match it; use `testid=agent-canvas-update-modal`.\n- The `/settings` → `/settings/agents` redirect only happens at ≥1024 px. Below that (phones and tablets) `/settings` is the hub; do not expect a redirect there.\n- From 768 to 1023 px the sidebar rail stays and the phone top bar is gone; the Settings selector above each subpage provides sibling navigation. The rail's gear (`backend-selector-settings-link`) still returns to the hub.\n- The phone hub is indented 32 px while sub-pages start at 16 px (hub padding inside the page padding); that is current layout, not overflow.\n- The \"LLM Profiles\" rename in `useSettingsNavItems` targets a `/settings` nav item that does not exist, so it never applies; the LLM nav item always reads \"LLM\".\n- Known issue [#17508](https://github.com/OpenHands/OpenHands/issues/17508) (`F09.update-available`): After installing a newer package, an older running bundle can keep showing its own version and an update banner. Live prerequisite: Isolated old/new package installs with the old process still serving, or the original stale bundle/cache capture after update.\n- The public `SettingsNavigation` export renders the legacy `SettingsMobileDrawer` with a half-opacity black scrim (`bg-black/50`). Check its modern and fallback compiled CSS as a build contract. The routed phone hub uses `SettingsMobileHub`, so its navigation recipe does not exercise the exported drawer; visual verification of that export needs an embedding host.",
      "features": [
        {
          "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. Below 1024 px the settings `<main>` fills the column down to the viewport bottom and scrolls itself, so a popover inside it (the tablet selector) is not clipped on a short page (#18256)."
        },
        {
          "id": "F09.tablet-selector",
          "description": "at 768–1023 px, a Settings selector above the subpage opens the same visible section links as the desktop sidebar, including Cloud links when available. Selecting a sibling navigates directly and closes it; Tab reaches links, Escape closes and returns focus, and outside clicks dismiss it. The current section uses a slash-boundary match, so a trailing slash does not hide the selector or its selected link. It is absent on the hub, phones and desktops."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F09--gear-entry-and-index-redirect",
          "title": "Gear entry and index redirect",
          "ids": [
            "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\"`.",
          "raw": "**Gear entry and index redirect (`F09.entry-gear`, `F09.index-redirect`).** 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\"`.",
          "source_line": 62,
          "expected": [
            "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.",
            "on desktop, `/settings` replaces itself with `/settings/agents` (Back skips `/settings`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--collapsed-rail",
          "title": "Collapsed rail",
          "ids": [
            "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\").",
          "raw": "**Collapsed rail (`F09.entry-collapsed-rail`).** 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\").",
          "source_line": 63,
          "expected": [
            "with the sidebar collapsed, the rail's Settings icon opens the Agent page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--command-menu",
          "title": "Command menu",
          "ids": [
            "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.",
          "raw": "**Command menu (`F09.entry-command-menu`).** 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.",
          "source_line": 64,
          "expected": [
            "the command menu's Settings group (Settings, Agent settings, LLM profiles, Model Router, Condenser, Agent Context, Verification, Application, Secrets settings) opens the matching page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--command-menu-coverage",
          "title": "Command menu coverage",
          "ids": [
            "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.",
          "raw": "**Command menu coverage (`F09.command-menu-coverage`).** 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.",
          "source_line": 65,
          "expected": [
            "every page in the settings nav, including Model Router and Agent Context, can be found in the command menu and opens from it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--deep-links-without-a-model",
          "title": "Deep links without a model",
          "ids": [
            "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`.",
          "raw": "**Deep links without a model (`F09.entry-deep-links`).** 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`.",
          "source_line": 66,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--deep-links-from-the-composer",
          "title": "Deep links from the composer",
          "ids": [
            "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`.",
          "raw": "**Deep links from the composer (`F09.entry-deep-links`).** 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`.",
          "source_line": 67,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--legacy-url",
          "title": "Legacy URL",
          "ids": [
            "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 `/`.",
          "raw": "**Legacy URL (`F09.legacy-agent-redirect`).** Run `control-openhands browser goto /`, then `control-openhands browser goto /settings/agent`; the result already reads `.../settings/agents`. `control-openhands browser back` returns to `/`.",
          "source_line": 68,
          "expected": [
            "the retired `/settings/agent` URL replaces itself with `/settings/agents`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--desktop-nav",
          "title": "Desktop nav",
          "ids": [
            "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.",
          "raw": "**Desktop nav (`F09.desktop-nav`).** 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.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--page-header",
          "title": "Page header",
          "ids": [
            "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.",
          "raw": "**Page header (`F09.page-header`).** 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.",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--landmarks",
          "title": "Landmarks",
          "ids": [
            "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`.",
          "raw": "**Landmarks (`F09.landmarks`).** 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`.",
          "source_line": 71,
          "expected": [
            "the settings screen exposes a single `main` landmark, on desktop pages and on the phone hub."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--phone-hub-and-back",
          "title": "Phone hub and Back",
          "ids": [
            "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$'` and `control-openhands browser wait 'testid=sidebar-mobile-drawer' --state detached --timeout 5000` pass (the drawer closes as it animates out; an immediate `visible` can still read `true`). Return with `control-openhands browser viewport desktop`.",
          "raw": "**Phone hub and Back (`F09.phone-hub`, `F09.phone-back`).** 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$'` and `control-openhands browser wait 'testid=sidebar-mobile-drawer' --state detached --timeout 5000` pass (the drawer closes as it animates out; an immediate `visible` can still read `true`). Return with `control-openhands browser viewport desktop`.",
          "source_line": 72,
          "expected": [
            "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.",
            "at phone width each settings page shows a Back chevron (label \"Settings\") in the top bar that returns to the hub."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--resize-from-the-hub",
          "title": "Resize from the hub",
          "ids": [
            "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`.",
          "raw": "**Resize from the hub (`F09.hub-resize-redirect`).** 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`.",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--widths-around-the-breakpoint",
          "title": "Widths around the breakpoint",
          "ids": [
            "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`. Height: at `390x844`, `767x1024`, `820x1180` and `1023x900`, on `/settings/secrets` (a short page) `control-openhands browser eval \"Math.round(document.querySelector('[data-testid=settings-screen] main').getBoundingClientRect().bottom)+'/'+innerHeight\"` reads the viewport height twice (`1180/1180` at 820x1180), and on `/settings/app` `control-openhands browser eval \"(()=>{const m=document.querySelector('[data-testid=settings-screen] main');return m.scrollHeight>m.clientHeight})()\"` is `true` (the `<main>` scrolls, not an outer column). At 1440x1000 the desktop layout is unchanged (the Secrets `<main>` ends at its content). Finish with `control-openhands browser viewport desktop`.",
          "raw": "**Widths around the breakpoint (`F09.breakpoint-sweep`).** 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`. Height: at `390x844`, `767x1024`, `820x1180` and `1023x900`, on `/settings/secrets` (a short page) `control-openhands browser eval \"Math.round(document.querySelector('[data-testid=settings-screen] main').getBoundingClientRect().bottom)+'/'+innerHeight\"` reads the viewport height twice (`1180/1180` at 820x1180), and on `/settings/app` `control-openhands browser eval \"(()=>{const m=document.querySelector('[data-testid=settings-screen] main');return m.scrollHeight>m.clientHeight})()\"` is `true` (the `<main>` scrolls, not an outer column). At 1440x1000 the desktop layout is unchanged (the Secrets `<main>` ends at its content). Finish with `control-openhands browser viewport desktop`.",
          "source_line": 74,
          "expected": [
            "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. Below 1024 px the settings `<main>` fills the column down to the viewport bottom and scrolls itself, so a popover inside it (the tablet selector) is not clipped on a short page (#18256)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--tablet-selector",
          "title": "Tablet selector",
          "ids": [
            "F09.tablet-selector"
          ],
          "body": "Run `control-openhands browser viewport tablet`, `control-openhands browser goto /settings/app`, then `control-openhands browser click 'testid=settings-compact-navigation >> role=button'`. `control-openhands browser snapshot 'testid=settings-compact-navigation >> role=navigation'` lists every visible settings destination with Application current. Select `control-openhands browser click 'testid=settings-compact-navigation >> testid=sidebar-settings-/settings/secrets' --expect-url '/settings/secrets$'`; the selector now shows Secrets and its button has `aria-expanded=false`. Open again, `browser press Tab`, `browser press Escape`: `browser eval \"document.activeElement.getAttribute('aria-expanded')\"` is `false` (focus returned to the trigger). Capture `browser screenshot --feature F09.tablet-selector --name settings`. Repeat at 768 and 1023 px; at 767 and 1024 px `browser count 'testid=settings-compact-navigation'` is `0`. On `/settings` below 1024 px the hub remains the list. At tablet width, visit `/settings/llm/`: the trigger reads `Settings: LLM`, the page heading is LLM, and opening the selector marks LLM current. A nested URL such as `/settings/llm/example` is not a registered page and shows the global Not Found screen (F09.unknown-subpath).",
          "raw": "**Tablet selector (`F09.tablet-selector`).** Run `control-openhands browser viewport tablet`, `control-openhands browser goto /settings/app`, then `control-openhands browser click 'testid=settings-compact-navigation >> role=button'`. `control-openhands browser snapshot 'testid=settings-compact-navigation >> role=navigation'` lists every visible settings destination with Application current. Select `control-openhands browser click 'testid=settings-compact-navigation >> testid=sidebar-settings-/settings/secrets' --expect-url '/settings/secrets$'`; the selector now shows Secrets and its button has `aria-expanded=false`. Open again, `browser press Tab`, `browser press Escape`: `browser eval \"document.activeElement.getAttribute('aria-expanded')\"` is `false` (focus returned to the trigger). Capture `browser screenshot --feature F09.tablet-selector --name settings`. Repeat at 768 and 1023 px; at 767 and 1024 px `browser count 'testid=settings-compact-navigation'` is `0`. On `/settings` below 1024 px the hub remains the list. At tablet width, visit `/settings/llm/`: the trigger reads `Settings: LLM`, the page heading is LLM, and opening the selector marks LLM current. A nested URL such as `/settings/llm/example` is not a registered page and shows the global Not Found screen (F09.unknown-subpath).",
          "source_line": 75,
          "expected": [
            "at 768–1023 px, a Settings selector above the subpage opens the same visible section links as the desktop sidebar, including Cloud links when available. Selecting a sibling navigates directly and closes it; Tab reaches links, Escape closes and returns focus, and outside clicks dismiss it. The current section uses a slash-boundary match, so a trailing slash does not hide the selector or its selected link. It is absent on the hub, phones and desktops."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--synced-badge",
          "title": "Synced badge",
          "ids": [
            "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.",
          "raw": "**Synced badge (`F09.synced-badge`).** 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.",
          "source_line": 76,
          "expected": [
            "the nav footer reads \"These settings are synced from Local backend (<origin>)\" for the active backend."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--cloud-links",
          "title": "Cloud links",
          "ids": [
            "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.",
          "raw": "**Cloud links (`F09.cloud-links`).** 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.",
          "source_line": 77,
          "expected": [
            "on a Cloud backend only, the nav adds \"Integrations\" and \"All Cloud Settings\" external links; on a local backend neither exists."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F09--update-card-and-dialog",
          "title": "Update card and dialog",
          "ids": [
            "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.",
          "raw": "**Update card and dialog (`F09.update-card`, `F09.update-modal`, `F09.update-up-to-date`).** 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.",
          "source_line": 78,
          "expected": [
            "the \"Agent Canvas / Version x.y.z\" card opens a dialog; its \"Up to date\" / \"Update\" badge appears only once the npm check succeeded.",
            "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.",
            "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…\"."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--update-available",
          "title": "Update available",
          "ids": [
            "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.",
          "raw": "**Update available (`F09.update-available`).** 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.",
          "source_line": 79,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17508](https://github.com/OpenHands/OpenHands/issues/17508) (`F09.update-available`): After installing a newer package, an older running bundle can keep showing its own version and an update banner. Live prerequisite: Isolated old/new package installs with the old process still serving, or the original stale bundle/cache capture after update."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F09--update-dialog-at-phone-width",
          "title": "Update dialog at phone width",
          "ids": [
            "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`.",
          "raw": "**Update dialog at phone width (`F09.update-modal-phone`).** 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`.",
          "source_line": 80,
          "expected": [
            "the update dialog fits a 390 px (and a 320 px) viewport with its title and Close button on screen, and Close dismisses it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--sidebar-version-tile",
          "title": "Sidebar version tile",
          "ids": [
            "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).",
          "raw": "**Sidebar version tile (`F09.sidebar-version-tile`).** 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).",
          "source_line": 81,
          "expected": [
            "the main sidebar shows an \"Agent Canvas\" version tile only when an update is available."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F09--unknown-sub-page",
          "title": "Unknown sub-page",
          "ids": [
            "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 '/$'`.",
          "raw": "**Unknown sub-page (`F09.unknown-subpath`).** 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 '/$'`.",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F09--after-the-family",
          "title": "After the family",
          "ids": [],
          "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.",
          "raw": "**After the family.** 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.",
          "source_line": 83,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F09--contract-f09-locked-cloud-landing",
          "title": "Locked cloud landing",
          "ids": [
            "F09.locked-cloud-landing"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "on a canvas locked to a Cloud host, `/settings` lands on `/settings/app` instead."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F09--contract-f09-hidden-llm-redirect",
          "title": "Hidden llm redirect",
          "ids": [
            "F09.hidden-llm-redirect"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "with the `hide_llm_settings` feature flag, the LLM link disappears and `/settings/llm` redirects to `/settings/agents`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F10",
      "title": "LLM profiles",
      "file": "F10-llm-profiles.md",
      "sha256": "4594c48dc9f877bab6330eee6f1b4b16355b97e6523df66a6a52cd0ef8434d80",
      "overview": "An LLM profile is a named model configuration: provider and model (or a custom\nmodel and base URL), an API key or a ChatGPT subscription, an optional\nprovider-connection link, and every advanced LLM option. Under Settings → LLM a\nuser lists profiles (grouped by provider connection once any are linked), adds\nand edits them in an embedded form whose Save first runs a one-token validation\ncall, renames, duplicates, deletes and sets one as **Default**, and bulk-adds\nmodels from a provider. The Default profile is the model new conversations run\non. Provider connections themselves (add, edit, delete) are F11.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`, opens `/settings/agents`), then **LLM** in the settings navigation (`sidebar-settings-/settings/llm`).\n- Direct URL `/settings/llm`.\n- Command menu (`Control+k` or `Meta+k`, or `command-menu-trigger` in the sidebar): search \"LLM\", choose **LLM profiles** (\"Manage models, providers, and API keys.\").\n- On the list: **Add LLM Profile** (`add-llm-profile`), **Add from provider connections** (`add-models-from-provider`), each row's `...` (`profile-menu-trigger`), and a provider connection row's `...` → **Bulk add** (`provider-connection-add-models`).\n- The create/edit editor has no URL of its own: it replaces the list in place at `/settings/llm`; **Back** (`back-to-profiles`) and **Cancel** (`cancel-profile-btn`) return to the list.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done). Do not run `llm preset deepseek` first: a fresh run has no profiles, which the empty state and \"first profile becomes Default\" need.\n- A DeepSeek key for real saves, either in the environment as `DEEPSEEK_API_KEY` or in a file. Pass it only with `browser fill ... --value-env DEEPSEEK_API_KEY` or `--value-file <key file>`; never on the command line.\n- No profile or connection named `QA_*`, `deepseek-chat` or `deepseek-v3` exists (`control-openhands api GET /api/profiles`, `control-openhands api GET /api/llm/provider-connections`).\n- Select a row by exact name with the CSS form `'[data-testid=profile-row]:has([title=\"<name>\"])'`; `has-text=QA_pro` also matches `QA_pro-copy`.\n- Read toasts with `control-openhands browser wait-text '<text>'` (or `browser toasts`) right after the click; they disappear after a few seconds.\n- Blocked here: completing `F10.subscription` sign-in (needs a ChatGPT Plus/Pro account), `F10.cloud-readonly` (needs a Cloud org member account), `F10.cloud-provider-pagination` (needs a Cloud organization backend where the user can manage profiles), and `F10.schema-unavailable` (needs an agent server without the schema endpoints, for example an older `launch --sdk-ref`). The `F10.list-states` error is arranged by the Load error bullet (a linked profile plus this run's corrupted connections store).",
      "gotchas": "- Save always runs a real completion against the provider (except for connection-linked profiles). A bad or missing key fails fast with the provider's message; budget one tiny call per Save.\n- The editor's starting tab is inferred from the values it loads. Edit opens on whichever tab the profile's non-default values need (`QA_renamed` and `QA_adv` opened on Basic, `QA_broken` on All). A new editor opens on Basic only while the agent's LLM settings are all defaults: once the Default profile has a temperature (the schema step) it opens on All, and this survives deleting every profile, because the agent settings keep the last applied values. Click the tab you need before touching its fields, or `llm-provider-input` / `llm-custom-model-input` / `llm-api-key-help-anchor` will not exist.\n- `control-openhands service stop agent-server` cannot show this page's error states: the whole app switches to the disconnected-backend screen.\n- In Edit, `llm-api-key-input` holds the stored *encrypted* key token (about 140 masked characters), not an empty field. Never run `browser value` on it: it prints the token. Leave it untouched to keep the key.\n- An open provider or model listbox intercepts clicks on the editor's buttons (`cancel-profile-btn`, `save-profile-btn` time out with \"subtree intercepts pointer events\"): `browser press Escape` first.\n- Provider and model options render in a portal outside the editor and modals: select `testid=provider-item-<name>` unscoped. Only verified providers have `provider-item-*` IDs and only Other models have `model-item-*` IDs; use `role=option[name=\"...\"][exact]` for the rest. `browser testids --filter model-item` lists only items scrolled into view; `browser count` finds the rest.\n- The auto-filled Profile Name never reuses an existing profile's name: picking a model whose name is already a profile's (for example `deepseek-flash` after `llm preset deepseek`) empties the field, which disables Save. Recipes fill `profile-name-input` with a `QA_*` name anyway.\n- A duplicate profile name is flagged: `aria-invalid` `true` and a taken-name alert. The alert has its own wording, `An LLM profile with this name already exists. …`.\n- The Rename modal does not check duplicates client-side (Rename stays enabled); the server's 409 comes back as the toast `Profile '<name>' already exists`, and `browser errors --app-only` counts one `http-error` and one `console.error` for it.\n- Set as default (and deleting the Default) rewrites the agent's active LLM settings (`control-openhands llm show`). Deleting every profile leaves the last applied model and key in agent settings.\n- The app never leaves profiles without a Default: `src/routes/root-layout.tsx` runs `useEnsureActiveProfile` (`src/hooks/use-ensure-active-profile.ts`), which activates the first profile with a key (else the first profile) whenever profiles exist but none is Default, whether never activated or because the Default was deleted (this is what promotes a profile in `F10.delete-default`). So `llm set --no-activate` keeps the Default only while another profile is Default: on a run whose only profile is the arranged one, `control-openhands api GET /api/profiles --pick active_profile` flips from `null` to it at the next page load (`browser reload`) and the agent's LLM settings follow (`llm show`).\n- Profiles created by Add models are named from the model (`deepseek-chat`, …), not `QA_*`. The chooser hides a model when a profile with that *name* exists, not when a profile uses that model. Clean them up before the connection: the server refuses to delete a connection while profiles reference it.\n- A profile linked to a missing connection is grouped under a header showing the raw connection id (`00000000…`) instead of a name.\n- The All tab logs many `[i18n] Missing translation for key \"SCHEMA$LLM$...\"` console warnings; they are not page errors.\n- The list follows the server's order, which sorts by profile file name: `QA_pro-copy-1` lists before `QA_pro-copy` (`-` sorts before `.json`'s `.`). Do not assert a strict alphabetical order on names that are prefixes of each other.\n- Toasts last only a few seconds. A `browser screenshot` taken after a slow command can miss them, so assert with `browser wait-text` or `browser toasts`.\n- A new profile keeps the inherited LLM options the editor shows, such as temperature.\n- `Subscription models are not loaded yet.` uses the translated `SETTINGS$SUBSCRIPTION_MODELS_NOT_LOADED` copy in settings and onboarding. It shows when a subscription profile is saved before `GET /api/llm/subscription/openai/models` answers; checking that branch needs the real request to be pending or failed.\n- Known issue [#17785](https://github.com/OpenHands/OpenHands/issues/17785): Saving DeepSeek-v4-flash on Windows Desktop can fail with missing cache_creation_tokens instead of saving. The reported path needs a Windows Desktop 1.24.0 environment with its SDK dependency resolution and an approved DeepSeek key; this macOS source run cannot establish that result. (`F10.create-validation`).\n- Known issue [#17706](https://github.com/OpenHands/OpenHands/issues/17706): A freshly resolved npm-launcher environment can reject openhands/claude-* conversations because LiteLLM sends prompt_cache_key. Reproduction needs an approved OpenHands proxy key with an Anthropic model and the affected fresh transitive dependency resolution; a keyless initialization cannot prove provider acceptance. (`F10.default-used`).\n- Known issue [#17391](https://github.com/OpenHands/OpenHands/issues/17391): ChatGPT Business subscription login can be refused or use a fallback model with a 404. Completing the entitlement and selected-model checks needs an approved ChatGPT Business workspace login and the reported Docker subscription runtime. (`F10.subscription`).\n- Known issue [OpenHands/software-agent-sdk#5181](https://github.com/OpenHands/software-agent-sdk/issues/5181): The SDK can turn an advertised output limit into a requested default cap and silently drop an explicitly enabled reasoning_effort. The narrowed maintainer scope needs an authenticated OpenRouter/proxy route exposing lower-limit endpoints and capability metadata, or a real endpoint accepting the explicit override; a synthetic empty-metadata response would not establish production behavior. (`F10.schema-validation`).\n- Known issue [OpenHands/software-agent-sdk#4934](https://github.com/OpenHands/software-agent-sdk/issues/4934): Switching to a MiniMax M3 profile can fail because the SDK sends unsupported reasoning_effort. The runtime switch needs approved working credentials for the starting model and MiniMax route; the reported provider rejection cannot be proved with a loopback connection error. (`F10.schema-validation`, `F10.default-used`).\n- Known issue [OpenHands/software-agent-sdk#4613](https://github.com/OpenHands/software-agent-sdk/issues/4613): A namespaced model such as openai/openai/example-model can have its inner provider prefix stripped again in Responses API and reach the provider as example-model. Reproduction needs an approved OpenAI-compatible endpoint serving the namespaced model and its exact stored API-mode settings; a loopback connection refusal cannot prove the outbound identifier. (`F10.custom-model`).\n- Known issue [OpenHands/software-agent-sdk#4511](https://github.com/OpenHands/software-agent-sdk/issues/4511): Disabling prompt caching can still emit cache_control on preexisting cache-marked tool messages. The report only supplies a Python serializer reproduction; a Canvas check needs the exact persisted/tool-message precondition and a provider request that exposes the cache markers, with approved model access. (`F10.schema-validation`).\n- Known issue [OpenHands/software-agent-sdk#4250](https://github.com/OpenHands/software-agent-sdk/issues/4250): A Cloud Workers AI configuration can fail on context-window validation or be routed as OpenAI with an invalid Cloudflare key. Reproduction needs a disposable OpenHands Cloud account, its Workers AI endpoint/key and the exact saved model configuration; the report supplies no complete configuration steps. (`F10.custom-model`, `F10.default-used`).\n- Known issue [OpenHands/software-agent-sdk#4247](https://github.com/OpenHands/software-agent-sdk/issues/4247): An LM Studio configuration can fail with LLM Provider NOT provided although its models endpoint works. Reproduction needs the reachable LM Studio service and the exact model ID/provider-qualified config, which the report does not supply as text; the reported CLI/Docker SDK 1.16.1 installation is not this source stack. (`F10.custom-model`).\n- Known issue [OpenHands/software-agent-sdk#5890](https://github.com/OpenHands/software-agent-sdk/issues/5890): Switching the conversation LLM can lose the managed-key refresh-on401 hook. Blocked: needs an approved managed-key refresh source and an allow-listed real model endpoint that returns401 and accepts the refreshed key. Refused loopback cannot establish the refresh/retry outcome; do not supply a fake successful completion. (`F10.edit`, `F10.default-used`).\n- Known issue [OpenHands/software-agent-sdk#5862](https://github.com/OpenHands/software-agent-sdk/issues/5862): DeepSeek invalid-key401 responses can be classified as bad-request errors. Blocked by this pass's loopback-only provider routing: a real invalid-key validation request to DeepSeek must be authorized to capture its actual401 shape. No usable key or successful inference is needed, but a loopback connection error does not prove this classification. (`F10.create-validation`).\n- Known issue [OpenHands/software-agent-sdk#5838](https://github.com/OpenHands/software-agent-sdk/issues/5838): A live LLM switch during a running tool step can be acknowledged before the new LLM is persisted for GET/restart. Blocked: needs a genuine model-issued bounded tool step (for example `sleep 20`) and an approved starting model. Refused-loopback initialization never enters that step. (`F10.default-used`).\n- Known issue [#17806](https://github.com/OpenHands/OpenHands/issues/17806): A model-only Save must preserve an untouched key. Current SDK1.55 refused-loopback validation rejects Save before persistence; the stored key marker remains set. Completing Basic/Advanced preserved-key and runtime-switch proof needs an approved route that accepts preflight. Keep dummy traffic on refused loopback until an external route is authorized; a rejected Save does not establish either key loss or successful preservation through Save. (`F10.edit`).",
      "features": [
        {
          "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` in the theme's error colour."
        },
        {
          "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 marks the field invalid, shows a taken-name message about LLM profiles and disables Save (Edit accepts the profile's own name)."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F10--empty-list",
          "title": "Empty list",
          "ids": [
            "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`.",
          "raw": "**Empty list (`F10.list-empty`).** 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`.",
          "source_line": 70,
          "expected": [
            "with no profiles the list reads `No profiles saved yet. Add a profile to save your LLM configurations.` and both Add buttons stay available."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--open-the-editor",
          "title": "Open the editor",
          "ids": [
            "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'`).",
          "raw": "**Open the editor (`F10.create`).** 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'`).",
          "source_line": 71,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--api-key-help-link",
          "title": "API key help link",
          "ids": [
            "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`.",
          "raw": "**API key help link (`F10.api-key-help`).** 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`.",
          "source_line": 72,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--provider-and-model-lists",
          "title": "Provider and model lists",
          "ids": [
            "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`.",
          "raw": "**Provider and model lists (`F10.model-picker`).** 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`.",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--every-provider-is-offered",
          "title": "Every provider is offered",
          "ids": [
            "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.",
          "raw": "**Every provider is offered (`F10.provider-list-complete`).** 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.",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--cloud-provider-pagination",
          "title": "Cloud provider pagination",
          "ids": [
            "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.",
          "raw": "**Cloud provider pagination (`F10.cloud-provider-pagination`).** 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.",
          "source_line": 75,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F10--create-the-first-profile",
          "title": "Create the first profile",
          "ids": [
            "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`.",
          "raw": "**Create the first profile (`F10.create`, `F10.name-autofill`).** 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`.",
          "source_line": 76,
          "expected": [
            "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.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--a-typed-name-sticks-a-cleared-one-follows-again",
          "title": "A typed name sticks; a cleared one follows again",
          "ids": [
            "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`.",
          "raw": "**A typed name sticks; a cleared one follows again (`F10.name-autofill`).** 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`.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--name-rules",
          "title": "Name rules",
          "ids": [
            "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 to the theme's danger colour: `control-openhands browser eval \"(()=>{const i=document.querySelector('[data-testid=profile-name-input]');return getComputedStyle(document.getElementById(i.getAttribute('aria-describedby'))).color+' '+getComputedStyle(document.body).getPropertyValue('--oh-color-danger')})()\"` reads `rgb(231, 106, 94) #e76a5e` on OpenHands-Neutral and `rgb(209, 52, 56) #D13438` on Light+ (`F16.color-theme`) (#18199); a valid name turns it back to the muted grey. An existing name (`QA_flash`) also gives `enabled` `false` and `aria-invalid` `true`, with a role=alert message under the field in the danger colour while the rule text stays grey. The message reads `An LLM profile with this name already exists. Choose a different name, or edit the existing one.` (`browser eval \"[...document.querySelectorAll('[role=alert]')].map(a=>a.innerText+' | '+getComputedStyle(a).color)\"`; screenshot `--feature F10.name-validation --name duplicate-neutral`). `QA_flash2` gives `true` and no message.",
          "raw": "**Name rules (`F10.name-validation`).** 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 to the theme's danger colour: `control-openhands browser eval \"(()=>{const i=document.querySelector('[data-testid=profile-name-input]');return getComputedStyle(document.getElementById(i.getAttribute('aria-describedby'))).color+' '+getComputedStyle(document.body).getPropertyValue('--oh-color-danger')})()\"` reads `rgb(231, 106, 94) #e76a5e` on OpenHands-Neutral and `rgb(209, 52, 56) #D13438` on Light+ (`F16.color-theme`) (#18199); a valid name turns it back to the muted grey. An existing name (`QA_flash`) also gives `enabled` `false` and `aria-invalid` `true`, with a role=alert message under the field in the danger colour while the rule text stays grey. The message reads `An LLM profile with this name already exists. Choose a different name, or edit the existing one.` (`browser eval \"[...document.querySelectorAll('[role=alert]')].map(a=>a.innerText+' | '+getComputedStyle(a).color)\"`; screenshot `--feature F10.name-validation --name duplicate-neutral`). `QA_flash2` gives `true` and no message.",
          "source_line": 78,
          "expected": [
            "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 marks the field invalid, shows a taken-name message about LLM profiles and disables Save (Edit accepts the profile's own name)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--validation-failure",
          "title": "Validation failure",
          "ids": [
            "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`.",
          "raw": "**Validation failure (`F10.create-validation`).** 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`.",
          "source_line": 79,
          "expected": [
            "a Save whose validation call fails toasts the provider error and keeps the editor open with nothing saved."
          ],
          "notes": [
            "Known issue [#17785](https://github.com/OpenHands/OpenHands/issues/17785): Saving DeepSeek-v4-flash on Windows Desktop can fail with missing cache_creation_tokens instead of saving. The reported path needs a Windows Desktop 1.24.0 environment with its SDK dependency resolution and an approved DeepSeek key; this macOS source run cannot establish that result. (`F10.create-validation`).",
            "Known issue [OpenHands/software-agent-sdk#5862](https://github.com/OpenHands/software-agent-sdk/issues/5862): DeepSeek invalid-key401 responses can be classified as bad-request errors. Blocked by this pass's loopback-only provider routing: a real invalid-key validation request to DeepSeek must be authorized to capture its actual401 shape. No usable key or successful inference is needed, but a loopback connection error does not prove this classification. (`F10.create-validation`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F10--custom-model",
          "title": "Custom model",
          "ids": [
            "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`.",
          "raw": "**Custom model (`F10.custom-model`).** 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`.",
          "source_line": 80,
          "expected": [
            "the Advanced tab takes a free-text model and Base URL; an empty model is refused with `Model is required`."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4613](https://github.com/OpenHands/software-agent-sdk/issues/4613): A namespaced model such as openai/openai/example-model can have its inner provider prefix stripped again in Responses API and reach the provider as example-model. Reproduction needs an approved OpenAI-compatible endpoint serving the namespaced model and its exact stored API-mode settings; a loopback connection refusal cannot prove the outbound identifier. (`F10.custom-model`).",
            "Known issue [OpenHands/software-agent-sdk#4250](https://github.com/OpenHands/software-agent-sdk/issues/4250): A Cloud Workers AI configuration can fail on context-window validation or be routed as OpenAI with an invalid Cloudflare key. Reproduction needs a disposable OpenHands Cloud account, its Workers AI endpoint/key and the exact saved model configuration; the report supplies no complete configuration steps. (`F10.custom-model`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4247](https://github.com/OpenHands/software-agent-sdk/issues/4247): An LM Studio configuration can fail with LLM Provider NOT provided although its models endpoint works. Reproduction needs the reachable LM Studio service and the exact model ID/provider-qualified config, which the report does not supply as text; the reported CLI/Docker SDK 1.16.1 installation is not this source stack. (`F10.custom-model`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F10--second-profile-from-a-verified-model",
          "title": "Second profile from a verified model",
          "ids": [],
          "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.",
          "raw": "**Second profile from a verified model.** 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.",
          "source_line": 81,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F10--row-menu",
          "title": "Row menu",
          "ids": [
            "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`.",
          "raw": "**Row menu (`F10.actions-menu`).** 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`.",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--set-as-default",
          "title": "Set as default",
          "ids": [
            "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.",
          "raw": "**Set as default (`F10.set-default`).** 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.",
          "source_line": 83,
          "expected": [
            "Set as default toasts `Switched to profile \"<name>\"`, moves the badge, persists and becomes the agent's active LLM."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--duplicate",
          "title": "Duplicate",
          "ids": [
            "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.",
          "raw": "**Duplicate (`F10.duplicate`).** 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.",
          "source_line": 84,
          "expected": [
            "Duplicate saves `<name>-copy`, then `<name>-copy-1`, with the same model and stored key; the copy is not Default."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--rename",
          "title": "Rename",
          "ids": [
            "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-danger` (the theme danger colour, #18199). 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.",
          "raw": "**Rename (`F10.rename`).** 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-danger` (the theme danger colour, #18199). 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.",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--rename-onto-an-existing-name",
          "title": "Rename onto an existing name",
          "ids": [
            "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\"]'`.",
          "raw": "**Rename onto an existing name (`F10.rename-conflict`).** 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\"]'`.",
          "source_line": 86,
          "expected": [
            "renaming onto an existing name is refused with the server's message (`Profile '<name>' already exists`) and the modal stays open."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--edit",
          "title": "Edit",
          "ids": [
            "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`). Filling another profile's name (`QA_flash`) sets `aria-invalid` `true` with the taken-name message of `F10.name-validation` and keeps Save disabled; refilling `QA_renamed` clears it. 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.",
          "raw": "**Edit (`F10.edit`).** 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`). Filling another profile's name (`QA_flash`) sets `aria-invalid` `true` with the taken-name message of `F10.name-validation` and keeps Save disabled; refilling `QA_renamed` clears it. 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.",
          "source_line": 87,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5890](https://github.com/OpenHands/software-agent-sdk/issues/5890): Switching the conversation LLM can lose the managed-key refresh-on401 hook. Blocked: needs an approved managed-key refresh source and an allow-listed real model endpoint that returns401 and accepts the refreshed key. Refused loopback cannot establish the refresh/retry outcome; do not supply a fake successful completion. (`F10.edit`, `F10.default-used`).",
            "Known issue [#17806](https://github.com/OpenHands/OpenHands/issues/17806): A model-only Save must preserve an untouched key. Current SDK1.55 refused-loopback validation rejects Save before persistence; the stored key marker remains set. Completing Basic/Advanced preserved-key and runtime-switch proof needs an approved route that accepts preflight. Keep dummy traffic on refused loopback until an external route is authorized; a rejected Save does not establish either key loss or successful preservation through Save. (`F10.edit`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--basic-re-save-keeps-a-hidden-base-url",
          "title": "Basic re-save keeps a hidden Base URL",
          "ids": [
            "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).",
          "raw": "**Basic re-save keeps a hidden Base URL (`F10.basic-save-keeps-base-url`).** `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).",
          "source_line": 88,
          "expected": [
            "re-saving a profile from the Basic view without changing its model keeps the Base URL that view hides."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--leave-without-saving",
          "title": "Leave without saving",
          "ids": [
            "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.",
          "raw": "**Leave without saving (`F10.editor-discard`).** 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.",
          "source_line": 89,
          "expected": [
            "Back and Cancel return to the list without saving and without an unsaved-changes prompt."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--schema-field-validation-and-re-apply",
          "title": "Schema field validation and re-apply",
          "ids": [
            "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`.",
          "raw": "**Schema field validation and re-apply (`F10.schema-validation`, `F10.edit-default-reapply`).** 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`.",
          "source_line": 90,
          "expected": [
            "bad values in schema fields (Temperature over 2, Top P over 1, invalid JSON) are refused with a toast naming the field.",
            "saving the Default profile re-applies it to the agent's LLM settings."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5181](https://github.com/OpenHands/software-agent-sdk/issues/5181): The SDK can turn an advertised output limit into a requested default cap and silently drop an explicitly enabled reasoning_effort. The narrowed maintainer scope needs an authenticated OpenRouter/proxy route exposing lower-limit endpoints and capability metadata, or a real endpoint accepting the explicit override; a synthetic empty-metadata response would not establish production behavior. (`F10.schema-validation`).",
            "Known issue [OpenHands/software-agent-sdk#4934](https://github.com/OpenHands/software-agent-sdk/issues/4934): Switching to a MiniMax M3 profile can fail because the SDK sends unsupported reasoning_effort. The runtime switch needs approved working credentials for the starting model and MiniMax route; the reported provider rejection cannot be proved with a loopback connection error. (`F10.schema-validation`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4511](https://github.com/OpenHands/software-agent-sdk/issues/4511): Disabling prompt caching can still emit cache_control on preexisting cache-marked tool messages. The report only supplies a Python serializer reproduction; a Canvas check needs the exact persisted/tool-message precondition and a provider request that exposes the cache markers, with approved model access. (`F10.schema-validation`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F10--new-profile-prefill",
          "title": "New profile prefill",
          "ids": [
            "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 `control-openhands assert --field value --equals 0.3 -- browser value 'testid=sdk-settings-llm.temperature'` passes: the temperature is 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, `control-openhands browser scroll 'testid=sdk-settings-llm.temperature'`, `control-openhands browser screenshot --feature F10.create-prefill --name inherited-before-save`, Save and `wait-text 'Profile \"QA_inherit\" created' --timeout 60000`. The saved profile has the temperature the form showed: `control-openhands api GET /api/profiles/QA_inherit --pick config.temperature` is `0.3`. After `browser reload`, open `QA_inherit`'s row menu (`'[data-testid=profile-row]:has([title=\"QA_inherit\"]) >> testid=profile-menu-trigger'`), click `testid=profile-actions-menu >> testid=profile-edit` and `testid=sdk-section-all-toggle`, `control-openhands browser scroll 'testid=sdk-settings-llm.temperature'` and `control-openhands assert --field value --equals 0.3 -- browser value 'testid=sdk-settings-llm.temperature'` passes (screenshot `--feature F10.create-prefill --name edit-after-create`); leave with `testid=cancel-profile-btn`. Repeat at `browser viewport phone` after `control-openhands api DELETE /api/profiles/QA_inherit --write` (the desktop pass already took the name); the same holds.",
          "raw": "**New profile prefill (`F10.create-prefill`).** 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 `control-openhands assert --field value --equals 0.3 -- browser value 'testid=sdk-settings-llm.temperature'` passes: the temperature is 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, `control-openhands browser scroll 'testid=sdk-settings-llm.temperature'`, `control-openhands browser screenshot --feature F10.create-prefill --name inherited-before-save`, Save and `wait-text 'Profile \"QA_inherit\" created' --timeout 60000`. The saved profile has the temperature the form showed: `control-openhands api GET /api/profiles/QA_inherit --pick config.temperature` is `0.3`. After `browser reload`, open `QA_inherit`'s row menu (`'[data-testid=profile-row]:has([title=\"QA_inherit\"]) >> testid=profile-menu-trigger'`), click `testid=profile-actions-menu >> testid=profile-edit` and `testid=sdk-section-all-toggle`, `control-openhands browser scroll 'testid=sdk-settings-llm.temperature'` and `control-openhands assert --field value --equals 0.3 -- browser value 'testid=sdk-settings-llm.temperature'` passes (screenshot `--feature F10.create-prefill --name edit-after-create`); leave with `testid=cancel-profile-btn`. Repeat at `browser viewport phone` after `control-openhands api DELETE /api/profiles/QA_inherit --write` (the desktop pass already took the name); the same holds.",
          "source_line": 91,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--link-to-a-provider-connection",
          "title": "Link to a provider connection",
          "ids": [
            "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`).",
          "raw": "**Link to a provider connection (`F10.connection-link`, `F10.list-grouped`).** 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`).",
          "source_line": 92,
          "expected": [
            "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.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--add-models-from-a-provider",
          "title": "Add models from a provider",
          "ids": [
            "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\"]'`.",
          "raw": "**Add models from a provider (`F10.add-models`).** 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\"]'`.",
          "source_line": 93,
          "expected": [
            "\"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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--broken-link",
          "title": "Broken link",
          "ids": [
            "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).",
          "raw": "**Broken link (`F10.broken-link`).** 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).",
          "source_line": 94,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--chatgpt-subscription-pre-auth",
          "title": "ChatGPT subscription, pre-auth",
          "ids": [
            "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`). Known failure (reproduced 2026-10-13, #17135): run `control-openhands browser fill 'testid=profile-name-input' QA_sub`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=save-profile-btn'` and `control-openhands browser network --bodies --filter '/api/profiles'`. Expected: Save validates and saves the shown `gpt-5.5`. Actual: one `POST /api/profiles/QA_sub/validate` whose body has `\"model\":\"openai/gpt-5.6-sol\"`, the API-key form's hidden default, although the dropdown shows `gpt-5.5` (it shows the first subscription model without writing it to the form); `browser toasts --history` has `OpenAI subscription login is required`, because no subscription is connected. Picking a model in `testid=llm-subscription-model-input`, even `gpt-5.5` again, makes the next Save send `POST /api/profiles/QA_sub/validate`, which answers `OpenAI subscription login is required` here (`browser toasts --history`), because no subscription is connected. `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).",
          "raw": "**ChatGPT subscription, pre-auth (`F10.subscription`).** 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`). Known failure (reproduced 2026-10-13, #17135): run `control-openhands browser fill 'testid=profile-name-input' QA_sub`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=save-profile-btn'` and `control-openhands browser network --bodies --filter '/api/profiles'`. Expected: Save validates and saves the shown `gpt-5.5`. Actual: one `POST /api/profiles/QA_sub/validate` whose body has `\"model\":\"openai/gpt-5.6-sol\"`, the API-key form's hidden default, although the dropdown shows `gpt-5.5` (it shows the first subscription model without writing it to the form); `browser toasts --history` has `OpenAI subscription login is required`, because no subscription is connected. Picking a model in `testid=llm-subscription-model-input`, even `gpt-5.5` again, makes the next Save send `POST /api/profiles/QA_sub/validate`, which answers `OpenAI subscription login is required` here (`browser toasts --history`), because no subscription is connected. `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).",
          "source_line": 95,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17391](https://github.com/OpenHands/OpenHands/issues/17391): ChatGPT Business subscription login can be refused or use a fallback model with a 404. Completing the entitlement and selected-model checks needs an approved ChatGPT Business workspace login and the reported Docker subscription runtime. (`F10.subscription`)."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-13, #17135): run `control-openhands browser fill 'testid=profile-name-input' QA_sub`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=save-profile-btn'` and `control-openhands browser network --bodies --filter '/api/profiles'`. Expected: Save validates and saves the shown `gpt-5.5`. Actual: one `POST /api/profiles/QA_sub/validate` whose body has `\"model\":\"openai/gpt-5.6-sol\"`, the API-key form's hidden default, although the dropdown shows `gpt-5.5` (it shows the first subscription model without writing it to the form); `browser toasts --history` has `OpenAI subscription login is required`, because no subscription is connected. Picking a model in `testid=llm-subscription-model-input`, even `gpt-5.5` again, makes the next Save send `POST /api/profiles/QA_sub/validate`, which answers `OpenAI subscription login is required` here (`browser toasts --history`), because no subscription is connected. `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)."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--delete",
          "title": "Delete",
          "ids": [
            "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`.",
          "raw": "**Delete (`F10.delete`, `F10.delete-default`).** 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`.",
          "source_line": 96,
          "expected": [
            "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.",
            "deleting the Default profile promotes another remaining profile to Default."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--command-menu-and-direct-url",
          "title": "Command menu and direct URL",
          "ids": [
            "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`).",
          "raw": "**Command menu and direct URL (`F10.list`).** 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`).",
          "source_line": 97,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--phone-layout",
          "title": "Phone layout",
          "ids": [
            "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`.",
          "raw": "**Phone layout (`F10.phone`).** 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`.",
          "source_line": 98,
          "expected": [
            "list, row menu and editor fit a 390 px viewport without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--default-profile-drives-a-conversation",
          "title": "Default profile drives a conversation",
          "ids": [
            "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.",
          "raw": "**Default profile drives a conversation (`F10.default-used`).** 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.",
          "source_line": 99,
          "expected": [
            "a conversation started after creating a profile in the UI runs on the Default profile's model."
          ],
          "notes": [
            "Known issue [#17706](https://github.com/OpenHands/OpenHands/issues/17706): A freshly resolved npm-launcher environment can reject openhands/claude-* conversations because LiteLLM sends prompt_cache_key. Reproduction needs an approved OpenHands proxy key with an Anthropic model and the affected fresh transitive dependency resolution; a keyless initialization cannot prove provider acceptance. (`F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4934](https://github.com/OpenHands/software-agent-sdk/issues/4934): Switching to a MiniMax M3 profile can fail because the SDK sends unsupported reasoning_effort. The runtime switch needs approved working credentials for the starting model and MiniMax route; the reported provider rejection cannot be proved with a loopback connection error. (`F10.schema-validation`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4250](https://github.com/OpenHands/software-agent-sdk/issues/4250): A Cloud Workers AI configuration can fail on context-window validation or be routed as OpenAI with an invalid Cloudflare key. Reproduction needs a disposable OpenHands Cloud account, its Workers AI endpoint/key and the exact saved model configuration; the report supplies no complete configuration steps. (`F10.custom-model`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#5890](https://github.com/OpenHands/software-agent-sdk/issues/5890): Switching the conversation LLM can lose the managed-key refresh-on401 hook. Blocked: needs an approved managed-key refresh source and an allow-listed real model endpoint that returns401 and accepts the refreshed key. Refused loopback cannot establish the refresh/retry outcome; do not supply a fake successful completion. (`F10.edit`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#5838](https://github.com/OpenHands/software-agent-sdk/issues/5838): A live LLM switch during a running tool step can be acknowledged before the new LLM is persisted for GET/restart. Blocked: needs a genuine model-issued bounded tool step (for example `sleep 20`) and an approved starting model. Refused-loopback initialization never enters that step. (`F10.default-used`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--persistence-across-a-restart",
          "title": "Persistence across a restart",
          "ids": [
            "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.",
          "raw": "**Persistence across a restart (`F10.list`).** `control-openhands restart`, `control-openhands doctor`, `control-openhands browser goto /settings/llm`: the same rows and the same Default badge are back.",
          "source_line": 100,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--load-error",
          "title": "Load error",
          "ids": [
            "F10.list-states"
          ],
          "body": "With any profile linked to a connection (here `QA_adv` → `QA_ds_conn`, from the Link bullet), make this run's connections store unreadable (shell arrange, this run only): `F=$OH_VERIFY_RUN/private/provider-connections/provider_connections.json; cp $F $F.bak; printf '{not json' > $F`. `control-openhands api GET /api/profiles` is now `400` (`Provider connections file is unreadable: …`). Run `control-openhands browser reload`, `control-openhands browser wait 'testid=profiles-load-error'` and `control-openhands browser wait 'testid=provider-connections-load-error'` (it can appear one retry after the profiles error): the first reads `Failed to load profiles` in the theme's error colour (`browser eval \"getComputedStyle(document.querySelector('[data-testid=profiles-load-error] p')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(192, 40, 45)` on Light+, #18330), with both Add buttons still shown and `provider-connections-load-error` below it (`F11.load-error`); screenshot `--feature F10.list-states --name load-error-neutral`. Restore with `cp $F.bak $F && rm $F.bak` and `browser reload`. The backend-down route does not reach it: `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`.",
          "raw": "**Load error (`F10.list-states`).** With any profile linked to a connection (here `QA_adv` → `QA_ds_conn`, from the Link bullet), make this run's connections store unreadable (shell arrange, this run only): `F=$OH_VERIFY_RUN/private/provider-connections/provider_connections.json; cp $F $F.bak; printf '{not json' > $F`. `control-openhands api GET /api/profiles` is now `400` (`Provider connections file is unreadable: …`). Run `control-openhands browser reload`, `control-openhands browser wait 'testid=profiles-load-error'` and `control-openhands browser wait 'testid=provider-connections-load-error'` (it can appear one retry after the profiles error): the first reads `Failed to load profiles` in the theme's error colour (`browser eval \"getComputedStyle(document.querySelector('[data-testid=profiles-load-error] p')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(192, 40, 45)` on Light+, #18330), with both Add buttons still shown and `provider-connections-load-error` below it (`F11.load-error`); screenshot `--feature F10.list-states --name load-error-neutral`. Restore with `cp $F.bak $F && rm $F.bak` and `browser reload`. The backend-down route does not reach it: `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`.",
          "source_line": 101,
          "expected": [
            "a spinner shows while profiles load; a load failure shows `Failed to load profiles` in the theme's error colour."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F10--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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}`.",
          "raw": "**Cleanup.** 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}`.",
          "source_line": 102,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F10--embedded-editor-starts-on-basic",
          "title": "Embedded editor starts on Basic",
          "ids": [
            "F10.create-prefill",
            "F10.edit"
          ],
          "body": "Arrange an active disposable `QA_LOOPBACK` profile through the real profiles API with `llm.model` `openai/gpt-4o`, a dummy key, `llm.base_url` `http://127.0.0.1:1/v1`, `num_retries` `0` and temperature `0.4` (no completion). Run `control-openhands browser goto /settings/llm`, `control-openhands browser click 'testid=add-llm-profile'` and `control-openhands browser snapshot 'role=tablist'`. Expected by [#17801](https://github.com/OpenHands/OpenHands/issues/17801): Basic selected each time the embedded form opens. Known failure, reproduced 2026-10-12: All is selected. Back, open the exact `QA_LOOPBACK` row menu and `testid=profile-actions-menu >> testid=profile-edit`; the tablist also selects All. Repeat Add/Edit once, then Back without saving. This exercises the non-default settings condition; it does not claim that a default-only profile opens on All.",
          "raw": "**Embedded editor starts on Basic (`F10.create-prefill`, `F10.edit`).** Arrange an active disposable `QA_LOOPBACK` profile through the real profiles API with `llm.model` `openai/gpt-4o`, a dummy key, `llm.base_url` `http://127.0.0.1:1/v1`, `num_retries` `0` and temperature `0.4` (no completion). Run `control-openhands browser goto /settings/llm`, `control-openhands browser click 'testid=add-llm-profile'` and `control-openhands browser snapshot 'role=tablist'`. Expected by [#17801](https://github.com/OpenHands/OpenHands/issues/17801): Basic selected each time the embedded form opens. Known failure, reproduced 2026-10-12: All is selected. Back, open the exact `QA_LOOPBACK` row menu and `testid=profile-actions-menu >> testid=profile-edit`; the tablist also selects All. Repeat Add/Edit once, then Back without saving. This exercises the non-default settings condition; it does not claim that a default-only profile opens on All.",
          "source_line": 104,
          "expected": [
            "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.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5890](https://github.com/OpenHands/software-agent-sdk/issues/5890): Switching the conversation LLM can lose the managed-key refresh-on401 hook. Blocked: needs an approved managed-key refresh source and an allow-listed real model endpoint that returns401 and accepts the refreshed key. Refused loopback cannot establish the refresh/retry outcome; do not supply a fake successful completion. (`F10.edit`, `F10.default-used`).",
            "Known issue [#17806](https://github.com/OpenHands/OpenHands/issues/17806): A model-only Save must preserve an untouched key. Current SDK1.55 refused-loopback validation rejects Save before persistence; the stored key marker remains set. Completing Basic/Advanced preserved-key and runtime-switch proof needs an approved route that accepts preflight. Keep dummy traffic on refused loopback until an external route is authorized; a rejected Save does not establish either key loss or successful preservation through Save. (`F10.edit`)."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: All is selected. Back, open the exact `QA_LOOPBACK` row menu and `testid=profile-actions-menu >> testid=profile-edit`; the tablist also selects All. Repeat Add/Edit once, then Back without saving. This exercises the non-default settings condition; it does not claim that a default-only profile opens on All."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--off-catalog-basic-model",
          "title": "Off-catalog Basic model",
          "ids": [
            "F10.model-picker"
          ],
          "body": "From the list open Add, click `testid=sdk-section-basic-toggle`, then `control-openhands browser choose 'testid=llm-provider-input' OpenRouter`. Run `control-openhands browser fill 'testid=llm-model-input' 'thinkingmachines/inkling-small:free'`, `control-openhands browser press Tab` and `control-openhands browser value 'testid=llm-model-input'`; repeat fill/Tab once. Expected: keep the model entered under the selected provider. Known failure, reproduced 2026-10-12: the value is empty on both attempts ([#17577](https://github.com/OpenHands/OpenHands/issues/17577), concrete companion of [#17233](https://github.com/OpenHands/OpenHands/issues/17233)). Capture `browser screenshot --feature F10.model-picker --name off-catalog-discarded` and Back without saving. The Advanced provider-prefix/authenticated-save half needs a working OpenRouter route; this check proves the Basic entry dead end.",
          "raw": "**Off-catalog Basic model (`F10.model-picker`).** From the list open Add, click `testid=sdk-section-basic-toggle`, then `control-openhands browser choose 'testid=llm-provider-input' OpenRouter`. Run `control-openhands browser fill 'testid=llm-model-input' 'thinkingmachines/inkling-small:free'`, `control-openhands browser press Tab` and `control-openhands browser value 'testid=llm-model-input'`; repeat fill/Tab once. Expected: keep the model entered under the selected provider. Known failure, reproduced 2026-10-12: the value is empty on both attempts ([#17577](https://github.com/OpenHands/OpenHands/issues/17577), concrete companion of [#17233](https://github.com/OpenHands/OpenHands/issues/17233)). Capture `browser screenshot --feature F10.model-picker --name off-catalog-discarded` and Back without saving. The Advanced provider-prefix/authenticated-save half needs a working OpenRouter route; this check proves the Basic entry dead end.",
          "source_line": 106,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12: the value is empty on both attempts ([#17577](https://github.com/OpenHands/OpenHands/issues/17577), concrete companion of [#17233](https://github.com/OpenHands/OpenHands/issues/17233)). Capture `browser screenshot --feature F10.model-picker --name off-catalog-discarded` and Back without saving. The Advanced provider-prefix/authenticated-save half needs a working OpenRouter route; this check proves the Basic entry dead end."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F10--azure-base-url-on-basic",
          "title": "Azure Base URL on Basic",
          "ids": [
            "F10.model-picker"
          ],
          "body": "Open Add, explicitly select Basic and `control-openhands browser choose 'testid=llm-provider-input' Azure`. `control-openhands browser count 'testid=base-url-input'` should be `1` for Azure's required endpoint. Known failure, reproduced 2026-10-12 ([#16726](https://github.com/OpenHands/OpenHands/issues/16726)): Basic count `0`, after `browser click 'testid=sdk-section-advanced-toggle'` count `1`, and after `browser click 'testid=sdk-section-basic-toggle'` count `0` again. Screenshot Basic and Back without saving. No Azure account is needed for this form omission; its reported authenticated conversation error is outside this observation.",
          "raw": "**Azure Base URL on Basic (`F10.model-picker`).** Open Add, explicitly select Basic and `control-openhands browser choose 'testid=llm-provider-input' Azure`. `control-openhands browser count 'testid=base-url-input'` should be `1` for Azure's required endpoint. Known failure, reproduced 2026-10-12 ([#16726](https://github.com/OpenHands/OpenHands/issues/16726)): Basic count `0`, after `browser click 'testid=sdk-section-advanced-toggle'` count `1`, and after `browser click 'testid=sdk-section-basic-toggle'` count `0` again. Screenshot Basic and Back without saving. No Azure account is needed for this form omission; its reported authenticated conversation error is outside this observation.",
          "source_line": 108,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([#16726](https://github.com/OpenHands/OpenHands/issues/16726)): Basic count `0`, after `browser click 'testid=sdk-section-advanced-toggle'` count `1`, and after `browser click 'testid=sdk-section-basic-toggle'` count `0` again. Screenshot Basic and Back without saving. No Azure account is needed for this form omission; its reported authenticated conversation error is outside this observation."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F10--slow-invalid-preflight",
          "title": "Slow invalid preflight",
          "ids": [
            "F10.create-validation"
          ],
          "body": "Arrange the disposable dummy-key `QA_LOOPBACK` profile from the embedded-editor bullet (all model traffic to `http://127.0.0.1:1/v1`). Open Add, explicitly select All and fill `testid=sdk-settings-llm.num_retries` with `5`; select Advanced, fill `llm-custom-model-input` with `openai/gpt-4o`, `base-url-input` with the refused loopback URL, the key via `--value-file <dummy-key file>` and `profile-name-input` with `QA_SLOW`. Clear `browser network --filter QA_SLOW --clear`, click Save with `--observe 'testid=save-profile-btn' --observe-ms 1000`, then inspect toasts and network after the 30-second client deadline. Expected: refuse the unreachable endpoint and save nothing. Known failure, reproduced twice 2026-10-12 ([#15778](https://github.com/OpenHands/OpenHands/issues/15778), slow branch of [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)): `Validating...` lasts until the browser aborts; 30 seconds after the validation POST, a profile POST succeeds with 201 and the profile appears in the list. The frontend deliberately returns no verdict for an abort, then saves. Repeating under `QA_SLOW_2` behaves the same; capture the list and `browser network --filter QA_SLOW_2 --bodies`. With retries `1`, the same refused endpoint instead failed in under a second, showed `Connection error` and left `GET /api/profiles/QA_INVALID` at 404 on both saves. Delete the two saved QA_SLOW profiles via the real API as fixture cleanup. This does not verify [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)'s separate exhausted-budget 429 branch, which needs an approved real route returning that provider verdict.",
          "raw": "**Slow invalid preflight (`F10.create-validation`).** Arrange the disposable dummy-key `QA_LOOPBACK` profile from the embedded-editor bullet (all model traffic to `http://127.0.0.1:1/v1`). Open Add, explicitly select All and fill `testid=sdk-settings-llm.num_retries` with `5`; select Advanced, fill `llm-custom-model-input` with `openai/gpt-4o`, `base-url-input` with the refused loopback URL, the key via `--value-file <dummy-key file>` and `profile-name-input` with `QA_SLOW`. Clear `browser network --filter QA_SLOW --clear`, click Save with `--observe 'testid=save-profile-btn' --observe-ms 1000`, then inspect toasts and network after the 30-second client deadline. Expected: refuse the unreachable endpoint and save nothing. Known failure, reproduced twice 2026-10-12 ([#15778](https://github.com/OpenHands/OpenHands/issues/15778), slow branch of [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)): `Validating...` lasts until the browser aborts; 30 seconds after the validation POST, a profile POST succeeds with 201 and the profile appears in the list. The frontend deliberately returns no verdict for an abort, then saves. Repeating under `QA_SLOW_2` behaves the same; capture the list and `browser network --filter QA_SLOW_2 --bodies`. With retries `1`, the same refused endpoint instead failed in under a second, showed `Connection error` and left `GET /api/profiles/QA_INVALID` at 404 on both saves. Delete the two saved QA_SLOW profiles via the real API as fixture cleanup. This does not verify [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)'s separate exhausted-budget 429 branch, which needs an approved real route returning that provider verdict.",
          "source_line": 110,
          "expected": [
            "a Save whose validation call fails toasts the provider error and keeps the editor open with nothing saved."
          ],
          "notes": [
            "Known issue [#17785](https://github.com/OpenHands/OpenHands/issues/17785): Saving DeepSeek-v4-flash on Windows Desktop can fail with missing cache_creation_tokens instead of saving. The reported path needs a Windows Desktop 1.24.0 environment with its SDK dependency resolution and an approved DeepSeek key; this macOS source run cannot establish that result. (`F10.create-validation`).",
            "Known issue [OpenHands/software-agent-sdk#5862](https://github.com/OpenHands/software-agent-sdk/issues/5862): DeepSeek invalid-key401 responses can be classified as bad-request errors. Blocked by this pass's loopback-only provider routing: a real invalid-key validation request to DeepSeek must be authorized to capture its actual401 shape. No usable key or successful inference is needed, but a loopback connection error does not prove this classification. (`F10.create-validation`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#15778](https://github.com/OpenHands/OpenHands/issues/15778), slow branch of [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)): `Validating...` lasts until the browser aborts; 30 seconds after the validation POST, a profile POST succeeds with 201 and the profile appears in the list. The frontend deliberately returns no verdict for an abort, then saves. Repeating under `QA_SLOW_2` behaves the same; capture the list and `browser network --filter QA_SLOW_2 --bodies`. With retries `1`, the same refused endpoint instead failed in under a second, showed `Connection error` and left `GET /api/profiles/QA_INVALID` at 404 on both saves. Delete the two saved QA_SLOW profiles via the real API as fixture cleanup. This does not verify [OpenHands/software-agent-sdk#5100](https://github.com/OpenHands/software-agent-sdk/issues/5100)'s separate exhausted-budget 429 branch, which needs an approved real route returning that provider verdict."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F10--deleted-title-profile-after-failed-cleanup",
          "title": "Deleted title profile after failed cleanup",
          "ids": [
            "F10.delete"
          ],
          "body": "Arrange a non-default dummy loopback profile `QA_Titles3`, select it on Application's Title generation model dropdown and Save there (this also hydrates the frontend settings cache). Navigate back via `sidebar-settings-/settings/llm`, open the exact profile row menu and Delete. On macOS arrange a selective real write failure by recording `settings.json`'s original file flags, setting only `$OH_VERIFY_RUN/private/settings.json` to `stat.UF_IMMUTABLE` with `os.chflags`, and restoring its flags in a `finally` block immediately after the delete observation. Child `profiles/` stays writable and settings stay readable; ordinary chmod is insufficient because SDK Save repairs directory permissions. Clear the network log, confirm Delete, read toasts, the exact row count and `api GET /api/profiles/QA_Titles3`. Expected: remove the row despite the cleanup error. Known failure, reproduced twice 2026-10-12 ([#17520](https://github.com/OpenHands/OpenHands/issues/17520)): DELETE returns 200, the following title-cleanup PATCH returns 500 on three retries, the error surfaces, but the row remains (`1`) while the API returns 404. Repeat with `QA_Titles4`. Restore flags before `doctor`; clear the stale title preference through the real settings API. No provider validation or network interception is needed.",
          "raw": "**Deleted title profile after failed cleanup (`F10.delete`).** Arrange a non-default dummy loopback profile `QA_Titles3`, select it on Application's Title generation model dropdown and Save there (this also hydrates the frontend settings cache). Navigate back via `sidebar-settings-/settings/llm`, open the exact profile row menu and Delete. On macOS arrange a selective real write failure by recording `settings.json`'s original file flags, setting only `$OH_VERIFY_RUN/private/settings.json` to `stat.UF_IMMUTABLE` with `os.chflags`, and restoring its flags in a `finally` block immediately after the delete observation. Child `profiles/` stays writable and settings stay readable; ordinary chmod is insufficient because SDK Save repairs directory permissions. Clear the network log, confirm Delete, read toasts, the exact row count and `api GET /api/profiles/QA_Titles3`. Expected: remove the row despite the cleanup error. Known failure, reproduced twice 2026-10-12 ([#17520](https://github.com/OpenHands/OpenHands/issues/17520)): DELETE returns 200, the following title-cleanup PATCH returns 500 on three retries, the error surfaces, but the row remains (`1`) while the API returns 404. Repeat with `QA_Titles4`. Restore flags before `doctor`; clear the stale title preference through the real settings API. No provider validation or network interception is needed.",
          "source_line": 112,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#17520](https://github.com/OpenHands/OpenHands/issues/17520)): DELETE returns 200, the following title-cleanup PATCH returns 500 on three retries, the error surfaces, but the row remains (`1`) while the API returns 404. Repeat with `QA_Titles4`. Restore flags before `doctor`; clear the stale title preference through the real settings API. No provider validation or network interception is needed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F10--connection-linked-provider-dispatch",
          "title": "Connection-linked provider dispatch",
          "ids": [
            "F10.custom-model",
            "F10.connection-link"
          ],
          "body": "From `/settings/llm` Add > Advanced, save `QA_QWEN` with `llm-custom-model-input` `qwen` linked through `llm-provider-connection-input` to an own OpenAI connection at refused loopback. Activate it and start two real Home chats; inspect `conversation events <id> --kinds ConversationErrorEvent --last 2`. Expected: the chosen OpenAI-compatible connection supplies the transport. Known failure, reproduced twice 2026-10-12 ([#17261](https://github.com/OpenHands/OpenHands/issues/17261)): Save succeeds, but each actual chat fails before HTTP with `LLM Provider NOT provided` for `model=qwen`. The reported Windows installation, unlinked preflight and successful local inference are further branches. With a separate own connection whose provider is `custom_openai`, repeat the UI Save/start for `ollama-local/qwen`: the same failure reproduces twice for the namespace reported in [#17195](https://github.com/OpenHands/OpenHands/issues/17195). Its exact exposed model suffix is absent from the report, so `qwen` is an owned diagnostic suffix; this is not a complete 9Router/Ollama inference check. Restore the neutral active profile and remove only the own profiles/connections.",
          "raw": "**Connection-linked provider dispatch (`F10.custom-model`, `F10.connection-link`).** From `/settings/llm` Add > Advanced, save `QA_QWEN` with `llm-custom-model-input` `qwen` linked through `llm-provider-connection-input` to an own OpenAI connection at refused loopback. Activate it and start two real Home chats; inspect `conversation events <id> --kinds ConversationErrorEvent --last 2`. Expected: the chosen OpenAI-compatible connection supplies the transport. Known failure, reproduced twice 2026-10-12 ([#17261](https://github.com/OpenHands/OpenHands/issues/17261)): Save succeeds, but each actual chat fails before HTTP with `LLM Provider NOT provided` for `model=qwen`. The reported Windows installation, unlinked preflight and successful local inference are further branches. With a separate own connection whose provider is `custom_openai`, repeat the UI Save/start for `ollama-local/qwen`: the same failure reproduces twice for the namespace reported in [#17195](https://github.com/OpenHands/OpenHands/issues/17195). Its exact exposed model suffix is absent from the report, so `qwen` is an owned diagnostic suffix; this is not a complete 9Router/Ollama inference check. Restore the neutral active profile and remove only the own profiles/connections.",
          "source_line": 115,
          "expected": [
            "the Advanced tab takes a free-text model and Base URL; an empty model is refused with `Model is required`.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4613](https://github.com/OpenHands/software-agent-sdk/issues/4613): A namespaced model such as openai/openai/example-model can have its inner provider prefix stripped again in Responses API and reach the provider as example-model. Reproduction needs an approved OpenAI-compatible endpoint serving the namespaced model and its exact stored API-mode settings; a loopback connection refusal cannot prove the outbound identifier. (`F10.custom-model`).",
            "Known issue [OpenHands/software-agent-sdk#4250](https://github.com/OpenHands/software-agent-sdk/issues/4250): A Cloud Workers AI configuration can fail on context-window validation or be routed as OpenAI with an invalid Cloudflare key. Reproduction needs a disposable OpenHands Cloud account, its Workers AI endpoint/key and the exact saved model configuration; the report supplies no complete configuration steps. (`F10.custom-model`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4247](https://github.com/OpenHands/software-agent-sdk/issues/4247): An LM Studio configuration can fail with LLM Provider NOT provided although its models endpoint works. Reproduction needs the reachable LM Studio service and the exact model ID/provider-qualified config, which the report does not supply as text; the reported CLI/Docker SDK 1.16.1 installation is not this source stack. (`F10.custom-model`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#17261](https://github.com/OpenHands/OpenHands/issues/17261)): Save succeeds, but each actual chat fails before HTTP with `LLM Provider NOT provided` for `model=qwen`. The reported Windows installation, unlinked preflight and successful local inference are further branches. With a separate own connection whose provider is `custom_openai`, repeat the UI Save/start for `ollama-local/qwen`: the same failure reproduces twice for the namespace reported in [#17195](https://github.com/OpenHands/OpenHands/issues/17195). Its exact exposed model suffix is absent from the report, so `qwen` is an owned diagnostic suffix; this is not a complete 9Router/Ollama inference check. Restore the neutral active profile and remove only the own profiles/connections."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F10--bedrock-named-profile-import",
          "title": "Bedrock named-profile import",
          "ids": [
            "F10.default-used"
          ],
          "body": "In the run-owned launcher environment set `AWS_EC2_METADATA_DISABLED=true`, then `restart` and `doctor`; verify that flag without printing other environment contents. Arrange an own profile with `model: bedrock/anthropic.claude-3-haiku-20240307-v1:0`, `aws_profile_name: qa_nonexistent`, region `us-east-1`, retries `0`, and both `base_url` and `aws_bedrock_runtime_endpoint` `http://127.0.0.1:1/v1`. A dummy-only API-key presence marker is needed for Canvas's send gate; the SDK filters that non-Bedrock key on this IAM path. Activate and start a real Home chat; inspect the error privately. Expected: the dependency imports and the missing named profile is reported before any AWS/provider request. A connection error cannot prove successful Bedrock inference. Restore the launcher environment and active profile, then remove only the own fixture.",
          "raw": "**Bedrock named-profile import (`F10.default-used`).** In the run-owned launcher environment set `AWS_EC2_METADATA_DISABLED=true`, then `restart` and `doctor`; verify that flag without printing other environment contents. Arrange an own profile with `model: bedrock/anthropic.claude-3-haiku-20240307-v1:0`, `aws_profile_name: qa_nonexistent`, region `us-east-1`, retries `0`, and both `base_url` and `aws_bedrock_runtime_endpoint` `http://127.0.0.1:1/v1`. A dummy-only API-key presence marker is needed for Canvas's send gate; the SDK filters that non-Bedrock key on this IAM path. Activate and start a real Home chat; inspect the error privately. Expected: the dependency imports and the missing named profile is reported before any AWS/provider request. A connection error cannot prove successful Bedrock inference. Restore the launcher environment and active profile, then remove only the own fixture.",
          "source_line": 117,
          "expected": [
            "a conversation started after creating a profile in the UI runs on the Default profile's model."
          ],
          "notes": [
            "Known issue [#17706](https://github.com/OpenHands/OpenHands/issues/17706): A freshly resolved npm-launcher environment can reject openhands/claude-* conversations because LiteLLM sends prompt_cache_key. Reproduction needs an approved OpenHands proxy key with an Anthropic model and the affected fresh transitive dependency resolution; a keyless initialization cannot prove provider acceptance. (`F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4934](https://github.com/OpenHands/software-agent-sdk/issues/4934): Switching to a MiniMax M3 profile can fail because the SDK sends unsupported reasoning_effort. The runtime switch needs approved working credentials for the starting model and MiniMax route; the reported provider rejection cannot be proved with a loopback connection error. (`F10.schema-validation`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#4250](https://github.com/OpenHands/software-agent-sdk/issues/4250): A Cloud Workers AI configuration can fail on context-window validation or be routed as OpenAI with an invalid Cloudflare key. Reproduction needs a disposable OpenHands Cloud account, its Workers AI endpoint/key and the exact saved model configuration; the report supplies no complete configuration steps. (`F10.custom-model`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#5890](https://github.com/OpenHands/software-agent-sdk/issues/5890): Switching the conversation LLM can lose the managed-key refresh-on401 hook. Blocked: needs an approved managed-key refresh source and an allow-listed real model endpoint that returns401 and accepts the refreshed key. Refused loopback cannot establish the refresh/retry outcome; do not supply a fake successful completion. (`F10.edit`, `F10.default-used`).",
            "Known issue [OpenHands/software-agent-sdk#5838](https://github.com/OpenHands/software-agent-sdk/issues/5838): A live LLM switch during a running tool step can be acknowledged before the new LLM is persisted for GET/restart. Blocked: needs a genuine model-issued bounded tool step (for example `sleep 20`) and an approved starting model. Refused-loopback initialization never enters that step. (`F10.default-used`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--unavailable-subscription-upstream-is-handled",
          "title": "Unavailable subscription upstream is handled",
          "ids": [
            "F10.subscription"
          ],
          "body": "Expected: device-start failure reports502/504 rather than a server crash. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5880](https://github.com/OpenHands/software-agent-sdk/issues/5880)): real Connect returned500 with the backend offline. The block uses an owned offline proxy; browser offline alone does not cut the backend request. The cached runtime must already exist: `UV_OFFLINE=1` prevents its dependency resolver from reaching the blocked network. The block changes only this run's environment, keeps model defaults on refused loopback, and restores/restarts it in `EXIT`. The poll branch remains blocked without a genuine pending device token.\n\n  ```sh known-bug F10.subscription offline-start\n  QA_SUB_ENV=\"$OH_VERIFY_RUN/private/qa-known-subscription-env.json\"\n  cp \"$OH_VERIFY_RUN/private/launcher-env.json\" \"$QA_SUB_ENV\"\n  trap 'cp \"$QA_SUB_ENV\" \"$OH_VERIFY_RUN/private/launcher-env.json\"; rm -f \"$QA_SUB_ENV\"; control-openhands restart --timeout 120 >/dev/null; control-openhands browser goto /settings/llm >/dev/null' EXIT\n  python3 - <<'PYENV'\n  from pathlib import Path\n  import os,json\n  p=Path(os.environ['OH_VERIFY_RUN'])/'private/launcher-env.json'\n  d=json.loads(p.read_text())\n  assert d.get('OPENAI_BASE_URL')=='http://127.0.0.1:1/v1'\n  assert d.get('OPENAI_API_BASE')=='http://127.0.0.1:1/v1'\n  d['UV_OFFLINE']='1'\n  for key in ['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy']: d[key]='http://127.0.0.1:1'\n  d['NO_PROXY']=d['no_proxy']='127.0.0.1,localhost,::1'\n  p.write_text(json.dumps(d))\n  PYENV\n  control-openhands restart --timeout 120\n  control-openhands doctor\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/llm\n  control-openhands browser click 'testid=add-llm-profile'\n  control-openhands browser click 'testid=sdk-section-basic-toggle'\n  control-openhands browser choose 'testid=llm-auth-type-input' 'ChatGPT subscription'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=subscription-connect' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --matches '^(502|504)$' --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/llm/subscription/openai/device/start\"' -- browser network --filter '/api/llm/subscription/openai/device/start'  # bug\n  ```",
          "raw": "**Unavailable subscription upstream is handled (`F10.subscription`).** Expected: device-start failure reports502/504 rather than a server crash. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5880](https://github.com/OpenHands/software-agent-sdk/issues/5880)): real Connect returned500 with the backend offline. The block uses an owned offline proxy; browser offline alone does not cut the backend request. The cached runtime must already exist: `UV_OFFLINE=1` prevents its dependency resolver from reaching the blocked network. The block changes only this run's environment, keeps model defaults on refused loopback, and restores/restarts it in `EXIT`. The poll branch remains blocked without a genuine pending device token.\n\n  ```sh known-bug F10.subscription offline-start\n  QA_SUB_ENV=\"$OH_VERIFY_RUN/private/qa-known-subscription-env.json\"\n  cp \"$OH_VERIFY_RUN/private/launcher-env.json\" \"$QA_SUB_ENV\"\n  trap 'cp \"$QA_SUB_ENV\" \"$OH_VERIFY_RUN/private/launcher-env.json\"; rm -f \"$QA_SUB_ENV\"; control-openhands restart --timeout 120 >/dev/null; control-openhands browser goto /settings/llm >/dev/null' EXIT\n  python3 - <<'PYENV'\n  from pathlib import Path\n  import os,json\n  p=Path(os.environ['OH_VERIFY_RUN'])/'private/launcher-env.json'\n  d=json.loads(p.read_text())\n  assert d.get('OPENAI_BASE_URL')=='http://127.0.0.1:1/v1'\n  assert d.get('OPENAI_API_BASE')=='http://127.0.0.1:1/v1'\n  d['UV_OFFLINE']='1'\n  for key in ['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy']: d[key]='http://127.0.0.1:1'\n  d['NO_PROXY']=d['no_proxy']='127.0.0.1,localhost,::1'\n  p.write_text(json.dumps(d))\n  PYENV\n  control-openhands restart --timeout 120\n  control-openhands doctor\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/llm\n  control-openhands browser click 'testid=add-llm-profile'\n  control-openhands browser click 'testid=sdk-section-basic-toggle'\n  control-openhands browser choose 'testid=llm-auth-type-input' 'ChatGPT subscription'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=subscription-connect' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --matches '^(502|504)$' --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/llm/subscription/openai/device/start\"' -- browser network --filter '/api/llm/subscription/openai/device/start'  # bug\n  ```",
          "source_line": 119,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17391](https://github.com/OpenHands/OpenHands/issues/17391): ChatGPT Business subscription login can be refused or use a fallback model with a 404. Completing the entitlement and selected-model checks needs an approved ChatGPT Business workspace login and the reported Docker subscription runtime. (`F10.subscription`)."
          ],
          "annotations": [
            "Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5880](https://github.com/OpenHands/software-agent-sdk/issues/5880)): real Connect returned500 with the backend offline. The block uses an owned offline proxy; browser offline alone does not cut the backend request. The cached runtime must already exist: `UV_OFFLINE=1` prevents its dependency resolver from reaching the blocked network. The block changes only this run's environment, keeps model defaults on refused loopback, and restores/restarts it in `EXIT`. The poll branch remains blocked without a genuine pending device token."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F10--contract-f10-schema-unavailable",
          "title": "Schema unavailable",
          "ids": [
            "F10.schema-unavailable"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "a backend without the settings-schema endpoints shows `SDK settings schema unavailable.` with an upgrade or session-key hint."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F10--contract-f10-cloud-readonly",
          "title": "Cloud readonly",
          "ids": [
            "F10.cloud-readonly"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "Cloud org members without edit rights see the list read-only (no Add buttons, no row menus, no Provider connections section)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F11",
      "title": "Provider connections",
      "file": "F11-provider-connections.md",
      "sha256": "0af9aeea1923e3785c8e70855b8bbb0e5f5d9fd69d77efccc481b677c6a95882",
      "overview": "A provider connection is a named, shared credential (provider, API key,\noptional base URL) that many LLM profiles link to instead of carrying their own\nkey. Under Settings → LLM, below **Available Profiles**, a user lists\nconnections with their linked-model count, adds them, edits them (rename,\nprovider, base URL, key rotation with a blank key meaning \"keep\"), bulk-adds\nlinked profiles from a row's menu, and deletes them; the server refuses to\ndelete a connection that a profile or the active settings still reference.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`), then **LLM** in the settings navigation (`sidebar-settings-/settings/llm`); the section sits below **Available Profiles**.\n- Direct URL `/settings/llm`.\n- Command menu (`Control+k`/`Meta+k`, or `command-menu-trigger`): search `LLM`, choose **LLM profiles**. Searching `provider connection` finds nothing.\n- **Add connection** (`add-provider-connection`) opens the create modal; each row's `...` (`provider-connection-menu-trigger`) opens **Bulk add** / **Edit** / **Delete**.\n- Elsewhere (other families): Model Router's editor has its own **Add connection** (`meta-profile-add-provider-connection`, see F12); the LLM profile editor links a profile to a connection and **Add from provider connections** bulk-adds without a preselected connection (see F10).",
      "given": "Preconditions:\n\n- A fresh `control-openhands launch --new`, doctored, `onboard --skip` done, desktop viewport. Do not run `llm preset deepseek` before this family: the recipes need no LLM profile and no connection (`control-openhands api GET /api/profiles` lists none, `control-openhands api GET /api/llm/provider-connections` is `[]`), so the profile added in Bulk add becomes the active one.\n- A DeepSeek key in a file (`<key file>`). Pass it only with `browser fill ... --value-file <key file>`; never on the command line.\n- `F11.agent-uses-connection` and `F11.rotate` start three tiny conversations on `deepseek/deepseek-flash`.\n- `F11.load-error` is arranged by making the run's own connections store unreadable (a shell arrange step that writes only `$OH_VERIFY_RUN/private/provider-connections/provider_connections.json`, restored afterwards); `service stop agent-server` does not work because it replaces the whole page with `agent-server-onboarding-screen`.\n- Blocked here: `F11.cloud` (needs a Cloud account with and without an organization, as owner and as member).",
      "gotchas": "- Select the Provider by clicking an option (`testid=provider-item-<name>` exists only for verified providers; use `'role=option[name=\"<label>\"]'` for the others), or with `browser choose 'testid=provider-connection-provider-input' <label>`. `browser fill` alone types a filter that is cleared on blur and leaves Save disabled.\n- `browser fill ... --value-file <key file>` needs no positional value and prints only the length; keep keys out of commands and screenshots (the field is a password input).\n- Duplicate display names are accepted (two rows called `QA_conn`), and so is any base URL text; rows then differ only by order (`'testid=provider-connection-row >> nth=1'`). Keep fixture names unique.\n- Edit's Save is enabled even with no changes, and a blank key on Edit is omitted from the request (the stored key is kept); there is no way to clear a key.\n- The delete spinner (`aria-busy`) lasts under 100 ms on a local stack; `--observe` sees only `Delete` then `<absent>`.\n- `role=dialog` matches two nested elements in these modals; count `> 0`, do not expect `1`.\n- The first profile added on a run with no profiles becomes the active one. Activating a linked profile copies the resolved key into the active agent settings, which keep the connection id: that is why the server still counts a reference after every linked profile is gone (see `F11.delete-stale-reference`) ([OpenHands/software-agent-sdk#5498](https://github.com/OpenHands/software-agent-sdk/issues/5498)).\n- Delete a connection's linked profiles before the connection; then also make an unlinked profile active, or the delete is refused.\n- Rotation results are read from the conversation (`conversation events ... --kinds ConversationErrorEvent,MessageEvent` and the banner in the chat), not from the settings page, which never shows the key.\n- Model Router's **Add connection** reuses this modal (F12); its toasts and fields are the same.\n- `F11.load-error` needs a real server failure: the recipe corrupts this run's own `provider_connections.json` and restores it. Always restore it (keep the `.bak`), or every later family on the run sees the error; never touch another run's state.\n- At phone width a long name squeezes the provider column to one letter (`d…`); the full values stay in the API and the name's `title`.\n- Known issue [OpenHands/software-agent-sdk#5497](https://github.com/OpenHands/software-agent-sdk/issues/5497): after a key rotation, new conversations still run agent steps on the old key; only title generation uses the new one (`F11.rotate`).\n- Known issue [OpenHands/software-agent-sdk#5207](https://github.com/OpenHands/software-agent-sdk/issues/5207): OpenRouter linked profiles can lose connection credentials at raw agent/condenser startup or reuse stale credentials after rotation; opaque reasoning can also be lost on persisted turns. End-to-end checks need an approved OpenRouter connection and a model returning reasoning_details, including the linked-profile switch and reload path; loopback failure does not prove them. (`F11.agent-uses-connection`, `F11.rotate`).",
      "features": [
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F11--empty-state",
          "title": "Empty state",
          "ids": [
            "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`).",
          "raw": "**Empty state (`F11.list-empty`).** 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`).",
          "source_line": 55,
          "expected": [
            "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.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--other-entry-points",
          "title": "Other entry points",
          "ids": [
            "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.",
          "raw": "**Other entry points (`F11.list`).** 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.",
          "source_line": 56,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--validation-and-provider-picker",
          "title": "Validation and provider picker",
          "ids": [
            "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`).",
          "raw": "**Validation and provider picker (`F11.create-validation`, `F11.provider-picker`).** 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`).",
          "source_line": 57,
          "expected": [
            "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.",
            "the Provider combobox lists `Verified Models` (OpenHands, Anthropic, OpenAI, …, deepseek, …) and `Other Models` groups and filters as you type."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--create",
          "title": "Create",
          "ids": [
            "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).",
          "raw": "**Create (`F11.create`).** 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).",
          "source_line": 58,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--row",
          "title": "Row",
          "ids": [
            "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).",
          "raw": "**Row (`F11.list`).** 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).",
          "source_line": 59,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--cancel",
          "title": "Cancel",
          "ids": [
            "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`.",
          "raw": "**Cancel (`F11.create-cancel`).** 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`.",
          "source_line": 60,
          "expected": [
            "Cancel, Escape and a backdrop click close the modal without creating anything; reopening starts empty."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--server-error",
          "title": "Server error",
          "ids": [
            "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'`. The modal stays open (`1`) and the toast reads `String should have at most 128 characters` (pass on main@3500d9e with Agent Server 1.53.0; screenshot `--feature F11.create-error --name toast`). Close with `role=dialog >> role=button[name=\"Cancel\"]`.",
          "raw": "**Server error (`F11.create-error`).** 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'`. The modal stays open (`1`) and the toast reads `String should have at most 128 characters` (pass on main@3500d9e with Agent Server 1.53.0; screenshot `--feature F11.create-error --name toast`). Close with `role=dialog >> role=button[name=\"Cancel\"]`.",
          "source_line": 61,
          "expected": [
            "a server rejection (for example a name over 128 characters) keeps the modal open and toasts a readable message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--second-connection",
          "title": "Second connection",
          "ids": [],
          "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`.",
          "raw": "**Second connection.** 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`.",
          "source_line": 62,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F11--row-menu",
          "title": "Row menu",
          "ids": [
            "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`; Known failure (reproduced twice 2026-10-16, [#18204](https://github.com/OpenHands/OpenHands/issues/18204)): it stays `provider-connection-menu-trigger` 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`).",
          "raw": "**Row menu (`F11.row-menu`, `F11.row-menu-focus`).** 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`; Known failure (reproduced twice 2026-10-16, [#18204](https://github.com/OpenHands/OpenHands/issues/18204)): it stays `provider-connection-menu-trigger` 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`).",
          "source_line": 63,
          "expected": [
            "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.",
            "opening the menu moves focus to its first item (**Bulk add**)."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced twice 2026-10-16, [#18204](https://github.com/OpenHands/OpenHands/issues/18204)): it stays `provider-connection-menu-trigger` 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`)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F11--edit",
          "title": "Edit",
          "ids": [
            "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`.",
          "raw": "**Edit (`F11.edit`).** 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`.",
          "source_line": 64,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--bulk-add",
          "title": "Bulk add",
          "ids": [
            "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\"`.",
          "raw": "**Bulk add (`F11.add-models`).** 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\"`.",
          "source_line": 65,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--the-agent-uses-the-connection-s-key",
          "title": "The agent uses the connection's key",
          "ids": [
            "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`.",
          "raw": "**The agent uses the connection's key (`F11.agent-uses-connection`).** 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`.",
          "source_line": 66,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5207](https://github.com/OpenHands/software-agent-sdk/issues/5207): OpenRouter linked profiles can lose connection credentials at raw agent/condenser startup or reuse stale credentials after rotation; opaque reasoning can also be lost on persisted turns. End-to-end checks need an approved OpenRouter connection and a model returning reasoning_details, including the linked-profile switch and reload path; loopback failure does not prove them. (`F11.agent-uses-connection`, `F11.rotate`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F11--rotate-the-key",
          "title": "Rotate the key",
          "ids": [
            "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`.",
          "raw": "**Rotate the key (`F11.rotate`).** 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`.",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5497](https://github.com/OpenHands/software-agent-sdk/issues/5497): after a key rotation, new conversations still run agent steps on the old key; only title generation uses the new one (`F11.rotate`).",
            "Known issue [OpenHands/software-agent-sdk#5207](https://github.com/OpenHands/software-agent-sdk/issues/5207): OpenRouter linked profiles can lose connection credentials at raw agent/condenser startup or reuse stale credentials after rotation; opaque reasoning can also be lost on persisted turns. End-to-end checks need an approved OpenRouter connection and a model returning reasoning_details, including the linked-profile switch and reload path; loopback failure does not prove them. (`F11.agent-uses-connection`, `F11.rotate`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F11--rename-a-linked-connection",
          "title": "Rename a linked connection",
          "ids": [
            "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).",
          "raw": "**Rename a linked connection (`F11.edit`).** 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).",
          "source_line": 68,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--delete-is-refused-while-referenced",
          "title": "Delete is refused while referenced",
          "ids": [
            "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.",
          "raw": "**Delete is refused while referenced (`F11.delete-referenced`).** 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.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--delete",
          "title": "Delete",
          "ids": [
            "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`.",
          "raw": "**Delete (`F11.delete`).** 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`.",
          "source_line": 70,
          "expected": [
            "**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`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--phone-layout",
          "title": "Phone layout",
          "ids": [
            "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`.",
          "raw": "**Phone layout (`F11.phone`).** 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`.",
          "source_line": 71,
          "expected": [
            "at 390 px the rows, the row menu and the modals fit without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--long-name-and-provider-change",
          "title": "Long name and provider change",
          "ids": [
            "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\"`.",
          "raw": "**Long name and provider change (`F11.long-name`, `F11.edit`).** 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\"`.",
          "source_line": 72,
          "expected": [
            "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.",
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--persistence-across-a-backend-restart",
          "title": "Persistence across a backend restart",
          "ids": [
            "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)`).",
          "raw": "**Persistence across a backend restart (`F11.create`).** 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)`).",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--unlisted-provider",
          "title": "Unlisted provider",
          "ids": [
            "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`.",
          "raw": "**Unlisted provider (`F11.edit-unlisted-provider`).** 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`.",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--stale-reference-and-cleanup",
          "title": "Stale reference and cleanup",
          "ids": [
            "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 `[]`.",
          "raw": "**Stale reference and cleanup (`F11.delete-stale-reference`).** 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 `[]`.",
          "source_line": 75,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--load-error",
          "title": "Load error",
          "ids": [
            "F11.load-error"
          ],
          "body": "With the baseline restored (connections `[]`), run the shell arrange step (it writes only this run's state; the file exists only once the run has created a connection, as the bullets above do, so on a run that never created one `cp` fails: create and delete one through **Add connection** first) `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`). The message uses the theme's error colour (`browser eval \"getComputedStyle(document.querySelector('[data-testid=provider-connections-load-error] p')).color\"`: `rgb(231, 106, 94)` on OpenHands-Neutral, `rgb(192, 40, 45)` on Light+, #18330). The profile rows render only because no profile links to a connection at this point: with a linked profile `GET /api/profiles` fails with the same `400` and the list above shows `Failed to load profiles` instead (`F10.list-states`). 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`.",
          "raw": "**Load error (`F11.load-error`).** With the baseline restored (connections `[]`), run the shell arrange step (it writes only this run's state; the file exists only once the run has created a connection, as the bullets above do, so on a run that never created one `cp` fails: create and delete one through **Add connection** first) `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`). The message uses the theme's error colour (`browser eval \"getComputedStyle(document.querySelector('[data-testid=provider-connections-load-error] p')).color\"`: `rgb(231, 106, 94)` on OpenHands-Neutral, `rgb(192, 40, 45)` on Light+, #18330). The profile rows render only because no profile links to a connection at this point: with a linked profile `GET /api/profiles` fails with the same `400` and the list above shows `Failed to load profiles` instead (`F10.list-states`). 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`.",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F11--errors-sweep",
          "title": "Errors sweep",
          "ids": [],
          "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.",
          "raw": "**Errors sweep.** `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.",
          "source_line": 77,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F11--redacted-key-is-rejected",
          "title": "Redacted key is rejected",
          "ids": [
            "F11.create-validation",
            "F11.edit"
          ],
          "body": "Add `QA_SENTINEL`, choose OpenAI, fill the API Key with the literal `**********` and Base URL with `http://127.0.0.1:1/v1`. Use the native keyboard Save (`browser focus 'testid=provider-connection-submit'`, `browser press Space`; the provider listbox can intercept pointer clicks). Read `api GET /api/llm/provider-connections --pick 0.api_key_set`. Expected: reject the placeholder. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5624](https://github.com/OpenHands/software-agent-sdk/issues/5624)): creation succeeds with `api_key_set: false`. Editing that row, typing the same placeholder and keyboard-saving succeeds and changes it to `true`. A second create under `QA_SENTINEL_2` again succeeds with the key unset. The UI already prevents a whitespace-only key; this recipe drives the sentinel branch, not provider authentication. Remove both unreferenced fixture connections afterwards.",
          "raw": "**Redacted key is rejected (`F11.create-validation`, `F11.edit`).** Add `QA_SENTINEL`, choose OpenAI, fill the API Key with the literal `**********` and Base URL with `http://127.0.0.1:1/v1`. Use the native keyboard Save (`browser focus 'testid=provider-connection-submit'`, `browser press Space`; the provider listbox can intercept pointer clicks). Read `api GET /api/llm/provider-connections --pick 0.api_key_set`. Expected: reject the placeholder. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5624](https://github.com/OpenHands/software-agent-sdk/issues/5624)): creation succeeds with `api_key_set: false`. Editing that row, typing the same placeholder and keyboard-saving succeeds and changes it to `true`. A second create under `QA_SENTINEL_2` again succeeds with the key unset. The UI already prevents a whitespace-only key; this recipe drives the sentinel branch, not provider authentication. Remove both unreferenced fixture connections afterwards.",
          "source_line": 79,
          "expected": [
            "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.",
            "**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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5624](https://github.com/OpenHands/software-agent-sdk/issues/5624)): creation succeeds with `api_key_set: false`. Editing that row, typing the same placeholder and keyboard-saving succeeds and changes it to `true`. A second create under `QA_SENTINEL_2` again succeeds with the key unset. The UI already prevents a whitespace-only key; this recipe drives the sentinel branch, not provider authentication. Remove both unreferenced fixture connections afterwards."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F11--store-lock-keeps-unrelated-routes-responsive",
          "title": "Store lock keeps unrelated routes responsive",
          "ids": [
            "F11.list"
          ],
          "body": "Hold only the own `private/provider-connections/.provider-connections.lock` from an external run-owned process using `fcntl.flock(..., LOCK_EX)` for 34 seconds. Start an authenticated provider-connections list request, then request the own Agent Server's `/alive` two seconds later; reload the actual LLM page and capture its stalled loading state. Expected: `/alive` answers within two seconds while the store request waits. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5632](https://github.com/OpenHands/software-agent-sdk/issues/5632)): `/alive` returned 200 after 25.416 and 28.060 seconds. A direct request with a 45-second client deadline received the store's 503 after 30.052 seconds; the CLI's shorter request instead timed out. Release/close the own lock and run `doctor`; do not hold or alter any other run's stores.",
          "raw": "**Store lock keeps unrelated routes responsive (`F11.list`).** Hold only the own `private/provider-connections/.provider-connections.lock` from an external run-owned process using `fcntl.flock(..., LOCK_EX)` for 34 seconds. Start an authenticated provider-connections list request, then request the own Agent Server's `/alive` two seconds later; reload the actual LLM page and capture its stalled loading state. Expected: `/alive` answers within two seconds while the store request waits. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5632](https://github.com/OpenHands/software-agent-sdk/issues/5632)): `/alive` returned 200 after 25.416 and 28.060 seconds. A direct request with a 45-second client deadline received the store's 503 after 30.052 seconds; the CLI's shorter request instead timed out. Release/close the own lock and run `doctor`; do not hold or alter any other run's stores.",
          "source_line": 81,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5632](https://github.com/OpenHands/software-agent-sdk/issues/5632)): `/alive` returned 200 after 25.416 and 28.060 seconds. A direct request with a 45-second client deadline received the store's 503 after 30.052 seconds; the CLI's shorter request instead timed out. Release/close the own lock and run `doctor`; do not hold or alter any other run's stores."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F11--connection-url-edit-reaches-next-chat",
          "title": "Connection URL edit reaches next chat",
          "ids": [
            "F11.edit",
            "F11.agent-uses-connection"
          ],
          "body": "Arrange a disposable OpenAI connection `QA_URL_PATCH` with a dummy key and `http://127.0.0.1:1/v1`, plus an activated linked `QA_URL_LINKED` profile without inline key/Base URL. From `/settings/llm`, open the exact connection row menu > `provider-connection-edit`, change `provider-connection-base-url-input` to `http://127.0.0.1:1/v2` and submit; `api GET /api/llm/provider-connections` confirms the stored URL. Start a fresh Home chat with a bounded no-tools prompt, then read only `agent.llm.base_url` in its run-owned `base_state.json`. Repeat the real edit to `/v3` and start another chat. Expected: each new runtime uses the newly saved URL. Known failure, reproduced twice 2026-10-12 ([#17803](https://github.com/OpenHands/OpenHands/issues/17803)): the connection persisted `/v2` and `/v3`, but settings and both new runtimes retained the activated `/v1` snapshot. All model endpoints refuse on loopback; no provider success is needed to observe resolution. Restore the neutral active profile, then delete only the own linked profile and connection.",
          "raw": "**Connection URL edit reaches next chat (`F11.edit`, `F11.agent-uses-connection`).** Arrange a disposable OpenAI connection `QA_URL_PATCH` with a dummy key and `http://127.0.0.1:1/v1`, plus an activated linked `QA_URL_LINKED` profile without inline key/Base URL. From `/settings/llm`, open the exact connection row menu > `provider-connection-edit`, change `provider-connection-base-url-input` to `http://127.0.0.1:1/v2` and submit; `api GET /api/llm/provider-connections` confirms the stored URL. Start a fresh Home chat with a bounded no-tools prompt, then read only `agent.llm.base_url` in its run-owned `base_state.json`. Repeat the real edit to `/v3` and start another chat. Expected: each new runtime uses the newly saved URL. Known failure, reproduced twice 2026-10-12 ([#17803](https://github.com/OpenHands/OpenHands/issues/17803)): the connection persisted `/v2` and `/v3`, but settings and both new runtimes retained the activated `/v1` snapshot. All model endpoints refuse on loopback; no provider success is needed to observe resolution. Restore the neutral active profile, then delete only the own linked profile and connection.",
          "source_line": 83,
          "expected": [
            "**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.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5207](https://github.com/OpenHands/software-agent-sdk/issues/5207): OpenRouter linked profiles can lose connection credentials at raw agent/condenser startup or reuse stale credentials after rotation; opaque reasoning can also be lost on persisted turns. End-to-end checks need an approved OpenRouter connection and a model returning reasoning_details, including the linked-profile switch and reload path; loopback failure does not prove them. (`F11.agent-uses-connection`, `F11.rotate`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#17803](https://github.com/OpenHands/OpenHands/issues/17803)): the connection persisted `/v2` and `/v3`, but settings and both new runtimes retained the activated `/v1` snapshot. All model endpoints refuse on loopback; no provider success is needed to observe resolution. Restore the neutral active profile, then delete only the own linked profile and connection."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F11--contract-f11-cloud",
          "title": "Cloud",
          "ids": [
            "F11.cloud"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "on Cloud the section appears only with an organization bound and for members allowed to manage profiles; local users always see it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F12",
      "title": "Model router (meta-LLM profiles)",
      "file": "F12-model-router.md",
      "sha256": "3723533607aa5ff7aee9974dbbd55bbf0f5e517eb3ef9f9e68c32545bc954c99",
      "overview": "A model router (the UI also calls it a \"meta-profile\") names a classifier LLM\nprofile, a prompt template and a model table. While a router is active, every\nnew conversation gets a `route_task_to_model` tool: the classifier picks one\nsaved LLM profile from the table and the agent switches to it (the chat shows\n`Switched to profile <name>`). With **Run on first message** on, the agent is\ntold to route the first user message before doing anything else. Users create\nrouters from the Router Pro / Router Flash templates or from scratch, can have\nthe missing router LLM profiles created from a provider connection, and\nactivate, edit and delete routers under Settings → Model Router.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`), then **Model Router** in the settings navigation (`sidebar-settings-/settings/meta-llm`, between LLM and Condenser). Used by `F12.page`.\n- Direct URL `/settings/meta-llm`. Used by most recipes (`browser goto`).\n- Command menu (`Control+k` or `Meta+k`, or `command-menu-trigger` in the sidebar): search \"Model Router\" (or \"router\", \"meta\"), choose **Model Router** (\"Meta-profiles that route each task to the right LLM profile.\"). Used by `F12.page`.\n- Per-router actions: the row `...` button (`meta-profile-menu-trigger-<name>`), then Edit / Set active / Delete.\n- The effect of a router is seen in a conversation started from the home composer (`F12.route-first-message`; see F05/F06 for the composer and activity rendering).",
      "given": "Preconditions:\n\n- `F12.empty-states` needs a fresh `launch --new` with `onboard --skip` done and **before** `llm preset deepseek` (no LLM profiles, no routers).\n- Everything after it needs `control-openhands llm preset deepseek --api-key-file <key file>` (profiles `deepseek-flash` and `deepseek-pro`), no meta-profile named `QA_*` (`control-openhands api GET /api/meta-profiles`), no provider connection (`control-openhands api GET /api/llm/provider-connections` is `[]`) and no LLM profile named `deepseek-chat` (`control-openhands api GET /api/profiles`).\n- `F12.add-connection` and `F12.route-first-message` use the DeepSeek key file; never print it.\n- Blocked here: `F12.unsupported` (needs an agent server older than software-agent-sdk #3744, for example `launch --new --sdk-ref <older release>`), `F12.cloud` (needs a Cloud account, with and without an organization) and `F12.load-error` (needs `/api/meta-profiles` to fail while the rest of the agent server answers; `service stop agent-server` swaps the whole app for the Manage backends card instead).\n- Desktop viewport unless a bullet says otherwise.",
      "gotchas": "- Settings nav test ids embed the route: quote `'testid=sidebar-settings-/settings/meta-llm'` as written.\n- The empty-state hint about LLM profiles only exists before any profile is saved: drive `F12.empty-states` first on a fresh run.\n- The first router you create is activated and turns **Run on first message** on by itself; later routers do neither. Any recipe that creates a router on an empty list changes both.\n- `Run on first message` is an `sr-only` checkbox (keyboard reachable): click the label text inside `testid=meta-profile-run-at-conversation-start` and read `.checked`/`.disabled` with `browser eval`. The stored preference (`misc_settings.app_preferences.run_router_at_conversation_start`) is independent of what the disabled switch shows; with no active router it may still be `true`. Turn it off before deleting the last router, or a later \"first router\" will not re-enable it because it is already on.\n- Router templates name 11 models (`gpt-5.4` … `claude-sonnet-4-5`) that will not exist in a sandbox. With a provider connection present, a template preselects it, and saving creates all 11 as LLM profiles linked to that connection unless you pick **Don't create profiles** first (`F12.create-more` does). If a run created them, remove them with `for p in gpt-5.4 gpt-5.5 minimax-m3 claude-opus-4-6 claude-opus-4-7 claude-opus-4-8 claude-fable-5 kimi-k2.5 kimi-k2.6 deepseek-v3.2-reasoner claude-sonnet-4-5; do control-openhands api DELETE /api/profiles/$p --write; done` (they live in the run's private state and only add rows to Settings → LLM).\n- Profile matching is case-insensitive and exact by name: table rows are `- <profile name>: description`; existing `deepseek-flash`/`deepseek-pro` are not re-created, and the endpoint is derived as `<provider lowercased>/<name>`.\n- The classifier and the model table refer to LLM *profile names*, not model ids. A router whose classifier profile does not exist (for example a template's `minimax-m3` without a connection) saves fine and only fails when a conversation tries to route.\n- The agent decides whether to call `route_task_to_model`: a prompt that conflicts with routing (`Reply with only the word pong.`) made deepseek-flash skip the call even with the instruction present. Use a task-like prompt for the positive check and assert the system prompt via `events/search` for the instruction itself.\n- `conversation events` does not print the system prompt; read it with `control-openhands api GET \"/api/conversations/<id>/events/search?limit=1\" --max-bytes 200000` and grep the output.\n- `service stop agent-server` does not show this page's error state: the whole app switches to the Manage backends card (F02). Run `control-openhands restart` afterwards.\n- The selection in the classifier and connection pickers is a combobox: open it with a click on the input, then click `role=option[name=\"...\"]`; `browser fill` on the classifier sets a free-text value (allowed) instead of picking a profile.\n- At 390 px the row truncates the name (`QA_route…`) and summary; read names with `browser text`, not the screenshot.\n- The Model table placeholder displays separate lines (`F12.template-custom`).\n- Known issue [OpenHands/software-agent-sdk#5878](https://github.com/OpenHands/software-agent-sdk/issues/5878): Router files can be written under a literal `~` directory when Agent Server was configured with leading-tilde `OH_PERSISTENCE_DIR`. Blocked on the normal absolute-path launcher fixture: needs a disposable server with private HOME/cwd and literal `~/...` or relative persistence configuration, then a router save and filesystem readback. Never point it at user state. (`F12.create-first`, `F12.list`).",
      "features": [
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F12--open-from-navigation",
          "title": "Open from navigation",
          "ids": [
            "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`.",
          "raw": "**Open from navigation (`F12.page`).** 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`.",
          "source_line": 61,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--command-menu",
          "title": "Command menu",
          "ids": [
            "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.",
          "raw": "**Command menu (`F12.page`).** 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.",
          "source_line": 62,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--empty-states",
          "title": "Empty states",
          "ids": [
            "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.",
          "raw": "**Empty states (`F12.empty-states`).** 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.",
          "source_line": 63,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--template-chooser",
          "title": "Template chooser",
          "ids": [
            "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`.",
          "raw": "**Template chooser (`F12.template-chooser`).** 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`.",
          "source_line": 64,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--custom-template",
          "title": "Custom template",
          "ids": [
            "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.",
          "raw": "**Custom template (`F12.template-custom`).** 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.",
          "source_line": 65,
          "expected": [
            "**Custom** opens a blank editor; the placeholders show an example prompt and a two-line example model table."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--validation",
          "title": "Validation",
          "ids": [
            "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.",
          "raw": "**Validation (`F12.editor-validation`).** 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.",
          "source_line": 66,
          "expected": [
            "Save stays disabled until the name is valid, a classifier is set and the prompt contains `{{ instance_text }}` (spaces inside the braces optional)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--add-a-connection",
          "title": "Add a connection",
          "ids": [
            "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)`.",
          "raw": "**Add a connection (`F12.add-connection`).** 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)`.",
          "source_line": 67,
          "expected": [
            "**Add connection** in the create editor opens `Add provider connection`; saving it selects the new connection in the picker."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--create-the-first-router",
          "title": "Create the first router",
          "ids": [
            "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.",
          "raw": "**Create the first router (`F12.create-first`, `F12.router-profiles`).** 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.",
          "source_line": 68,
          "expected": [
            "saving the first router lists it, makes it Active and turns **Run on first message** on.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5878](https://github.com/OpenHands/software-agent-sdk/issues/5878): Router files can be written under a literal `~` directory when Agent Server was configured with leading-tilde `OH_PERSISTENCE_DIR`. Blocked on the normal absolute-path launcher fixture: needs a disposable server with private HOME/cwd and literal `~/...` or relative persistence configuration, then a router save and filesystem readback. Never point it at user state. (`F12.create-first`, `F12.list`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F12--duplicate-name",
          "title": "Duplicate name",
          "ids": [
            "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`.",
          "raw": "**Duplicate name (`F12.duplicate-name`).** 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`.",
          "source_line": 69,
          "expected": [
            "a new router cannot take an existing router's name (`A meta-profile with this name already exists...`, Save disabled)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--don-t-create-profiles-in-a-template",
          "title": "\"Don't create profiles\" in a template",
          "ids": [
            "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.",
          "raw": "**\"Don't create profiles\" in a template (`F12.router-profiles`).** 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.",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--create-another-router-without-new-profiles",
          "title": "Create another router without new profiles",
          "ids": [
            "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).",
          "raw": "**Create another router without new profiles (`F12.create-more`, `F12.router-profiles`).** 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).",
          "source_line": 71,
          "expected": [
            "saving another router lists it and leaves the Active badge where it was.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--row-menu-and-activate",
          "title": "Row menu and activate",
          "ids": [
            "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.",
          "raw": "**Row menu and activate (`F12.row-menu`, `F12.activate`).** 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.",
          "source_line": 72,
          "expected": [
            "the row menu offers Edit, Set active (disabled on the active row) and Delete, and closes on Escape.",
            "**Set active** toasts `Meta-profile \"<name>\" activated` and moves the badge."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--edit",
          "title": "Edit",
          "ids": [
            "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.",
          "raw": "**Edit (`F12.edit`).** 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.",
          "source_line": 73,
          "expected": [
            "**Edit** opens `Edit meta-profile` with the name locked and no connection picker; changes persist."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--cancel-the-connection-dialog",
          "title": "Cancel the connection dialog",
          "ids": [
            "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).",
          "raw": "**Cancel the connection dialog (`F12.add-connection-cancel`).** 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).",
          "source_line": 74,
          "expected": [
            "**Cancel** in `Add provider connection` closes it without creating a connection and keeps the editor's values and picker choice."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--leave-the-editor",
          "title": "Leave the editor",
          "ids": [
            "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`.",
          "raw": "**Leave the editor (`F12.editor-cancel`).** 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`.",
          "source_line": 75,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--run-on-first-message",
          "title": "Run on first message",
          "ids": [
            "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`.",
          "raw": "**Run on first message (`F12.run-first-message-toggle`).** 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`.",
          "source_line": 76,
          "expected": [
            "the switch saves immediately (no Save button, no toast) and survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--routing-in-a-conversation",
          "title": "Routing in a conversation",
          "ids": [
            "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.",
          "raw": "**Routing in a conversation (`F12.route-first-message`).** 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.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--delete",
          "title": "Delete",
          "ids": [
            "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`.",
          "raw": "**Delete (`F12.delete`).** 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`.",
          "source_line": 78,
          "expected": [
            "**Delete** asks `Are you sure you want to delete \"<name>\"? This cannot be undone.`; Cancel keeps the row, Delete removes it."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--delete-the-active-router",
          "title": "Delete the active router",
          "ids": [
            "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.",
          "raw": "**Delete the active router (`F12.delete-active`).** 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.",
          "source_line": 79,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--light-theme",
          "title": "Light theme",
          "ids": [
            "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.",
          "raw": "**Light theme (`F12.light-theme`).** 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.",
          "source_line": 80,
          "expected": [
            "in a light colour theme the list heading and router names are readable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F12--phone-f12-phone-also-re-proves-f12-create-first-on-a-router-less-page",
          "title": "Phone (`F12.phone`, also re-proves `F12.create-first` on a router-less page)",
          "ids": [
            "F12.phone",
            "F12.create-first"
          ],
          "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`.",
          "raw": "**Phone (`F12.phone`, also re-proves `F12.create-first` on a router-less page).** 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`.",
          "source_line": 81,
          "expected": [
            "the list, template modal, editor and row menu fit a 390 px viewport without horizontal overflow.",
            "saving the first router lists it, makes it Active and turns **Run on first message** on."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5878](https://github.com/OpenHands/software-agent-sdk/issues/5878): Router files can be written under a literal `~` directory when Agent Server was configured with leading-tilde `OH_PERSISTENCE_DIR`. Blocked on the normal absolute-path launcher fixture: needs a disposable server with private HOME/cwd and literal `~/...` or relative persistence configuration, then a router save and filesystem readback. Never point it at user state. (`F12.create-first`, `F12.list`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F12--restart-persistence",
          "title": "Restart persistence",
          "ids": [
            "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`.",
          "raw": "**Restart persistence (`F12.list`).** 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`.",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5878](https://github.com/OpenHands/software-agent-sdk/issues/5878): Router files can be written under a literal `~` directory when Agent Server was configured with leading-tilde `OH_PERSISTENCE_DIR`. Blocked on the normal absolute-path launcher fixture: needs a disposable server with private HOME/cwd and literal `~/...` or relative persistence configuration, then a router save and filesystem readback. Never point it at user state. (`F12.create-first`, `F12.list`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F12--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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).",
          "raw": "**Cleanup.** 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).",
          "source_line": 83,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F12--router-activation-is-honest-on-acp",
          "title": "Router activation is honest on ACP",
          "ids": [
            "F12.activate"
          ],
          "body": "ACP cannot attach the router; activation must report that refusal rather than plain success. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5879](https://github.com/OpenHands/software-agent-sdk/issues/5879)): actual global ACP settings produced an activated toast while both stored pointers stayed null. An active ACP profile pointer alone does not create this condition. The block restores the original owned settings bytes; its assertion rejects the misleading success toast.\n\n  ```sh known-bug F12.activate acp-settings\n  QA_ACP_SETTINGS=\"$OH_VERIFY_RUN/private/qa-known-acp-settings.json\"\n  cp \"$OH_VERIFY_RUN/private/settings.json\" \"$QA_ACP_SETTINGS\"\n  trap 'cp \"$QA_ACP_SETTINGS\" \"$OH_VERIFY_RUN/private/settings.json\"; rm -f \"$QA_ACP_SETTINGS\"; control-openhands api DELETE /api/meta-profiles/QA_KNOWN_ACP_ROUTER --write >/dev/null; control-openhands browser reload >/dev/null' EXIT\n  control-openhands api POST /api/meta-profiles/QA_KNOWN_ACP_ROUTER --data '{\"classifier_model\":\"QA_KNOWN_CLASSIFIER\",\"prompt_template\":\"Pick {{ instance_text }}\"}' --write\n  control-openhands api PATCH /api/settings --data '{\"agent_settings_diff\":{\"agent_kind\":\"acp\"}}' --write --pick agent_settings.agent_kind\n  control-openhands assert --field value --equals acp -- api GET /api/settings --pick agent_settings.agent_kind\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/meta-llm\n  control-openhands browser wait 'testid=meta-profile-row-QA_KNOWN_ACP_ROUTER'\n  control-openhands browser click 'testid=meta-profile-menu-trigger-QA_KNOWN_ACP_ROUTER'\n  control-openhands browser click 'testid=meta-profile-set-active' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field value --equals false --bug-shows true -- browser eval 'document.body.innerText.includes(\"Meta-profile \\\"QA_KNOWN_ACP_ROUTER\\\" activated\")'  # bug\n  ```",
          "raw": "**Router activation is honest on ACP (`F12.activate`).** ACP cannot attach the router; activation must report that refusal rather than plain success. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5879](https://github.com/OpenHands/software-agent-sdk/issues/5879)): actual global ACP settings produced an activated toast while both stored pointers stayed null. An active ACP profile pointer alone does not create this condition. The block restores the original owned settings bytes; its assertion rejects the misleading success toast.\n\n  ```sh known-bug F12.activate acp-settings\n  QA_ACP_SETTINGS=\"$OH_VERIFY_RUN/private/qa-known-acp-settings.json\"\n  cp \"$OH_VERIFY_RUN/private/settings.json\" \"$QA_ACP_SETTINGS\"\n  trap 'cp \"$QA_ACP_SETTINGS\" \"$OH_VERIFY_RUN/private/settings.json\"; rm -f \"$QA_ACP_SETTINGS\"; control-openhands api DELETE /api/meta-profiles/QA_KNOWN_ACP_ROUTER --write >/dev/null; control-openhands browser reload >/dev/null' EXIT\n  control-openhands api POST /api/meta-profiles/QA_KNOWN_ACP_ROUTER --data '{\"classifier_model\":\"QA_KNOWN_CLASSIFIER\",\"prompt_template\":\"Pick {{ instance_text }}\"}' --write\n  control-openhands api PATCH /api/settings --data '{\"agent_settings_diff\":{\"agent_kind\":\"acp\"}}' --write --pick agent_settings.agent_kind\n  control-openhands assert --field value --equals acp -- api GET /api/settings --pick agent_settings.agent_kind\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/meta-llm\n  control-openhands browser wait 'testid=meta-profile-row-QA_KNOWN_ACP_ROUTER'\n  control-openhands browser click 'testid=meta-profile-menu-trigger-QA_KNOWN_ACP_ROUTER'\n  control-openhands browser click 'testid=meta-profile-set-active' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field value --equals false --bug-shows true -- browser eval 'document.body.innerText.includes(\"Meta-profile \\\"QA_KNOWN_ACP_ROUTER\\\" activated\")'  # bug\n  ```",
          "source_line": 85,
          "expected": [
            "**Set active** toasts `Meta-profile \"<name>\" activated` and moves the badge."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5879](https://github.com/OpenHands/software-agent-sdk/issues/5879)): actual global ACP settings produced an activated toast while both stored pointers stayed null. An active ACP profile pointer alone does not create this condition. The block restores the original owned settings bytes; its assertion rejects the misleading success toast."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F12--unreadable-settings-refuse-router-activation",
          "title": "Unreadable settings refuse router activation",
          "ids": [
            "F12.activate"
          ],
          "body": "Expected: a handled409 without a filesystem path and no store overwrite. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5922](https://github.com/OpenHands/software-agent-sdk/issues/5922)): the real Set active path showed the generic500 and the settings path. The corrupt bytes survived. Back up only this run's settings, and restore them in `EXIT` before the runner's health check.\n\n  ```sh known-bug F12.activate corrupt-settings\n  QA_ROUTER_SETTINGS=\"$OH_VERIFY_RUN/private/qa-known-router-settings.json\"\n  cp \"$OH_VERIFY_RUN/private/settings.json\" \"$QA_ROUTER_SETTINGS\"\n  trap 'cp \"$QA_ROUTER_SETTINGS\" \"$OH_VERIFY_RUN/private/settings.json\"; rm -f \"$QA_ROUTER_SETTINGS\"; control-openhands api DELETE /api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER --write >/dev/null; control-openhands browser reload >/dev/null' EXIT\n  control-openhands api POST /api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER --data '{\"classifier_model\":\"QA_KNOWN_CLASSIFIER\",\"prompt_template\":\"Pick {{ instance_text }}\"}' --write\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/meta-llm\n  control-openhands browser wait 'testid=meta-profile-row-QA_KNOWN_CORRUPT_ROUTER'\n  printf '{\"agent_settings\": ' > \"$OH_VERIFY_RUN/private/settings.json\"\n  control-openhands browser click 'testid=meta-profile-menu-trigger-QA_KNOWN_CORRUPT_ROUTER'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=meta-profile-set-active' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --equals 409 --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER/activate\"' -- browser network --filter '/api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER/activate'  # bug\n  ```",
          "raw": "**Unreadable settings refuse router activation (`F12.activate`).** Expected: a handled409 without a filesystem path and no store overwrite. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5922](https://github.com/OpenHands/software-agent-sdk/issues/5922)): the real Set active path showed the generic500 and the settings path. The corrupt bytes survived. Back up only this run's settings, and restore them in `EXIT` before the runner's health check.\n\n  ```sh known-bug F12.activate corrupt-settings\n  QA_ROUTER_SETTINGS=\"$OH_VERIFY_RUN/private/qa-known-router-settings.json\"\n  cp \"$OH_VERIFY_RUN/private/settings.json\" \"$QA_ROUTER_SETTINGS\"\n  trap 'cp \"$QA_ROUTER_SETTINGS\" \"$OH_VERIFY_RUN/private/settings.json\"; rm -f \"$QA_ROUTER_SETTINGS\"; control-openhands api DELETE /api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER --write >/dev/null; control-openhands browser reload >/dev/null' EXIT\n  control-openhands api POST /api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER --data '{\"classifier_model\":\"QA_KNOWN_CLASSIFIER\",\"prompt_template\":\"Pick {{ instance_text }}\"}' --write\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/meta-llm\n  control-openhands browser wait 'testid=meta-profile-row-QA_KNOWN_CORRUPT_ROUTER'\n  printf '{\"agent_settings\": ' > \"$OH_VERIFY_RUN/private/settings.json\"\n  control-openhands browser click 'testid=meta-profile-menu-trigger-QA_KNOWN_CORRUPT_ROUTER'\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=meta-profile-set-active' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --equals 409 --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER/activate\"' -- browser network --filter '/api/meta-profiles/QA_KNOWN_CORRUPT_ROUTER/activate'  # bug\n  ```",
          "source_line": 102,
          "expected": [
            "**Set active** toasts `Meta-profile \"<name>\" activated` and moves the badge."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5922](https://github.com/OpenHands/software-agent-sdk/issues/5922)): the real Set active path showed the generic500 and the settings path. The corrupt bytes survived. Back up only this run's settings, and restore them in `EXIT` before the runner's health check."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F12--contract-f12-load-error",
          "title": "Load error",
          "ids": [
            "F12.load-error"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "when only the meta-profile request fails, a red generic error shows under the header."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F12--contract-f12-unsupported",
          "title": "Unsupported",
          "ids": [
            "F12.unsupported"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F12--contract-f12-cloud",
          "title": "Cloud",
          "ids": [
            "F12.cloud"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "a Cloud backend without an organization shows `Meta-profiles are only available for local backends.`; an organization-bound Cloud backend shows the full page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F13",
      "title": "Agent profiles",
      "file": "F13-agent-profiles.md",
      "sha256": "5ebbed53b56eed0fe9ffca9693e8a139ecaeee0ba807f7210cf40c3446395ffd",
      "overview": "Settings → Agent is a library of named agent profiles. Each profile is either an\nOpenHands agent (an LLM profile, a system prompt, a tool set, parallel\ntool calls, and MCP-server and secret scopes) or an ACP external agent (a preset\nsuch as Claude Code, Codex, Gemini CLI, Pi or OpenCode, its launch command, a\nmodel the agent itself reports, and provider credentials). One profile is\nactive; new conversations started from Home launch from it.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`): on desktop `/settings` redirects to `/settings/agents`. In the settings navigation, **Agent** (`sidebar-settings-/settings/agents`).\n- Direct URL `/settings/agents`; the retired `/settings/agent` redirects there.\n- Command menu (`Control+k`/`Meta+k` or `command-menu-trigger`): **Agent settings**.\n- Getting-started checklist **Customize your agent** and the composer **+** → **Switch agent profile** → **Manage agent profiles** (both mapped in F09/F05).\n- On Home, the composer **+** → **Switch agent profile** list activates a profile like **Set as active** (F05.agent-profile-switch).\n- All per-profile actions are in the row's `...` menu (`agent-profile-menu-trigger`). The editor has no route: it replaces the list in place.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) on a **fresh** run. `F13.editor-no-llm` must run before `control-openhands llm preset deepseek`; everything from `F13.stale-llm-ref` on needs the preset (`deepseek-flash` active, `deepseek-pro`).\n- A fresh run has one profile, `default`, active, whose LLM profile is `default` (which does not exist). `control-openhands api GET /api/agent-profiles` shows it.\n- `F13.mcp-scope` needs two configured MCP servers. Arrange them with `control-openhands api POST /api/settings/mcp/qa_mcp_a --data '{\"transport\":\"stdio\",\"command\":\"qa-no-such-command\",\"enabled\":false}' --write` and the same for `qa_mcp_b`.\n- `F13.acp-conversation` needs the provider's agent CLI and an approved account; `F13.cloud-read-only` needs a Cloud organization member login. Both are blocked here.\n- Dropdowns are autocompletes. Pick an option with `control-openhands browser choose 'testid=<testid>' '<label>'` (proven on `agent-type-selector`, `agent-preset-selector`, `agent-model-selector` (`Custom`), `agent-profile-llm-selector`, `agent-settings-mcp-mode` and `agent-settings-secrets-mode`). The manual opener, `control-openhands browser click 'div:has(> div > input[data-testid=<testid>]) >> role=button[name=\"Show suggestions\"]'` then `browser click 'role=option[name=\"<label>\"]'`, works for most of them but is flaky right after an editor opens (see Gotchas); clicking the input itself can open an empty, filtered list.",
      "gotchas": "- The editor is not a route: `browser url` stays `/settings/agents` and `browser reload` drops unsaved edits back to the list. The settings page header (`settings-page-subtitle`) is hidden while it is open.\n- Autocomplete dropdowns: clicking an input that already shows a value can open an empty listbox (the text filters it), and an option click then times out. The toggle-button opener does not always open the list either: on the Edit editor's `agent-profile-llm-selector` (bottom of the form) the option click timed out 5 of 5 times right after opening the editor, and the model list's `Custom` option once detached mid-click after a preset change. `browser choose` avoids the input-filtering problem, but an option may still remain unstable immediately after opening an editor. Reload and reopen before retrying; the suffix-identity check can use the already-selected active LLM without changing that dropdown. A listbox also closes if you spend a command (snapshot, eval) in between, so click the option right after opening.\n- A taken name marks the field invalid with an alert reading `An agent profile with this name already exists. …`.\n- `has-text=default` also matches the active row's `Default` badge (Playwright's text match ignores case), so with several profiles `'testid=agent-profile-row >> has-text=default'` hits two rows; select the seeded profile with `'testid=agent-profile-row >> nth=-1'` (it sorts last) or by its badge.\n- Model lookups (`F13.acp-model-discovery`) spawn the provider CLI on the agent-server machine through npx with the profile's allowed secrets; they send no prompt. A failed lookup is retried each time the preset is picked again; a successful one is cached for about five minutes per preset and secret set.\n- Switch rows are `sr-only` checkboxes inside labels (keyboard reachable): click the label text (`'testid=agent-settings-tool-list >> text=<tool>'`, `'testid=agent-settings-mcp-list >> text=<name>'`) and read `.checked` with `browser eval`. A bare `label` selector segment is parsed as `label=`, not CSS.\n- The Default badge, the **Set as active** item and the `Switched to profile` toast all refer to the same active pointer; the wording differs from the LLM page (**Set as default**).\n- The OpenHands row's grey text is the LLM profile name, not the kind. A fresh run's `default` shows `default` there although no LLM profile of that name exists until it is edited (`F13.stale-llm-ref`).\n- Local seeded OpenHands `default` launches through global agent settings so the Skills selection applies; its per-profile fields do not apply on that path. Named profiles use their saved settings. Home launches follow the LLM pill. On a backend advertising per-launch LLM override, a named profile pinned to another LLM keeps its tool, concurrency and MCP settings; read `launched_agent_profile` and `agent` on the conversation (`F13.llm-pill-precedence`). Only older backends without that capability use the legacy settings fallback when the profile and pill differ.\n- The ACP auth probe runs the provider CLI on the agent-server machine. If that machine has a Claude CLI login, Claude Code can show \"already signed in\". Use only an explicitly approved account for authenticated ACP checks.\n- ACP credentials are global secrets, not profile fields: cancelling the editor saves nothing, but saving any ACP profile with a typed credential writes it to Settings → Secrets (F14) for every conversation.\n- **Choose secrets** on an ACP profile also selects provider secret names that are not saved (`CODEX_AUTH_JSON`, `OPENAI_BASE_URL`); they show as switches without descriptions.\n- The empty state (`agent-profiles-empty`) and the load-error state (`agent-profiles-load-error`) are not reachable on a local backend: deleting the last profile re-seeds `default`, and with the agent-server stopped the app swaps to **Manage backends** after a reload, or keeps the cached list without one.\n- The refused-launch toast text comes from the Agent Server and changes between versions (`MCP server(s) not configured: <name>` on 1.53.0): assert that one toast names the server and the prompt stays, not the exact wording.\n- Opening a row menu focuses its first enabled item. Arrows skip disabled **Set as active**; Escape closes the menu and returns focus to its trigger, while Tab closes it and continues normal tab order.\n- The Add agent editor has its own agent description. Parallel tool calls label and description are translated.\n- Known issue [#16905](https://github.com/OpenHands/OpenHands/issues/16905): An enabled built-in OpenHands skill can be absent from a new Claude Code ACP session. The enabled/disabled instruction-delivery check needs an approved authenticated Claude Code account, its ACP subprocess and the configured Code Review automation; no provider session is authorized here. (`F13.acp-conversation`).\n- Known issue [#16296](https://github.com/OpenHands/OpenHands/issues/16296): Claude Code credential fields remain visible below the already-connected banner, leading users to think keys are required. The connected-state check needs an approved existing Claude CLI login on the agent-server host; a dummy credential does not prove that signed-in state. (`F13.acp-credentials`).\n- Known issue [#17051](https://github.com/OpenHands/OpenHands/issues/17051): Codex ACP can work in New Chat while Issue-to-PR automation fails with missing OpenAI API credentials. Reproduction needs an approved Codex subscription login plus a configured Issue-to-PR automation and its repository credentials; the report does not identify the saved automation/profile settings. (`F13.acp-conversation`).\n- Known issue [OpenHands/software-agent-sdk#5167](https://github.com/OpenHands/software-agent-sdk/issues/5167): On a headless Docker host an already usable Codex ChatGPT login can still time out during ACP authenticate. Reproduction needs an approved ChatGPT login in the reported headless/read-only-auth-file deployment and its adapter; a desktop or bogus login cannot establish this conditional trigger. (`F13.acp-conversation`).\n- Known issue [OpenHands/software-agent-sdk#5995](https://github.com/OpenHands/software-agent-sdk/issues/5995): Claude Code ACP first turns with `claude-opus-5-5` can fail because the pinned adapter bundles an older Claude Code. Blocked: needs an approved Claude login/API key entitled to Opus5.5 and a real first turn; current SDK1.55 still pins the reported adapter0.63.0. No authenticated turn was attempted. (`F13.acp-conversation`, `F13.acp-model-discovery`).\n- Known issue [OpenHands/software-agent-sdk#5899](https://github.com/OpenHands/software-agent-sdk/issues/5899): The browser tool can be reported unusable when genuine Chromium exists only under `PLAYWRIGHT_BROWSERS_PATH`. Blocked on this host: it has standard user Chrome. The distinguishing fixture needs a host/container with no standard/default-cache/PATH browser, plus a genuine custom-cache Chromium; never remove the user browser to create it. (`F13.openhands-options`; operational browser path F27).",
      "features": [
        {
          "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 name another profile already has also marks the field invalid, shows a red taken-name message about agent profiles under it (the rule stays grey) and disables Save (Edit accepts the profile's own name)."
        },
        {
          "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 (the active LLM profile by default), a **System prompt** choice (`OpenHands default`, `Default + your instructions` or `Custom persona`, each saved on the profile), **Parallel tool calls** (default 1, minimum 1) and **Tools**: `Standard tools` (default) lists the server's standard set read-only, `Choose tools` turns the server's tool catalog into switches; 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, a backend advertising per-launch LLM override uses the pill's LLM while preserving the named profile's other settings and identity."
        },
        {
          "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, Credentials and Model; each preset prefills its command and its model choice (`Agent default`, or Gemini CLI's preferred `gemini-2.5-pro`), **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-local-presets",
          "description": "on a local backend the Preset list also offers **Pi** and **OpenCode** (with their own commands and credentials); a profile saved on them stores `acp_server` `pi`/`opencode` with `acp_command` `null`. On Cloud both are hidden (blocked here)."
        },
        {
          "id": "F13.acp-model-discovery",
          "description": "for a built-in preset with its default command the Model list comes from the agent: `Asking <agent> which models it offers…` while the agent-server asks it, then its models with `Agent default (<model>)` first; an agent that wants a login says `Sign in or add credentials above to see the models <agent> offers.`, one that cannot start says `<agent>'s models appear here after your first conversation with it. …`; a picked model is saved as `acp_model` and reopens selected."
        },
        {
          "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`; Pi `PI_AUTH_JSON` (multi-line), `ANTHROPIC_API_KEY`; OpenCode `OPENCODE_AUTH_CONTENT` (multi-line), `OPENCODE_API_KEY`; **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 selects `default` immediately; deleting the last profile makes the server re-seed and activate `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."
        }
      ],
      "scenarios": [
        {
          "name": "F13--list",
          "title": "List",
          "ids": [
            "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`.",
          "raw": "**List (`F13.list`).** 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`.",
          "source_line": 56,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--no-llm-profile",
          "title": "No LLM profile",
          "ids": [
            "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.`. 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'`.",
          "raw": "**No LLM profile (`F13.editor-no-llm`).** 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.`. 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'`.",
          "source_line": 57,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--name-rules",
          "title": "Name rules",
          "ids": [
            "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) → `true`/`false`; `'bad name!'` → `true`/`false`; `QA_ok` → `false`/`true`. The rule under the field is always shown (grey) and turns the theme danger colour (`text-danger`, #18199) 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)`. For the duplicate, `control-openhands browser count 'role=alert >> text=already exists'` is `1`: the alert is the field's description, in the danger colour (`rgb(231, 106, 94)` on OpenHands-Neutral, `rgb(209, 52, 56)` on Light+), while the rule text stays grey. It reads `An agent profile with this name already exists. Choose a different name, or edit the existing one.` Take `control-openhands browser screenshot --feature F13.name-validation --name duplicate` with `default` filled. In Edit the same message appears for another profile's name and not for the profile's own name (`F13.edit`).",
          "raw": "**Name rules (`F13.name-validation`).** 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) → `true`/`false`; `'bad name!'` → `true`/`false`; `QA_ok` → `false`/`true`. The rule under the field is always shown (grey) and turns the theme danger colour (`text-danger`, #18199) 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)`. For the duplicate, `control-openhands browser count 'role=alert >> text=already exists'` is `1`: the alert is the field's description, in the danger colour (`rgb(231, 106, 94)` on OpenHands-Neutral, `rgb(209, 52, 56)` on Light+), while the rule text stays grey. It reads `An agent profile with this name already exists. Choose a different name, or edit the existing one.` Take `control-openhands browser screenshot --feature F13.name-validation --name duplicate` with `default` filled. In Edit the same message appears for another profile's name and not for the profile's own name (`F13.edit`).",
          "source_line": 58,
          "expected": [
            "an empty or malformed name marks the field invalid (red rule) and disables Save; a name another profile already has also marks the field invalid, shows a red taken-name message about agent profiles under it (the rule stays grey) and disables Save (Edit accepts the profile's own name)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--acp-form",
          "title": "ACP form",
          "ids": [
            "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 `Agent default`; `agent-profile-no-llm` is gone (ACP needs no LLM profile). The preset list (`control-openhands browser click 'div:has(> div > input[data-testid=agent-preset-selector]) >> role=button[name=\"Show suggestions\"]'`, then `control-openhands browser snapshot 'role=listbox'`) is `Claude Code`, `Codex`, `Gemini CLI`, `Pi`, `OpenCode`, `Custom` (`browser press Escape`). 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 `Agent default` / `gemini-2.5-pro` (the models come from the agent since #18131: `F13.acp-model-discovery`). 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).",
          "raw": "**ACP form (`F13.acp-form`).** 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 `Agent default`; `agent-profile-no-llm` is gone (ACP needs no LLM profile). The preset list (`control-openhands browser click 'div:has(> div > input[data-testid=agent-preset-selector]) >> role=button[name=\"Show suggestions\"]'`, then `control-openhands browser snapshot 'role=listbox'`) is `Claude Code`, `Codex`, `Gemini CLI`, `Pi`, `OpenCode`, `Custom` (`browser press Escape`). 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 `Agent default` / `gemini-2.5-pro` (the models come from the agent since #18131: `F13.acp-model-discovery`). 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).",
          "source_line": 59,
          "expected": [
            "choosing **ACP (external subprocess)** shows Preset, Command, Credentials and Model; each preset prefills its command and its model choice (`Agent default`, or Gemini CLI's preferred `gemini-2.5-pro`), **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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--create-an-acp-profile",
          "title": "Create an ACP profile",
          "ids": [
            "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`.",
          "raw": "**Create an ACP profile (`F13.create`).** 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`.",
          "source_line": 60,
          "expected": [
            "**Add agent profile** opens the editor; Save toasts `Profile \"<name>\" created` and the row is listed after a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--pi-and-opencode-presets",
          "title": "Pi and OpenCode presets",
          "ids": [
            "F13.acp-local-presets"
          ],
          "body": "For each of `OpenCode` and `Pi`: run `control-openhands browser click 'testid=add-agent-profile'`, `control-openhands browser choose 'testid=agent-type-selector' 'ACP (external subprocess)'` and `control-openhands browser choose 'testid=agent-preset-selector' '<preset>'`. `browser value 'testid=agent-command-input'` is `npx -y --prefer-offline opencode-ai@1.18.23 acp` / `npx -y --prefer-offline --package=pi-acp@0.0.33 --package=@earendil-works/pi-coding-agent@0.83.0 pi-acp` (the versions follow `@openhands/typescript-client`). Fill `testid=agent-profile-name-input` with `QA_opencode` / `QA_pi`, run `control-openhands browser wait 'testid=agent-model-discovering' --state detached --timeout 120000`, click `testid=save-agent-profile-btn` and `control-openhands browser wait-text 'Profile \"<name>\" created' --timeout 10000`. `control-openhands api GET /api/agent-profiles/<name>` has `acp_server` `opencode` / `pi`, `acp_command` `null` and `acp_model` `null`, and after `browser reload` both rows read `<name>` / `ACP (external subprocess)`. Cloud hides both presets (blocked here: needs a Cloud backend).",
          "raw": "**Pi and OpenCode presets (`F13.acp-local-presets`).** For each of `OpenCode` and `Pi`: run `control-openhands browser click 'testid=add-agent-profile'`, `control-openhands browser choose 'testid=agent-type-selector' 'ACP (external subprocess)'` and `control-openhands browser choose 'testid=agent-preset-selector' '<preset>'`. `browser value 'testid=agent-command-input'` is `npx -y --prefer-offline opencode-ai@1.18.23 acp` / `npx -y --prefer-offline --package=pi-acp@0.0.33 --package=@earendil-works/pi-coding-agent@0.83.0 pi-acp` (the versions follow `@openhands/typescript-client`). Fill `testid=agent-profile-name-input` with `QA_opencode` / `QA_pi`, run `control-openhands browser wait 'testid=agent-model-discovering' --state detached --timeout 120000`, click `testid=save-agent-profile-btn` and `control-openhands browser wait-text 'Profile \"<name>\" created' --timeout 10000`. `control-openhands api GET /api/agent-profiles/<name>` has `acp_server` `opencode` / `pi`, `acp_command` `null` and `acp_model` `null`, and after `browser reload` both rows read `<name>` / `ACP (external subprocess)`. Cloud hides both presets (blocked here: needs a Cloud backend).",
          "source_line": 61,
          "expected": [
            "on a local backend the Preset list also offers **Pi** and **OpenCode** (with their own commands and credentials); a profile saved on them stores `acp_server` `pi`/`opencode` with `acp_command` `null`. On Cloud both are hidden (blocked here)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F13--models-from-the-agent",
          "title": "Models from the agent",
          "ids": [
            "F13.acp-model-discovery"
          ],
          "body": "In a new ACP editor, each built-in preset pick first shows (unless a successful lookup for that preset and secret set is cached, about 5 minutes: OpenCode then skips it) `control-openhands browser text 'testid=agent-model-discovering'` `Asking <agent> which models it offers…` (the agent-server runs the agent CLI through npx for `POST /api/acp/models`; the first lookup can take a minute while npx downloads it). Wait with `control-openhands browser wait 'testid=agent-model-discovering' --state detached --timeout 120000`, then read `control-openhands browser testids --hidden --filter agent-model`. In this sandbox (no Claude, Codex, Gemini or Pi login for the agent-server): Claude Code fails to start and shows `agent-model-list-after-first-conversation` (`Claude Code's models appear here after your first conversation with it. You can also switch models from the chat.`); Codex, Gemini CLI and Pi need a login and show `agent-model-discovery-needs-auth` (`Sign in or add credentials above to see the models <agent> offers.`), with the model left at `Agent default` (`gemini-2.5-pro` for Gemini); `<run>/private/stack.log` logs `ACP model discovery failed: server=<key> code=ACPInitError|ACPAuthRequired`. OpenCode needs no key: `browser value 'testid=agent-model-selector'` is `Agent default (OpenCode/Big Pickle)` and the list (open it with `browser click 'div:has(> div > input[data-testid=agent-model-selector]) >> role=button[name=\"Show suggestions\"]'`, then `browser snapshot 'role=listbox'`) has that entry, a non-empty list of the models OpenCode reports live (12 on 2026-10-13, such as `OpenCode/Big Pickle` and `OpenCode/Exo Free`) and `Custom` (screenshot `--feature F13.acp-model-discovery --name opencode-models`). Edit `QA_opencode`: Save is disabled until `control-openhands browser choose 'testid=agent-model-selector' 'OpenCode/Exo Free'`; Save (`Profile \"QA_opencode\" updated`) stores `acp_model` `opencode/exo-free`, and reopening shows `OpenCode/Exo Free` with Save disabled. With a saved `OPENAI_API_KEY` (even a dummy, `F13.acp-credentials`) Codex lists its models and shows `Agent default (GPT-6-Astra)`. Lookups never add a conversation to the list (`api GET \"/api/conversations/search?limit=20\"` stays empty).",
          "raw": "**Models from the agent (`F13.acp-model-discovery`).** In a new ACP editor, each built-in preset pick first shows (unless a successful lookup for that preset and secret set is cached, about 5 minutes: OpenCode then skips it) `control-openhands browser text 'testid=agent-model-discovering'` `Asking <agent> which models it offers…` (the agent-server runs the agent CLI through npx for `POST /api/acp/models`; the first lookup can take a minute while npx downloads it). Wait with `control-openhands browser wait 'testid=agent-model-discovering' --state detached --timeout 120000`, then read `control-openhands browser testids --hidden --filter agent-model`. In this sandbox (no Claude, Codex, Gemini or Pi login for the agent-server): Claude Code fails to start and shows `agent-model-list-after-first-conversation` (`Claude Code's models appear here after your first conversation with it. You can also switch models from the chat.`); Codex, Gemini CLI and Pi need a login and show `agent-model-discovery-needs-auth` (`Sign in or add credentials above to see the models <agent> offers.`), with the model left at `Agent default` (`gemini-2.5-pro` for Gemini); `<run>/private/stack.log` logs `ACP model discovery failed: server=<key> code=ACPInitError|ACPAuthRequired`. OpenCode needs no key: `browser value 'testid=agent-model-selector'` is `Agent default (OpenCode/Big Pickle)` and the list (open it with `browser click 'div:has(> div > input[data-testid=agent-model-selector]) >> role=button[name=\"Show suggestions\"]'`, then `browser snapshot 'role=listbox'`) has that entry, a non-empty list of the models OpenCode reports live (12 on 2026-10-13, such as `OpenCode/Big Pickle` and `OpenCode/Exo Free`) and `Custom` (screenshot `--feature F13.acp-model-discovery --name opencode-models`). Edit `QA_opencode`: Save is disabled until `control-openhands browser choose 'testid=agent-model-selector' 'OpenCode/Exo Free'`; Save (`Profile \"QA_opencode\" updated`) stores `acp_model` `opencode/exo-free`, and reopening shows `OpenCode/Exo Free` with Save disabled. With a saved `OPENAI_API_KEY` (even a dummy, `F13.acp-credentials`) Codex lists its models and shows `Agent default (GPT-6-Astra)`. Lookups never add a conversation to the list (`api GET \"/api/conversations/search?limit=20\"` stays empty).",
          "source_line": 62,
          "expected": [
            "for a built-in preset with its default command the Model list comes from the agent: `Asking <agent> which models it offers…` while the agent-server asks it, then its models with `Agent default (<model>)` first; an agent that wants a login says `Sign in or add credentials above to see the models <agent> offers.`, one that cannot start says `<agent>'s models appear here after your first conversation with it. …`; a picked model is saved as `acp_model` and reopens selected."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5995](https://github.com/OpenHands/software-agent-sdk/issues/5995): Claude Code ACP first turns with `claude-opus-5-5` can fail because the pinned adapter bundles an older Claude Code. Blocked: needs an approved Claude login/API key entitled to Opus5.5 and a real first turn; current SDK1.55 still pins the reported adapter0.63.0. No authenticated turn was attempted. (`F13.acp-conversation`, `F13.acp-model-discovery`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F13--edit-and-rename",
          "title": "Edit and rename",
          "ids": [
            "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.",
          "raw": "**Edit and rename (`F13.edit`).** 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.",
          "source_line": 63,
          "expected": [
            "Edit reopens the stored values with Save disabled until something changes; renaming keeps the profile id; Back and Cancel leave without saving."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--credentials",
          "title": "Credentials",
          "ids": [
            "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: `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'`). Known failure in Light+ (reproduced 2026-10-13 on TARGET and BASE, #18004): the auth banners use raw `text-green-200` (`settings-acp-auth-detected`) and `text-blue-200` (`settings-acp-auth-configured`) on a pale tint over white (`acp-auth-status-banner.tsx:63/93`), measuring 1.10:1 and 1.26:1; expected: readable banner text in every theme (screenshot `--feature F13.acp-credentials --name detected-banner-lightplus`). 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` `null` (the agent's own default since #18131). 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.",
          "raw": "**Credentials (`F13.acp-credentials`).** 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: `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'`). Known failure in Light+ (reproduced 2026-10-13 on TARGET and BASE, #18004): the auth banners use raw `text-green-200` (`settings-acp-auth-detected`) and `text-blue-200` (`settings-acp-auth-configured`) on a pale tint over white (`acp-auth-status-banner.tsx:63/93`), measuring 1.10:1 and 1.26:1; expected: readable banner text in every theme (screenshot `--feature F13.acp-credentials --name detected-banner-lightplus`). 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` `null` (the agent's own default since #18131). 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.",
          "source_line": 64,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16296](https://github.com/OpenHands/OpenHands/issues/16296): Claude Code credential fields remain visible below the already-connected banner, leading users to think keys are required. The connected-state check needs an approved existing Claude CLI login on the agent-server host; a dummy credential does not prove that signed-in state. (`F13.acp-credentials`)."
          ],
          "annotations": [
            "Known failure in Light+ (reproduced 2026-10-13 on TARGET and BASE, #18004): the auth banners use raw `text-green-200` (`settings-acp-auth-detected`) and `text-blue-200` (`settings-acp-auth-configured`) on a pale tint over white (`acp-auth-status-banner.tsx:63/93`), measuring 1.10:1 and 1.26:1; expected: readable banner text in every theme (screenshot `--feature F13.acp-credentials --name detected-banner-lightplus`). 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` `null` (the agent's own default since #18131). 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."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F13--secrets-scope",
          "title": "Secrets scope",
          "ids": [
            "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`.",
          "raw": "**Secrets scope (`F13.secrets-scope`).** 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`.",
          "source_line": 65,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--stale-llm-reference",
          "title": "Stale LLM reference",
          "ids": [
            "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.",
          "raw": "**Stale LLM reference (`F13.stale-llm-ref`).** 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.",
          "source_line": 66,
          "expected": [
            "editing a profile whose LLM profile no longer exists preselects the active LLM profile and enables Save, so saving heals the reference."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--openhands-options",
          "title": "OpenHands options",
          "ids": [
            "F13.openhands-options",
            "F13.create"
          ],
          "body": "Click `testid=add-agent-profile`, fill the name `QA_oh`. Defaults: `browser value` on `testid=agent-profile-llm-selector` is the active LLM profile (`deepseek-flash (deepseek/deepseek-flash)`), on `testid=agent-settings-system-prompt-mode` `OpenHands default`, on `testid=agent-settings-tools-mode` `Standard tools` and on `testid=sdk-settings-tool_concurrency_limit` `1`; `control-openhands browser text 'testid=agent-settings-tool-list'` lists `file_editor`, `task_tracker`, `terminal` and `switch_llm` with their descriptions (the list follows the Agent Server; it also includes `browser_tool_set` when usable). Run `control-openhands browser choose 'testid=agent-settings-tools-mode' 'Choose tools'`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=agent-settings-tool-list] input')].map(i=>i.dataset.testid.replace('agent-settings-tool-','')+'='+i.checked)\"` is the selected standard tools, including `file_editor=true`, `task_tracker=true`, `terminal=true` and `switch_llm=true`; `task_tool_set=false`, `glob=false` and `grep=false`. Additional rows follow the current server catalog. Click `'testid=agent-settings-tool-list >> text=switch_llm'` and `'testid=agent-settings-tool-list >> text=task_tool_set'` (the eval flips both). 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`, `tools` retaining the selected standard tools plus `task_tool_set` (including `task_tracker`), `enable_classify_and_switch_llm_tool` `false` and `tool_concurrency_limit` `2`. Reopening shows `Choose tools` with the same switches, `2` and `deepseek-pro`, with Save disabled. (There are no **Enable sub-agents** or **Let the agent switch LLM profiles** switches: `browser count 'text=Enable sub-agents'` is `0`.)\n  System prompt: reopen `QA_oh`, `control-openhands browser choose 'testid=agent-settings-system-prompt-mode' 'Default + your instructions'` and fill `testid=agent-settings-system-prompt` with `Use terse QA answers.`. Save, `browser reload` and reopen: `api GET /api/agent-profiles/QA_oh` has `system_message_suffix` `Use terse QA answers.` and `persona` `null`, and the editor keeps the mode and text with Save disabled. Then choose `Custom persona`, fill `You are the disposable QA persona.`, Save, reload and reopen: `persona` is that text, `system_message_suffix` is `null`, and the editor keeps the mode and text. Choose `OpenHands default` and Save (both fields `null`) before later checks that use this profile.",
          "raw": "**OpenHands options (`F13.openhands-options`, `F13.create`).** Click `testid=add-agent-profile`, fill the name `QA_oh`. Defaults: `browser value` on `testid=agent-profile-llm-selector` is the active LLM profile (`deepseek-flash (deepseek/deepseek-flash)`), on `testid=agent-settings-system-prompt-mode` `OpenHands default`, on `testid=agent-settings-tools-mode` `Standard tools` and on `testid=sdk-settings-tool_concurrency_limit` `1`; `control-openhands browser text 'testid=agent-settings-tool-list'` lists `file_editor`, `task_tracker`, `terminal` and `switch_llm` with their descriptions (the list follows the Agent Server; it also includes `browser_tool_set` when usable). Run `control-openhands browser choose 'testid=agent-settings-tools-mode' 'Choose tools'`; `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=agent-settings-tool-list] input')].map(i=>i.dataset.testid.replace('agent-settings-tool-','')+'='+i.checked)\"` is the selected standard tools, including `file_editor=true`, `task_tracker=true`, `terminal=true` and `switch_llm=true`; `task_tool_set=false`, `glob=false` and `grep=false`. Additional rows follow the current server catalog. Click `'testid=agent-settings-tool-list >> text=switch_llm'` and `'testid=agent-settings-tool-list >> text=task_tool_set'` (the eval flips both). 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`, `tools` retaining the selected standard tools plus `task_tool_set` (including `task_tracker`), `enable_classify_and_switch_llm_tool` `false` and `tool_concurrency_limit` `2`. Reopening shows `Choose tools` with the same switches, `2` and `deepseek-pro`, with Save disabled. (There are no **Enable sub-agents** or **Let the agent switch LLM profiles** switches: `browser count 'text=Enable sub-agents'` is `0`.)\n  System prompt: reopen `QA_oh`, `control-openhands browser choose 'testid=agent-settings-system-prompt-mode' 'Default + your instructions'` and fill `testid=agent-settings-system-prompt` with `Use terse QA answers.`. Save, `browser reload` and reopen: `api GET /api/agent-profiles/QA_oh` has `system_message_suffix` `Use terse QA answers.` and `persona` `null`, and the editor keeps the mode and text with Save disabled. Then choose `Custom persona`, fill `You are the disposable QA persona.`, Save, reload and reopen: `persona` is that text, `system_message_suffix` is `null`, and the editor keeps the mode and text. Choose `OpenHands default` and Save (both fields `null`) before later checks that use this profile.",
          "source_line": 67,
          "expected": [
            "OpenHands profiles offer an LLM profile dropdown (the active LLM profile by default), a **System prompt** choice (`OpenHands default`, `Default + your instructions` or `Custom persona`, each saved on the profile), **Parallel tool calls** (default 1, minimum 1) and **Tools**: `Standard tools` (default) lists the server's standard set read-only, `Choose tools` turns the server's tool catalog into switches; values persist and reopen.",
            "**Add agent profile** opens the editor; Save toasts `Profile \"<name>\" created` and the row is listed after a reload."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5899](https://github.com/OpenHands/software-agent-sdk/issues/5899): The browser tool can be reported unusable when genuine Chromium exists only under `PLAYWRIGHT_BROWSERS_PATH`. Blocked on this host: it has standard user Chrome. The distinguishing fixture needs a host/container with no standard/default-cache/PATH browser, plus a genuine custom-cache Chromium; never remove the user browser to create it. (`F13.openhands-options`; operational browser path F27)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F13--set-as-active",
          "title": "Set as active",
          "ids": [
            "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`).",
          "raw": "**Set as active (`F13.set-active`).** 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`).",
          "source_line": 69,
          "expected": [
            "**Set as active** toasts `Switched to profile \"<name>\"`, moves the Default badge and is disabled on the active row."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--pill-wins-over-a-pinned-llm",
          "title": "Pill wins over a pinned LLM",
          "ids": [
            "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. Do not run tools.\" --wait --timeout 240`. The output's `model` is `deepseek/deepseek-flash`. On the current backend, `control-openhands api GET /api/conversations/<id>` (id from that output) has `launched_agent_profile.agent_profile_id` equal to QA_oh's id, `launched_agent_profile.llm_profile_ref` `deepseek-flash`, and `agent.tool_concurrency_limit` `2`: the per-launch LLM override keeps the named profile's other settings. These metadata checks can be made before a successful reply; a refused-loopback fixture proves the launch configuration only.",
          "raw": "**Pill wins over a pinned LLM (`F13.llm-pill-precedence`).** 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. Do not run tools.\" --wait --timeout 240`. The output's `model` is `deepseek/deepseek-flash`. On the current backend, `control-openhands api GET /api/conversations/<id>` (id from that output) has `launched_agent_profile.agent_profile_id` equal to QA_oh's id, `launched_agent_profile.llm_profile_ref` `deepseek-flash`, and `agent.tool_concurrency_limit` `2`: the per-launch LLM override keeps the named profile's other settings. These metadata checks can be made before a successful reply; a refused-loopback fixture proves the launch configuration only.",
          "source_line": 70,
          "expected": [
            "when the active profile pins a different LLM profile than the Home LLM pill, a backend advertising per-launch LLM override uses the pill's LLM while preserving the named profile's other settings and identity."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--active-profile-drives-the-launch",
          "title": "Active profile drives the launch",
          "ids": [
            "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 `agent.tool_concurrency_limit` `2`; `control-openhands conversation events <id> --kinds MessageEvent` contains `qa-profile-ok`.",
          "raw": "**Active profile drives the launch (`F13.active-drives-conversation`).** 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 `agent.tool_concurrency_limit` `2`; `control-openhands conversation events <id> --kinds MessageEvent` contains `qa-profile-ok`.",
          "source_line": 71,
          "expected": [
            "a conversation started from Home after activation launches from that profile (its id and settings are stamped on the conversation)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--mcp-scope",
          "title": "MCP scope",
          "ids": [
            "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; on older backends without per-launch LLM override it must also be pinned to the pill's LLM): 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`.",
          "raw": "**MCP scope (`F13.mcp-scope`).** 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; on older backends without per-launch LLM override it must also be pinned to the pill's LLM): 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`.",
          "source_line": 72,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--credential-fields-per-preset",
          "title": "Credential fields per preset",
          "ids": [
            "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).",
          "raw": "**Credential fields per preset (`F13.acp-preset-credentials`).** 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).",
          "source_line": 73,
          "expected": [
            "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`; Pi `PI_AUTH_JSON` (multi-line), `ANTHROPIC_API_KEY`; OpenCode `OPENCODE_AUTH_CONTENT` (multi-line), `OPENCODE_API_KEY`; **Custom** has no Credentials section. With no host login and no stored credential (Gemini here) no auth banner shows."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--menu-keyboard",
          "title": "Menu keyboard",
          "ids": [
            "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\"`: `agent-profile-edit` (opening the menu moves focus to its first enabled item, as the LLM profile menu does in `F10.actions-menu`). Then `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`) and returns focus to the row's own trigger (`control-openhands browser eval \"(()=>{const a=document.activeElement;const r=a.closest('[data-testid=agent-profile-row]');return a.dataset.testid+'@'+(r?r.innerText.split('\\\\n')[0]:'-')})()\"` is `agent-profile-menu-trigger@QA_kb`); reopen it and `control-openhands browser press Tab`: closed, and focus moves on past the trigger to the next row's trigger; keyboard open (`control-openhands browser focus` on the trigger, then `browser press Enter`) also lands on `agent-profile-edit`; 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]`; focus is already on `agent-profile-edit`), then `control-openhands browser press ArrowDown` and the eval, then `control-openhands browser press ArrowUp` and the eval: `agent-profile-delete` (the disabled **Set as active** is skipped), then `agent-profile-edit` (`control-openhands browser screenshot --feature F13.menu-keyboard --name active-row-menu`). At `browser viewport phone` the menu opens inside the viewport with the same focus order. 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`.",
          "raw": "**Menu keyboard (`F13.menu-keyboard`).** 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\"`: `agent-profile-edit` (opening the menu moves focus to its first enabled item, as the LLM profile menu does in `F10.actions-menu`). Then `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`) and returns focus to the row's own trigger (`control-openhands browser eval \"(()=>{const a=document.activeElement;const r=a.closest('[data-testid=agent-profile-row]');return a.dataset.testid+'@'+(r?r.innerText.split('\\\\n')[0]:'-')})()\"` is `agent-profile-menu-trigger@QA_kb`); reopen it and `control-openhands browser press Tab`: closed, and focus moves on past the trigger to the next row's trigger; keyboard open (`control-openhands browser focus` on the trigger, then `browser press Enter`) also lands on `agent-profile-edit`; 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]`; focus is already on `agent-profile-edit`), then `control-openhands browser press ArrowDown` and the eval, then `control-openhands browser press ArrowUp` and the eval: `agent-profile-delete` (the disabled **Set as active** is skipped), then `agent-profile-edit` (`control-openhands browser screenshot --feature F13.menu-keyboard --name active-row-menu`). At `browser viewport phone` the menu opens inside the viewport with the same focus order. 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`.",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--delete",
          "title": "Delete",
          "ids": [
            "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 `1`, the `default` row has that badge, and `api GET /api/agent-profiles` has `active_agent_profile_id` equal to default's id. Delete `QA_codex`, `QA_opencode`, `QA_pi` and `default`: the list again shows one row, `default` / `deepseek-flash` / `Default`, with a new id (the server re-seeds it).",
          "raw": "**Delete (`F13.delete`).** 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 `1`, the `default` row has that badge, and `api GET /api/agent-profiles` has `active_agent_profile_id` equal to default's id. Delete `QA_codex`, `QA_opencode`, `QA_pi` and `default`: the list again shows one row, `default` / `deepseek-flash` / `Default`, with a new id (the server re-seeds it).",
          "source_line": 75,
          "expected": [
            "Delete asks for confirmation; Cancel keeps the row, Delete removes it. Deleting the active profile selects `default` immediately; deleting the last profile makes the server re-seed and activate `default`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F13.phone`).** 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`.",
          "source_line": 76,
          "expected": [
            "the list, row menu and editor fit a 390 px viewport without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--errors",
          "title": "Errors",
          "ids": [],
          "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.",
          "raw": "**Errors.** 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.",
          "source_line": 77,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F13--blocked",
          "title": "Blocked",
          "ids": [
            "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.",
          "raw": "**Blocked (`F13.acp-conversation`, `F13.cloud-read-only`).** 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.",
          "source_line": 78,
          "expected": [
            "a conversation launched from an active ACP profile runs the external agent. Blocked without an approved provider account.",
            "on a Cloud backend, organization members see rows without the `...` menu or **Add agent profile**. Blocked without a Cloud account."
          ],
          "notes": [
            "Known issue [#16905](https://github.com/OpenHands/OpenHands/issues/16905): An enabled built-in OpenHands skill can be absent from a new Claude Code ACP session. The enabled/disabled instruction-delivery check needs an approved authenticated Claude Code account, its ACP subprocess and the configured Code Review automation; no provider session is authorized here. (`F13.acp-conversation`).",
            "Known issue [#17051](https://github.com/OpenHands/OpenHands/issues/17051): Codex ACP can work in New Chat while Issue-to-PR automation fails with missing OpenAI API credentials. Reproduction needs an approved Codex subscription login plus a configured Issue-to-PR automation and its repository credentials; the report does not identify the saved automation/profile settings. (`F13.acp-conversation`).",
            "Known issue [OpenHands/software-agent-sdk#5167](https://github.com/OpenHands/software-agent-sdk/issues/5167): On a headless Docker host an already usable Codex ChatGPT login can still time out during ACP authenticate. Reproduction needs an approved ChatGPT login in the reported headless/read-only-auth-file deployment and its adapter; a desktop or bogus login cannot establish this conditional trigger. (`F13.acp-conversation`).",
            "Known issue [OpenHands/software-agent-sdk#5995](https://github.com/OpenHands/software-agent-sdk/issues/5995): Claude Code ACP first turns with `claude-opus-5-5` can fail because the pinned adapter bundles an older Claude Code. Blocked: needs an approved Claude login/API key entitled to Opus5.5 and a real first turn; current SDK1.55 still pins the reported adapter0.63.0. No authenticated turn was attempted. (`F13.acp-conversation`, `F13.acp-model-discovery`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F13--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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.",
          "raw": "**Cleanup.** 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.",
          "source_line": 79,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F13--default-wording",
          "title": "Default wording",
          "ids": [
            "F13.list",
            "F13.set-active"
          ],
          "body": "Run `control-openhands browser goto /settings/agents`, `control-openhands browser text 'testid=agent-profile-active-badge'`, open the active row's `testid=agent-profile-menu-trigger`, and `control-openhands browser snapshot 'testid=agent-profile-actions-menu'`. Expected: the menu uses the same Default wording as the badge. Known failure, reproduced 2026-10-12 ([#17426](https://github.com/OpenHands/OpenHands/issues/17426)): the badge reads `Default` and the menu reads `Set as active`. Close with Escape, reopen once to repeat, then Escape; no activation or model call is required.",
          "raw": "**Default wording (`F13.list`, `F13.set-active`).** Run `control-openhands browser goto /settings/agents`, `control-openhands browser text 'testid=agent-profile-active-badge'`, open the active row's `testid=agent-profile-menu-trigger`, and `control-openhands browser snapshot 'testid=agent-profile-actions-menu'`. Expected: the menu uses the same Default wording as the badge. Known failure, reproduced 2026-10-12 ([#17426](https://github.com/OpenHands/OpenHands/issues/17426)): the badge reads `Default` and the menu reads `Set as active`. Close with Escape, reopen once to repeat, then Escape; no activation or model call is required.",
          "source_line": 81,
          "expected": [
            "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).",
            "**Set as active** toasts `Switched to profile \"<name>\"`, moves the Default badge and is disabled on the active row."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([#17426](https://github.com/OpenHands/OpenHands/issues/17426)): the badge reads `Default` and the menu reads `Set as active`. Close with Escape, reopen once to repeat, then Escape; no activation or model call is required."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F13--seeded-default-key-propagation",
          "title": "Seeded default key propagation",
          "ids": [
            "F13.active-drives-conversation"
          ],
          "body": "With the seeded `default` agent profile active and the disposable `QA_LOOPBACK` LLM profile active, `control-openhands api GET /api/settings --pick llm_api_key_is_set` is `true`. Run `control-openhands conversation start --prompt 'Reply with exactly pong. Do not run tools.' --timeout 60` (the dummy endpoint refuses connections). Read only boolean presence of `agent.llm.api_key` in the run-owned persisted `base_state.json`, keeping the value private, and inspect `conversation status <id>`. Expected: the key is populated and initialization reaches the refused loopback endpoint rather than an absent-key error. This checks propagation before provider authentication or a successful reply.",
          "raw": "**Seeded default key propagation (`F13.active-drives-conversation`).** With the seeded `default` agent profile active and the disposable `QA_LOOPBACK` LLM profile active, `control-openhands api GET /api/settings --pick llm_api_key_is_set` is `true`. Run `control-openhands conversation start --prompt 'Reply with exactly pong. Do not run tools.' --timeout 60` (the dummy endpoint refuses connections). Read only boolean presence of `agent.llm.api_key` in the run-owned persisted `base_state.json`, keeping the value private, and inspect `conversation status <id>`. Expected: the key is populated and initialization reaches the refused loopback endpoint rather than an absent-key error. This checks propagation before provider authentication or a successful reply.",
          "source_line": 83,
          "expected": [
            "a conversation started from Home after activation launches from that profile (its id and settings are stamped on the conversation)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F13--gemini-auth-file-is-not-sufficient",
          "title": "Gemini auth file is not sufficient",
          "ids": [
            "F13.acp-credentials"
          ],
          "body": "In the isolated run HOME only, create an empty `{}` file at `private/home/.gemini/oauth_creds.json` (never replace a real host login). Open Add agent profile → ACP → Gemini CLI and read `testid=settings-acp-auth-detected`; Cancel and repeat a fresh Add form. Expected: no authenticated banner for an empty file. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#4629](https://github.com/OpenHands/software-agent-sdk/issues/4629)): `You're already signed in to Gemini CLI — you can leave the fields below blank.` No provider or model call is needed. Remove the own dummy auth file; SDK auth-method priority and upstream CLI-version branches remain outside this check.",
          "raw": "**Gemini auth file is not sufficient (`F13.acp-credentials`).** In the isolated run HOME only, create an empty `{}` file at `private/home/.gemini/oauth_creds.json` (never replace a real host login). Open Add agent profile → ACP → Gemini CLI and read `testid=settings-acp-auth-detected`; Cancel and repeat a fresh Add form. Expected: no authenticated banner for an empty file. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#4629](https://github.com/OpenHands/software-agent-sdk/issues/4629)): `You're already signed in to Gemini CLI — you can leave the fields below blank.` No provider or model call is needed. Remove the own dummy auth file; SDK auth-method priority and upstream CLI-version branches remain outside this check.",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16296](https://github.com/OpenHands/OpenHands/issues/16296): Claude Code credential fields remain visible below the already-connected banner, leading users to think keys are required. The connected-state check needs an approved existing Claude CLI login on the agent-server host; a dummy credential does not prove that signed-in state. (`F13.acp-credentials`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#4629](https://github.com/OpenHands/software-agent-sdk/issues/4629)): `You're already signed in to Gemini CLI — you can leave the fields below blank.` No provider or model call is needed. Remove the own dummy auth file; SDK auth-method priority and upstream CLI-version branches remain outside this check."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F13--published-stale-acp-binary-on-path",
          "title": "Published stale ACP binary on PATH",
          "ids": [
            "F13.acp-conversation"
          ],
          "body": "Install the real published `@zed-industries/codex-acp@0.16.0` under the owned run's `workspace/qa-stale-adapter` (no global install). Create/activate a real Codex ACP profile through Settings > Agents with only a dummy API-key presence marker and loopback Base URL. Prepend that package's `node_modules/.bin` only during the own backend `restart`; keep its private HOME, set default OpenAI routes plus HTTP(S)/ALL proxy to refused loopback and `NO_PROXY=127.0.0.1,localhost`, with cached dependencies offline. Doctor, then start two actual Home chats and inspect the sanitized runtime command and initialization version. Expected: the SDK uses the pinned `1.10.0` adapter. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5466](https://github.com/OpenHands/software-agent-sdk/issues/5466)): `acp_command` became bare `codex-acp`, and actual ACP initialization reported `agent_version=0.16.0`; the version warning still wrongly blamed npx fallback. Both then stopped at `Authentication required`. This proves selection/warning before authentication, not a usable session, the historical model-cache error or the Claude counterpart. Restore the launcher environment/PATH and default agent, delete the own profile/dummy secrets/package fixture, then doctor and stop.",
          "raw": "**Published stale ACP binary on PATH (`F13.acp-conversation`).** Install the real published `@zed-industries/codex-acp@0.16.0` under the owned run's `workspace/qa-stale-adapter` (no global install). Create/activate a real Codex ACP profile through Settings > Agents with only a dummy API-key presence marker and loopback Base URL. Prepend that package's `node_modules/.bin` only during the own backend `restart`; keep its private HOME, set default OpenAI routes plus HTTP(S)/ALL proxy to refused loopback and `NO_PROXY=127.0.0.1,localhost`, with cached dependencies offline. Doctor, then start two actual Home chats and inspect the sanitized runtime command and initialization version. Expected: the SDK uses the pinned `1.10.0` adapter. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5466](https://github.com/OpenHands/software-agent-sdk/issues/5466)): `acp_command` became bare `codex-acp`, and actual ACP initialization reported `agent_version=0.16.0`; the version warning still wrongly blamed npx fallback. Both then stopped at `Authentication required`. This proves selection/warning before authentication, not a usable session, the historical model-cache error or the Claude counterpart. Restore the launcher environment/PATH and default agent, delete the own profile/dummy secrets/package fixture, then doctor and stop.",
          "source_line": 87,
          "expected": [
            "a conversation launched from an active ACP profile runs the external agent. Blocked without an approved provider account."
          ],
          "notes": [
            "Known issue [#16905](https://github.com/OpenHands/OpenHands/issues/16905): An enabled built-in OpenHands skill can be absent from a new Claude Code ACP session. The enabled/disabled instruction-delivery check needs an approved authenticated Claude Code account, its ACP subprocess and the configured Code Review automation; no provider session is authorized here. (`F13.acp-conversation`).",
            "Known issue [#17051](https://github.com/OpenHands/OpenHands/issues/17051): Codex ACP can work in New Chat while Issue-to-PR automation fails with missing OpenAI API credentials. Reproduction needs an approved Codex subscription login plus a configured Issue-to-PR automation and its repository credentials; the report does not identify the saved automation/profile settings. (`F13.acp-conversation`).",
            "Known issue [OpenHands/software-agent-sdk#5167](https://github.com/OpenHands/software-agent-sdk/issues/5167): On a headless Docker host an already usable Codex ChatGPT login can still time out during ACP authenticate. Reproduction needs an approved ChatGPT login in the reported headless/read-only-auth-file deployment and its adapter; a desktop or bogus login cannot establish this conditional trigger. (`F13.acp-conversation`).",
            "Known issue [OpenHands/software-agent-sdk#5995](https://github.com/OpenHands/software-agent-sdk/issues/5995): Claude Code ACP first turns with `claude-opus-5-5` can fail because the pinned adapter bundles an older Claude Code. Blocked: needs an approved Claude login/API key entitled to Opus5.5 and a real first turn; current SDK1.55 still pins the reported adapter0.63.0. No authenticated turn was attempted. (`F13.acp-conversation`, `F13.acp-model-discovery`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5466](https://github.com/OpenHands/software-agent-sdk/issues/5466)): `acp_command` became bare `codex-acp`, and actual ACP initialization reported `agent_version=0.16.0`; the version warning still wrongly blamed npx fallback. Both then stopped at `Authentication required`. This proves selection/warning before authentication, not a usable session, the historical model-cache error or the Claude counterpart. Restore the launcher environment/PATH and default agent, delete the own profile/dummy secrets/package fixture, then doctor and stop."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F13--suffixed-profile-keeps-its-identity",
          "title": "Suffixed profile keeps its identity",
          "ids": [
            "F13.edit"
          ],
          "body": "A normal edit preserves the profile UUID and increments its revision. Known failure (reproduced 2026-10-16, [OpenHands/software-agent-sdk#5873](https://github.com/OpenHands/software-agent-sdk/issues/5873)): two real concurrency-only saves of a `.json` name each minted a new UUID at revision0. SDK1.55 repaired the active pointer to default; do not claim it remained dangling. The credential-free block checks the revision symptom and cleans its own fixtures.\n\n  ```sh known-bug F13.edit json-suffix\n  trap 'control-openhands api DELETE /api/agent-profiles/QA_KNOWN_SUFFIX --write >/dev/null' EXIT\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/agents\n  control-openhands browser click 'testid=add-agent-profile'\n  control-openhands browser fill 'testid=agent-profile-name-input' QA_KNOWN_SUFFIX.json\n  control-openhands browser click 'testid=save-agent-profile-btn'\n  control-openhands browser wait-text 'Profile \"QA_KNOWN_SUFFIX.json\" created' --timeout 10000\n  control-openhands assert --field value --equals 0 -- api GET /api/agent-profiles/QA_KNOWN_SUFFIX --pick profile.revision\n  control-openhands browser click 'testid=agent-profile-row >> has-text=QA_KNOWN_SUFFIX >> testid=agent-profile-menu-trigger'\n  control-openhands browser click 'testid=agent-profile-edit'\n  control-openhands browser fill 'testid=sdk-settings-tool_concurrency_limit' 2\n  control-openhands browser click 'testid=save-agent-profile-btn'\n  control-openhands browser wait-text 'Profile \"QA_KNOWN_SUFFIX.json\" updated' --timeout 10000\n  control-openhands assert --field value --equals 1 --bug-shows 0 -- api GET /api/agent-profiles/QA_KNOWN_SUFFIX --pick profile.revision  # bug\n  ```",
          "raw": "**Suffixed profile keeps its identity (`F13.edit`).** A normal edit preserves the profile UUID and increments its revision. Known failure (reproduced 2026-10-16, [OpenHands/software-agent-sdk#5873](https://github.com/OpenHands/software-agent-sdk/issues/5873)): two real concurrency-only saves of a `.json` name each minted a new UUID at revision0. SDK1.55 repaired the active pointer to default; do not claim it remained dangling. The credential-free block checks the revision symptom and cleans its own fixtures.\n\n  ```sh known-bug F13.edit json-suffix\n  trap 'control-openhands api DELETE /api/agent-profiles/QA_KNOWN_SUFFIX --write >/dev/null' EXIT\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/agents\n  control-openhands browser click 'testid=add-agent-profile'\n  control-openhands browser fill 'testid=agent-profile-name-input' QA_KNOWN_SUFFIX.json\n  control-openhands browser click 'testid=save-agent-profile-btn'\n  control-openhands browser wait-text 'Profile \"QA_KNOWN_SUFFIX.json\" created' --timeout 10000\n  control-openhands assert --field value --equals 0 -- api GET /api/agent-profiles/QA_KNOWN_SUFFIX --pick profile.revision\n  control-openhands browser click 'testid=agent-profile-row >> has-text=QA_KNOWN_SUFFIX >> testid=agent-profile-menu-trigger'\n  control-openhands browser click 'testid=agent-profile-edit'\n  control-openhands browser fill 'testid=sdk-settings-tool_concurrency_limit' 2\n  control-openhands browser click 'testid=save-agent-profile-btn'\n  control-openhands browser wait-text 'Profile \"QA_KNOWN_SUFFIX.json\" updated' --timeout 10000\n  control-openhands assert --field value --equals 1 --bug-shows 0 -- api GET /api/agent-profiles/QA_KNOWN_SUFFIX --pick profile.revision  # bug\n  ```",
          "source_line": 89,
          "expected": [
            "Edit reopens the stored values with Save disabled until something changes; renaming keeps the profile id; Back and Cancel leave without saving."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-16, [OpenHands/software-agent-sdk#5873](https://github.com/OpenHands/software-agent-sdk/issues/5873)): two real concurrency-only saves of a `.json` name each minted a new UUID at revision0. SDK1.55 repaired the active pointer to default; do not claim it remained dangling. The credential-free block checks the revision symptom and cleans its own fixtures."
          ],
          "states": [
            "failure"
          ]
        }
      ]
    },
    {
      "id": "F14",
      "title": "Secrets",
      "file": "F14-secrets.md",
      "sha256": "691a0bb63cdd0d9798212156afe96cf4c545d13ab2cd5e706f2a076b3cd1a763",
      "overview": "Secrets are named values the agent can use during conversations. A user lists,\nadds, edits and deletes them under Settings → Secrets; saved values are never\nshown again, and every new conversation receives them as environment variables.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`), then **Secrets** in the settings navigation (`sidebar-settings-/settings/secrets`).\n- Direct URL `/settings/secrets`.\n- Command menu (`Control+k` or `Meta+k`, or `command-menu-trigger` in the sidebar): search \"Secrets\", choose **Secrets settings**.\n- The agent itself, in a conversation: its `request_secret` client tool opens the request dialog (`testid=secret-request-modal`) on `/conversations/<id>` (`F14.agent-request`).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done).\n- No secret named `QA_TMP_SECRET`, `QA_AGENT_SECRET`, `QA_RENAMED_SECRET` or `QA_BACK_SECRET` exists (`control-openhands api GET /api/settings/secrets`).\n- `F14.agent-access`, `F14.rename`, `F14.edit-value` and `F14.agent-request` need an active LLM profile (`control-openhands llm preset deepseek`).\n- No secret named `QA_REQ_SECRET` or `QA_REQ_CANCEL` exists, for `F14.agent-request`.",
      "gotchas": "- Settings nav test ids embed the route, slashes included: `sidebar-settings-/settings/secrets`. Quote the selector as written.\n\n- The value editor is a plain `textarea`, not a password field: use dummy values only, and never ask the agent to print a real secret.\n- Edit forms intentionally show an empty value; that is not data loss.\n- The invalid-name check is native browser validation. It shows a tooltip that screenshots do not capture, and it leaves any earlier `Secret already exists` paragraph on screen. Assert with `validationMessage` and the API, not the paragraph.\n- The model is the weak link in the agent checks, not the product: asked to print a secret's length or last characters, deepseek-flash replies \"I can't run that one\" and runs nothing, and even for the equality check it may refuse to repeat `True` in its reply. Use the equality prompt and read the terminal `ObservationEvent`; `conversation events` truncates message text to about 160 characters.\n- The conversation Overview has no Secrets entry point: its \"...\" menu (`conversation-overview-ellipsis`) offers Workspace and Git sections only.\n- There is no empty state to reach safely: `secrets-empty` renders only with zero secrets, which means deleting the launcher-seeded key (see below).\n- Secrets saved in Settings reach only conversations started after the save. Reuse an older conversation and the variable is missing. Only `request_secret` (`F14.agent-request`) attaches a secret to a running conversation, and only on a local backend.\n- `request_secret` is registered on every new OpenHands-agent conversation this frontend launches (not on ACP agents or older conversations) and appears in Show Agent Tools & Metadata (see `F07.agent-tools-modal`). The agent learns the outcome from a hidden `[secret-request] ...` user message; read it with `conversation events <id> --kinds MessageEvent`, not from the chat.\n- At 390 px the name column truncates long names (`OPEN…`); use `browser text` on the row rather than the screenshot to read a name.\n- `OPENHANDS_AUTOMATION_API_KEY` is seeded by the launcher for automations; deleting it breaks F18/F19 runs in the same stack.\n- Known issue [#17710](https://github.com/OpenHands/OpenHands/issues/17710): On the Cloud backend, deleting a secret reportedly adds a suffixed duplicate and leaves the original. Reproduction needs a disposable writable OpenHands Cloud account at the affected service; Local secret CRUD exercises a different transport. (`F14.delete`, `F14.edit`).",
      "features": [
        {
          "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."
        },
        {
          "id": "F14.agent-request",
          "description": "in a local conversation the agent's `request_secret` tool opens **The agent needs a secret** over the chat with its reason and the name prefilled; Add secret saves it, attaches it to the running conversation and reports `saved` to the agent, Cancel reports `not_saved`, and a name that is already saved is attached without a dialog. The value never reaches the agent's events."
        }
      ],
      "scenarios": [
        {
          "name": "F14--open-the-list",
          "title": "Open the list",
          "ids": [
            "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`.",
          "raw": "**Open the list (`F14.list`).** 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`.",
          "source_line": 40,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--command-menu-entry",
          "title": "Command-menu entry",
          "ids": [
            "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`.",
          "raw": "**Command-menu entry (`F14.list`).** 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`.",
          "source_line": 41,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--create",
          "title": "Create",
          "ids": [
            "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.",
          "raw": "**Create (`F14.create`).** 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.",
          "source_line": 42,
          "expected": [
            "adding a secret persists it; it is listed after a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--duplicate-name",
          "title": "Duplicate name",
          "ids": [
            "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. The paragraph uses the theme's danger colour (#18199): `control-openhands browser eval \"getComputedStyle([...document.querySelectorAll('[data-testid=add-secret-form] p')].find(e=>e.innerText==='Secret already exists')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(209, 52, 56)` on Light+ (`F16.color-theme`).",
          "raw": "**Duplicate name (`F14.create-validation`).** 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. The paragraph uses the theme's danger colour (#18199): `control-openhands browser eval \"getComputedStyle([...document.querySelectorAll('[data-testid=add-secret-form] p')].find(e=>e.innerText==='Secret already exists')).color\"` is `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(209, 52, 56)` on Light+ (`F16.color-theme`).",
          "source_line": 43,
          "expected": [
            "duplicate names, names that break `^[a-zA-Z][a-zA-Z0-9_]{0,63}$` and empty values are refused with a message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--invalid-name",
          "title": "Invalid name",
          "ids": [
            "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'`.",
          "raw": "**Invalid name (`F14.create-validation`).** 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'`.",
          "source_line": 44,
          "expected": [
            "duplicate names, names that break `^[a-zA-Z][a-zA-Z0-9_]{0,63}$` and empty values are refused with a message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--empty-value",
          "title": "Empty value",
          "ids": [
            "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`.",
          "raw": "**Empty value (`F14.create-validation`).** 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`.",
          "source_line": 45,
          "expected": [
            "duplicate names, names that break `^[a-zA-Z][a-zA-Z0-9_]{0,63}$` and empty values are refused with a message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--edit-keeps-the-value",
          "title": "Edit keeps the value",
          "ids": [
            "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).",
          "raw": "**Edit keeps the value (`F14.edit`).** 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).",
          "source_line": 46,
          "expected": [
            "editing the description with the value left blank keeps the stored value; Save stays disabled until something changes."
          ],
          "notes": [
            "Known issue [#17710](https://github.com/OpenHands/OpenHands/issues/17710): On the Cloud backend, deleting a secret reportedly adds a suffixed duplicate and leaves the original. Reproduction needs a disposable writable OpenHands Cloud account at the affected service; Local secret CRUD exercises a different transport. (`F14.delete`, `F14.edit`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F14--delete-with-confirmation",
          "title": "Delete with confirmation",
          "ids": [
            "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`.",
          "raw": "**Delete with confirmation (`F14.delete`).** 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`.",
          "source_line": 47,
          "expected": [
            "deleting asks for confirmation; Cancel keeps the row, Confirm removes it for good."
          ],
          "notes": [
            "Known issue [#17710](https://github.com/OpenHands/OpenHands/issues/17710): On the Cloud backend, deleting a secret reportedly adds a suffixed duplicate and leaves the original. Reproduction needs a disposable writable OpenHands Cloud account at the affected service; Local secret CRUD exercises a different transport. (`F14.delete`, `F14.edit`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F14--agent-receives-the-secret",
          "title": "Agent receives the secret",
          "ids": [
            "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.",
          "raw": "**Agent receives the secret (`F14.agent-access`).** 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.",
          "source_line": 48,
          "expected": [
            "a conversation started after the secret was saved sees it as an environment variable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--phone-layout",
          "title": "Phone layout",
          "ids": [
            "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`.",
          "raw": "**Phone layout (`F14.phone`).** 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`.",
          "source_line": 49,
          "expected": [
            "the page and its forms fit a 390 px viewport without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--back-discards-the-draft",
          "title": "Back discards the draft",
          "ids": [
            "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`.",
          "raw": "**Back discards the draft (`F14.form-back`).** 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`.",
          "source_line": 50,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--rename-keeps-the-value",
          "title": "Rename keeps the value",
          "ids": [
            "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`.",
          "raw": "**Rename keeps the value (`F14.rename`).** 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`.",
          "source_line": 51,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--replace-the-value",
          "title": "Replace the value",
          "ids": [
            "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`.",
          "raw": "**Replace the value (`F14.edit-value`).** 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`.",
          "source_line": 52,
          "expected": [
            "typing a value in the Edit form (labelled \"Secret Value (leave blank to preserve the existing value)\") replaces the stored value for new conversations."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--agent-requests-a-secret",
          "title": "Agent requests a secret",
          "ids": [
            "F14.agent-request"
          ],
          "body": "Run `control-openhands browser goto /`, then `control-openhands conversation start --prompt \"Call the request_secret tool with name QA_REQ_SECRET and reason 'QA check: a dummy token for verification.' Do not run any other tools. After calling it, end your turn and wait.\" --wait --timeout 240` (prints `<req-id>`; it leaves the browser on the conversation). `control-openhands conversation events <req-id> --kinds ActionEvent,ObservationEvent` shows the `request_secret` action and the observation `Tool call dispatched to client.`; `control-openhands browser snapshot 'role=dialog'` reads `dialog \"The agent needs a secret\"`, the reason `QA check: a dummy token for verification.`, textbox `Name` holding `QA_REQ_SECRET`, `Value`, `Description Optional`, `Cancel` and `Add secret [disabled]` (`browser screenshot --feature F14.agent-request --name modal`). Focus starts in `name-input` and Tab cycles Name, Value, Description and Cancel without leaving the dialog (`control-openhands browser eval \"document.activeElement.dataset.testid\"` after each `browser press Tab`). `control-openhands browser press Escape` and `control-openhands browser mouse-click 30 500` leave it open (`browser count 'testid=secret-request-modal'` stays `1`), and so does `control-openhands browser reload`, with the name still prefilled. Run `control-openhands browser fill 'testid=secret-request-modal >> testid=value-input' qa-dummy-5190` and `control-openhands browser click 'testid=secret-request-modal >> testid=submit-button'`: the dialog closes, `control-openhands api GET /api/settings/secrets` lists `QA_REQ_SECRET`, and after `control-openhands conversation wait <req-id> --fresh --timeout 180` `control-openhands conversation events <req-id> --kinds MessageEvent` holds the user message `[secret-request] {\"status\":\"saved\",\"name\":\"QA_REQ_SECRET\"}`, which the chat does not show (`browser count 'text=[secret-request]'` is `0`). The running conversation now has the value: `control-openhands conversation send <req-id> --prompt \"Run exactly this command and reply with only its output: python3 -c \\\"import os; print(os.environ.get('QA_REQ_SECRET') == 'qa-dummy-5190')\\\"\" --wait --timeout 240`, then the last terminal `ObservationEvent` is `True`. Cancel: `browser goto /`, start a second conversation with the same prompt naming `QA_REQ_CANCEL` and reason `QA cancel check.`, fill `value-input` with `should-not-save` and `control-openhands browser click 'testid=secret-request-modal >> testid=cancel-button'`: the dialog closes, `api GET /api/settings/secrets` lists no `QA_REQ_CANCEL`, the agent receives `[secret-request] {\"status\":\"not_saved\",\"name\":\"QA_REQ_CANCEL\",\"guidance\":...}`, and a reload does not bring the dialog back. Already saved: a third conversation asking for `QA_REQ_SECRET` again shows no dialog (`browser count 'testid=secret-request-modal'` `0`) and its events hold `[secret-request] {\"status\":\"saved\",\"name\":\"QA_REQ_SECRET\"}`. Expected: at phone width the dialog fits the viewport (at most 90vw, 351 px at 390 px, `insideViewport` `true`) with the title, every field and both buttons visible. Known failure (reproduced 2026-10-14, #18432): at 390×844 `browser bbox 'testid=secret-request-modal'` is 520 px wide at x -65, `insideViewport` `false`, so the title, the field labels and Cancel are cut off on the left (and 65 px on the right); run the block below. Return with `browser viewport desktop`. `QA_REQ_SECRET` is deleted in the cleanup below.\n\n  ```sh known-bug F14.agent-request phone-width\n  control-openhands browser viewport desktop\n  control-openhands browser goto /\n  control-openhands conversation start --prompt \"Call the request_secret tool with name QA_REQ_PHONE and reason 'QA phone check.' Do not run any other tools. After calling it, end your turn and wait.\" --wait --timeout 240\n  control-openhands browser wait 'testid=secret-request-modal'\n  control-openhands assert --field insideViewport --equals true -- browser bbox 'testid=secret-request-modal'\n  control-openhands browser viewport phone\n  control-openhands browser screenshot --feature F14.agent-request --name phone-width\n  control-openhands assert --field insideViewport --equals true --bug 'insideViewport eq false && box.width eq 520' -- browser bbox 'testid=secret-request-modal'  # bug\n  control-openhands browser click 'testid=secret-request-modal >> role=button[name=\"Cancel\"]'\n  control-openhands browser wait 'testid=secret-request-modal' --state detached\n  ```",
          "raw": "**Agent requests a secret (`F14.agent-request`).** Run `control-openhands browser goto /`, then `control-openhands conversation start --prompt \"Call the request_secret tool with name QA_REQ_SECRET and reason 'QA check: a dummy token for verification.' Do not run any other tools. After calling it, end your turn and wait.\" --wait --timeout 240` (prints `<req-id>`; it leaves the browser on the conversation). `control-openhands conversation events <req-id> --kinds ActionEvent,ObservationEvent` shows the `request_secret` action and the observation `Tool call dispatched to client.`; `control-openhands browser snapshot 'role=dialog'` reads `dialog \"The agent needs a secret\"`, the reason `QA check: a dummy token for verification.`, textbox `Name` holding `QA_REQ_SECRET`, `Value`, `Description Optional`, `Cancel` and `Add secret [disabled]` (`browser screenshot --feature F14.agent-request --name modal`). Focus starts in `name-input` and Tab cycles Name, Value, Description and Cancel without leaving the dialog (`control-openhands browser eval \"document.activeElement.dataset.testid\"` after each `browser press Tab`). `control-openhands browser press Escape` and `control-openhands browser mouse-click 30 500` leave it open (`browser count 'testid=secret-request-modal'` stays `1`), and so does `control-openhands browser reload`, with the name still prefilled. Run `control-openhands browser fill 'testid=secret-request-modal >> testid=value-input' qa-dummy-5190` and `control-openhands browser click 'testid=secret-request-modal >> testid=submit-button'`: the dialog closes, `control-openhands api GET /api/settings/secrets` lists `QA_REQ_SECRET`, and after `control-openhands conversation wait <req-id> --fresh --timeout 180` `control-openhands conversation events <req-id> --kinds MessageEvent` holds the user message `[secret-request] {\"status\":\"saved\",\"name\":\"QA_REQ_SECRET\"}`, which the chat does not show (`browser count 'text=[secret-request]'` is `0`). The running conversation now has the value: `control-openhands conversation send <req-id> --prompt \"Run exactly this command and reply with only its output: python3 -c \\\"import os; print(os.environ.get('QA_REQ_SECRET') == 'qa-dummy-5190')\\\"\" --wait --timeout 240`, then the last terminal `ObservationEvent` is `True`. Cancel: `browser goto /`, start a second conversation with the same prompt naming `QA_REQ_CANCEL` and reason `QA cancel check.`, fill `value-input` with `should-not-save` and `control-openhands browser click 'testid=secret-request-modal >> testid=cancel-button'`: the dialog closes, `api GET /api/settings/secrets` lists no `QA_REQ_CANCEL`, the agent receives `[secret-request] {\"status\":\"not_saved\",\"name\":\"QA_REQ_CANCEL\",\"guidance\":...}`, and a reload does not bring the dialog back. Already saved: a third conversation asking for `QA_REQ_SECRET` again shows no dialog (`browser count 'testid=secret-request-modal'` `0`) and its events hold `[secret-request] {\"status\":\"saved\",\"name\":\"QA_REQ_SECRET\"}`. Expected: at phone width the dialog fits the viewport (at most 90vw, 351 px at 390 px, `insideViewport` `true`) with the title, every field and both buttons visible. Known failure (reproduced 2026-10-14, #18432): at 390×844 `browser bbox 'testid=secret-request-modal'` is 520 px wide at x -65, `insideViewport` `false`, so the title, the field labels and Cancel are cut off on the left (and 65 px on the right); run the block below. Return with `browser viewport desktop`. `QA_REQ_SECRET` is deleted in the cleanup below.\n\n  ```sh known-bug F14.agent-request phone-width\n  control-openhands browser viewport desktop\n  control-openhands browser goto /\n  control-openhands conversation start --prompt \"Call the request_secret tool with name QA_REQ_PHONE and reason 'QA phone check.' Do not run any other tools. After calling it, end your turn and wait.\" --wait --timeout 240\n  control-openhands browser wait 'testid=secret-request-modal'\n  control-openhands assert --field insideViewport --equals true -- browser bbox 'testid=secret-request-modal'\n  control-openhands browser viewport phone\n  control-openhands browser screenshot --feature F14.agent-request --name phone-width\n  control-openhands assert --field insideViewport --equals true --bug 'insideViewport eq false && box.width eq 520' -- browser bbox 'testid=secret-request-modal'  # bug\n  control-openhands browser click 'testid=secret-request-modal >> role=button[name=\"Cancel\"]'\n  control-openhands browser wait 'testid=secret-request-modal' --state detached\n  ```",
          "source_line": 53,
          "expected": [
            "in a local conversation the agent's `request_secret` tool opens **The agent needs a secret** over the chat with its reason and the name prefilled; Add secret saves it, attaches it to the running conversation and reports `saved` to the agent, Cancel reports `not_saved`, and a name that is already saved is attached without a dialog. The value never reaches the agent's events."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-14, #18432): at 390×844 `browser bbox 'testid=secret-request-modal'` is 520 px wide at x -65, `insideViewport` `false`, so the title, the field labels and Cancel are cut off on the left (and 65 px on the right); run the block below. Return with `browser viewport desktop`. `QA_REQ_SECRET` is deleted in the cleanup below."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F14--escape-cancels-delete",
          "title": "Escape cancels delete",
          "ids": [
            "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` and then `QA_REQ_SECRET` (from `F14.agent-request`), each with `delete-secret-button` → `testid=confirmation-modal >> testid=confirm-button`, `browser reload`; `api GET /api/settings/secrets` lists only `OPENHANDS_AUTOMATION_API_KEY`.",
          "raw": "**Escape cancels delete (`F14.delete-escape`).** 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` and then `QA_REQ_SECRET` (from `F14.agent-request`), each with `delete-secret-button` → `testid=confirmation-modal >> testid=confirm-button`, `browser reload`; `api GET /api/settings/secrets` lists only `OPENHANDS_AUTOMATION_API_KEY`.",
          "source_line": 67,
          "expected": [
            "Escape closes the delete confirmation without deleting."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F14--cipher-mismatch-refuses-mutation",
          "title": "Cipher mismatch refuses mutation",
          "ids": [
            "F14.create",
            "F14.edit",
            "F14.delete"
          ],
          "body": "This is an external-state fixture, not a credential test. Arrange two own dummy secrets and a dummy loopback LLM key; privately back up the run-owned `encryption-key`, `settings.json` and `secrets.json`. Replace only the own cipher file with a newly generated value (never display it), restart and doctor. After bootstrap, restore the old encrypted store bytes under the replacement cipher to isolate the UI write. Add a new dummy secret through the real Secrets form, and inspect only boolean preservation metadata / availability of the old dummy value privately. Independently restore the encrypted settings baseline and change Sound Notifications through Application → Save. Expected: reject writes when existing encrypted state cannot decrypt, preserving it. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5622](https://github.com/OpenHands/software-agent-sdk/issues/5622)): the secret create succeeds and rewrites an old entry while its dummy value is unavailable (getter 404, name still listed); two unrelated settings saves persist the old LLM key as null. Repeat with a fresh replacement cipher. Immediately restore the original cipher and both stores in `finally`, restart and doctor; original state must be readable again. This drives secret creation and settings Save, not every edit/delete permutation. Keep all cipher bytes, values and raw store snapshots private.",
          "raw": "**Cipher mismatch refuses mutation (`F14.create`, `F14.edit`, `F14.delete`).** This is an external-state fixture, not a credential test. Arrange two own dummy secrets and a dummy loopback LLM key; privately back up the run-owned `encryption-key`, `settings.json` and `secrets.json`. Replace only the own cipher file with a newly generated value (never display it), restart and doctor. After bootstrap, restore the old encrypted store bytes under the replacement cipher to isolate the UI write. Add a new dummy secret through the real Secrets form, and inspect only boolean preservation metadata / availability of the old dummy value privately. Independently restore the encrypted settings baseline and change Sound Notifications through Application → Save. Expected: reject writes when existing encrypted state cannot decrypt, preserving it. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5622](https://github.com/OpenHands/software-agent-sdk/issues/5622)): the secret create succeeds and rewrites an old entry while its dummy value is unavailable (getter 404, name still listed); two unrelated settings saves persist the old LLM key as null. Repeat with a fresh replacement cipher. Immediately restore the original cipher and both stores in `finally`, restart and doctor; original state must be readable again. This drives secret creation and settings Save, not every edit/delete permutation. Keep all cipher bytes, values and raw store snapshots private.",
          "source_line": 69,
          "expected": [
            "adding a secret persists it; it is listed after a reload.",
            "editing the description with the value left blank keeps the stored value; Save stays disabled until something changes.",
            "deleting asks for confirmation; Cancel keeps the row, Confirm removes it for good."
          ],
          "notes": [
            "Known issue [#17710](https://github.com/OpenHands/OpenHands/issues/17710): On the Cloud backend, deleting a secret reportedly adds a suffixed duplicate and leaves the original. Reproduction needs a disposable writable OpenHands Cloud account at the affected service; Local secret CRUD exercises a different transport. (`F14.delete`, `F14.edit`)."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#5622](https://github.com/OpenHands/software-agent-sdk/issues/5622)): the secret create succeeds and rewrites an old entry while its dummy value is unavailable (getter 404, name still listed); two unrelated settings saves persist the old LLM key as null. Repeat with a fresh replacement cipher. Immediately restore the original cipher and both stores in `finally`, restart and doctor; original state must be readable again. This drives secret creation and settings Save, not every edit/delete permutation. Keep all cipher bytes, values and raw store snapshots private."
          ],
          "states": [
            "failure",
            "issue"
          ]
        }
      ]
    },
    {
      "id": "F15",
      "title": "Condenser, agent context and verification settings",
      "file": "F15-agent-behavior-settings.md",
      "sha256": "ecb2e14f733ae101f70810d28781c2392ec7b43a925ee2ba330c6736d2c68818",
      "overview": "Three settings pages are generated from the Agent Server's settings schemas:\n**Condenser** (summarize long chats), **Agent Context** (persistent agent\nmemory) and **Verification** (confirmation mode, security analyzer and the\ncritic). Each page shows the schema's fields in Basic/Advanced/All tiers (only\nthe tiers that have fields), hides fields whose parent switch is off, and saves\nthrough one **Save Changes** button that stays disabled until a value differs\nfrom the stored one.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`, lands on `/settings/agents`), then **Condenser**, **Agent Context** or **Verification** in the settings navigation (`sidebar-settings-/settings/condenser`, `sidebar-settings-/settings/agent-context`, `sidebar-settings-/settings/verification`).\n- Direct URLs `/settings/condenser`, `/settings/agent-context`, `/settings/verification`.\n- Command menu (`Control+k` or `Meta+k`, or `command-menu-trigger` in the sidebar): **Condenser settings**, **Agent Context** and **Verification settings**; \"memory\" finds both Condenser settings and Agent Context.\n- The effects show up in new conversations only: the confirmation prompt in the chat, and the memory instructions in the conversation's system prompt.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done). No LLM is needed for the settings pages.\n- `F15.confirmation-mode-effect` and `F15.agent-context-effect` need an active LLM profile (`control-openhands llm preset deepseek`).\n- `F15.schema-unavailable` needs an agent server that serves `/api/settings` but answers 404 for `/api/settings/agent-schema` (an older release via `launch --new --sdk-ref <old>`); not available in this checkout's pinned stack.\n- Stored values are the defaults: `control-openhands api GET /api/settings` shows `agent_settings.condenser.enabled` `true`, `max_size` `240`, `condenser_kind` `llm_summarizing`; `agent_context.load_memory` `false`; `verification.critic_enabled` `false`; `conversation_settings.confirmation_mode` `false`.",
      "gotchas": "- Switches are `sr-only` checkboxes (keyboard reachable): toggle by clicking the label text scoped to the page, read `.checked` with `browser eval`.\n- `text=Enable Critic` is a case-insensitive substring match and hits the help paragraph `Enable critic evaluation for the agent.` first, so the click does nothing. Quote it: `text=\"Enable Critic\"` (same for `text=\"Enable Iterative Refinement\"`).\n- The conversation-owned fields have no `verification.` prefix: `sdk-settings-confirmation_mode`, `sdk-settings-security_analyzer`. Critic fields keep it (`sdk-settings-verification.critic_enabled`).\n- Saving is tier-scoped: Basic or Advanced saves reset every field the tier hides to its schema default (`F15.view-scoped-save`). Make all edits on All when other non-default values must survive.\n- The page opens on the least detailed tier that shows every stored override (any minor override → All). Do not assume Basic after a reload.\n- Condenser Kind **None** (`no_op`) still shows the summarizer fields with defaults, although the stored condenser no longer has them, and edits to them are accepted (`Settings saved`) but dropped while **None** is stored. #18049 covers hiding fields that do not apply to the selected kind.\n- A server-side validation error is retried three times (about 1.5 s of `Saving...`) before the toast, and the toast only says `Settings validation failed` without naming the field (#17932).\n- Condenser Kind is served by the Agent Server's settings schema as a user field. Canvas shows its own choice labels and help text rather than raw `llm_summarizing`/`no_op` values or reStructuredText. The dropdown (`F15.condenser-kind`) remains a user setting so additional kinds can be selected. [OpenHands/software-agent-sdk#5495](https://github.com/OpenHands/software-agent-sdk/issues/5495), the schema's developer-facing description, remains an upstream issue.\n- Toasts close after a few seconds; read them right after the click, or assert the stored value with `api GET /api/settings`.\n- Under confirmation mode the first confirmed command sometimes returned `The terminal session was reset because the underlying tmux server/session disappeared...` while several runs shared one tmux server (OpenHands/OpenHands#17946); `launch` now isolates them, so treat a reset as a finding. The agent then asks to run it again; a confirmed command took up to two minutes to produce its observation. Confirm again and wait with `conversation wait`, not a fixed sleep.\n- The confirmation buttons have test ids (`action-confirm-button`, `action-reject-button`) but their accessible names are `Confirm action` / `Reject action`, not the visible `Continue` / `Cancel`: `role=button[name*=\"Continue\"]` finds nothing.\n- Basic-tier saves on Verification reset Security Analyzer to `llm` (it is an Advanced field). That is a convenient restore, and a trap if you meant to keep `None`.\n- The memory instructions are only visible through the conversation's first event; `conversation events` truncates text, so read `api GET /api/conversations/<id>/events/search?limit=1` and search the JSON.\n- A checkout without #18035 shows the condenser minor fields with the schema's English text (`Max tokens`, `Keep first`, raw choice values) and logs 14 `[i18n] Missing translation` warnings on the All tab.\n- The dropdowns' clear (×) button has no accessible name and, in this build, clicking it through `role=button >> nth=2` left the value unchanged.\n- After every successful save the selected tab is recomputed from the stored values (`F15.tier-after-save`), so a follow-up `fill` on an All-only field can time out because the page silently moved to Basic. Re-click `sdk-section-all-toggle` before each further edit.\n- Pressing Enter in a field does not save (no toast, API unchanged, Save stays enabled); only the Save Changes button submits.\n- Fields revealed by a parent switch do not exist while the switch is off: `fill` on `critic_threshold` with Iterative Refinement off waits for a missing element.\n- Rejecting a pending action (Cancel), the lock chip above the composer and the ⌘↩ / ⇧⌘⌫ shortcuts belong to `F06.confirmation-mode` and `F06.confirmation-shortcuts`; this family only proves that the settings turn the prompt on.\n- `browser count 'role=status'` also counts the backend status dot (`backend-status-dot`); use `browser toasts` for toast texts.",
      "features": [
        {
          "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)."
        }
      ],
      "scenarios": [
        {
          "name": "F15--open-condenser-from-the-sidebar",
          "title": "Open Condenser from the sidebar",
          "ids": [
            "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`).",
          "raw": "**Open Condenser from the sidebar (`F15.condenser-page`).** 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`).",
          "source_line": 55,
          "expected": [
            "Condenser shows its subtitle, Basic and All tabs (no Advanced), and in Basic only the **Enable Memory Condensation** switch."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--all-tier",
          "title": "All tier",
          "ids": [
            "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.`).",
          "raw": "**All tier (`F15.condenser-all-view`).** 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.`).",
          "source_line": 56,
          "expected": [
            "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`, …)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--dirty-state",
          "title": "Dirty state",
          "ids": [
            "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.",
          "raw": "**Dirty state (`F15.save-dirty-state`).** 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.",
          "source_line": 57,
          "expected": [
            "Save is disabled on load, enabled after an edit, and disabled again when the edit is reverted by hand."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--save-and-reload",
          "title": "Save and reload",
          "ids": [
            "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.",
          "raw": "**Save and reload (`F15.condenser-save`).** 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.",
          "source_line": 58,
          "expected": [
            "changing Max Size and saving shows **Saving...**, then the saved toast; after a reload the page opens on All with the new value."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--dependent-fields",
          "title": "Dependent fields",
          "ids": [
            "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`.",
          "raw": "**Dependent fields (`F15.condenser-dependents`).** 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`.",
          "source_line": 59,
          "expected": [
            "turning condensation off hides the six size/reset fields (Condenser Kind stays); turning it back on un-dirties Save."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--basic-save-keeps-hidden-fields",
          "title": "Basic save keeps hidden fields",
          "ids": [
            "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).",
          "raw": "**Basic save keeps hidden fields (`F15.view-scoped-save`).** 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).",
          "source_line": 60,
          "expected": [
            "saving from a narrower tier keeps the stored values of fields that tier does not show."
          ],
          "notes": [],
          "annotations": [
            "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)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F15--invalid-values",
          "title": "Invalid values",
          "ids": [
            "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.",
          "raw": "**Invalid values (`F15.save-errors`).** 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.",
          "source_line": 61,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--condenser-kind",
          "title": "Condenser Kind",
          "ids": [
            "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`).",
          "raw": "**Condenser Kind (`F15.condenser-kind`).** 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`).",
          "source_line": 62,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "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`)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F15--agent-context",
          "title": "Agent Context",
          "ids": [
            "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]`).",
          "raw": "**Agent Context (`F15.agent-context-page`).** 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]`).",
          "source_line": 63,
          "expected": [
            "Agent Context shows a single **Persistent Agent Memory** switch with its help text and no tab bar."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--persistent-memory-persists",
          "title": "Persistent memory persists",
          "ids": [
            "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`.",
          "raw": "**Persistent memory persists (`F15.agent-context-save`).** 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`.",
          "source_line": 64,
          "expected": [
            "toggling Persistent Agent Memory and saving persists after reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--verification",
          "title": "Verification",
          "ids": [
            "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.",
          "raw": "**Verification (`F15.verification-page`).** 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.",
          "source_line": 65,
          "expected": [
            "Verification shows Basic, Advanced and All tabs; with defaults every tier shows only **Confirmation Mode** and **Enable Critic**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--critic-and-analyzer-fields",
          "title": "Critic and analyzer fields",
          "ids": [
            "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.",
          "raw": "**Critic and analyzer fields (`F15.verification-critic-fields`).** 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.",
          "source_line": 66,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--confirmation-mode-persists",
          "title": "Confirmation mode persists",
          "ids": [
            "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.",
          "raw": "**Confirmation mode persists (`F15.confirmation-mode-save`).** 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.",
          "source_line": 67,
          "expected": [
            "Confirmation Mode and Security Analyzer (conversation settings) persist after reload, alongside the agent-settings fields on the same page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--confirmation-stops-the-agent",
          "title": "Confirmation stops the agent",
          "ids": [
            "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`).",
          "raw": "**Confirmation stops the agent (`F15.confirmation-mode-effect`).** 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`).",
          "source_line": 68,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--critic-values-persist",
          "title": "Critic values persist",
          "ids": [
            "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`.",
          "raw": "**Critic values persist (`F15.critic-save`).** 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`.",
          "source_line": 69,
          "expected": [
            "critic text fields persist after reload; the Critic API Key comes back masked; clearing the fields and turning the critic off restores the defaults."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--critic-mode-threshold-and-iterations",
          "title": "Critic mode, threshold and iterations",
          "ids": [
            "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.",
          "raw": "**Critic mode, threshold and iterations (`F15.critic-tuning`).** 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.",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--tier-follows-the-stored-values-after-a-save",
          "title": "Tier follows the stored values after a save",
          "ids": [
            "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).",
          "raw": "**Tier follows the stored values after a save (`F15.tier-after-save`).** 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).",
          "source_line": 71,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--memory-reaches-the-agent",
          "title": "Memory reaches the agent",
          "ids": [
            "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).",
          "raw": "**Memory reaches the agent (`F15.agent-context-effect`).** 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).",
          "source_line": 72,
          "expected": [
            "with Persistent Agent Memory on, a new conversation's system prompt carries the `.openhands/memory/` instructions; with it off, it does not."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--leaving-discards-edits",
          "title": "Leaving discards edits",
          "ids": [
            "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`).",
          "raw": "**Leaving discards edits (`F15.unsaved-navigation`).** 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`).",
          "source_line": 73,
          "expected": [
            "leaving a page with unsaved edits shows no prompt and discards them."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--command-menu",
          "title": "Command menu",
          "ids": [
            "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`).",
          "raw": "**Command menu (`F15.command-menu`).** 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`).",
          "source_line": 75,
          "expected": [
            "the command menu offers **Condenser settings**, **Agent Context** and **Verification settings**, and each opens its page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--direct-urls",
          "title": "Direct URLs",
          "ids": [
            "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`.",
          "raw": "**Direct URLs (`F15.direct-url`).** 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`.",
          "source_line": 76,
          "expected": [
            "each page renders when opened by URL."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--phone",
          "title": "Phone",
          "ids": [
            "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.",
          "raw": "**Phone (`F15.phone`).** 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.",
          "source_line": 77,
          "expected": [
            "at 390 px the fields stack in one column without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--schema-unavailable-f15-schema-unavailable-blocked",
          "title": "Schema unavailable (`F15.schema-unavailable`, blocked)",
          "ids": [
            "F15.schema-unavailable"
          ],
          "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.`",
          "raw": "**Schema unavailable (`F15.schema-unavailable`, blocked).** `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.`",
          "source_line": 78,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F15--errors-sweep",
          "title": "Errors sweep",
          "ids": [],
          "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).",
          "raw": "**Errors sweep.** `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).",
          "source_line": 79,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F15--background-refetch-keeps-the-draft",
          "title": "Background refetch keeps the draft",
          "ids": [
            "F15.save-dirty-state"
          ],
          "body": "With Max Size stored as `240`, run `control-openhands browser goto /settings/condenser`, `control-openhands browser click 'testid=sdk-section-all-toggle'` and `control-openhands browser fill 'testid=sdk-settings-condenser.max_size' 111`. Arrange a changed server baseline with `control-openhands api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"condenser\":{\"max_size\":300}}}'`; this API write is a precondition, not the UI proof. Run `control-openhands browser clock --offset-ms 360000` (makes the mounted query stale), `control-openhands browser network-state offline`, `control-openhands browser network-state online` and `control-openhands browser network --filter '/api/settings'`. After the real GET refetch, the unsaved `111` should remain and Save should stay enabled. Known failure (reproduced 2026-10-12, [#16948](https://github.com/OpenHands/OpenHands/issues/16948)): `control-openhands browser value 'testid=sdk-settings-condenser.max_size'` is `300` and `control-openhands browser enabled 'testid=save-button'` is `false`; the draft vanished without Save, reset or a scope change. Capture `control-openhands browser screenshot --feature F15.save-dirty-state --name refetch-discards-draft`. Restore with `control-openhands browser clock --offset-ms 0`, fill Max Size `240`, click Save and confirm `control-openhands api GET /api/settings --pick agent_settings.condenser.max_size` is `240`; networking stays online.",
          "raw": "**Background refetch keeps the draft (`F15.save-dirty-state`).** With Max Size stored as `240`, run `control-openhands browser goto /settings/condenser`, `control-openhands browser click 'testid=sdk-section-all-toggle'` and `control-openhands browser fill 'testid=sdk-settings-condenser.max_size' 111`. Arrange a changed server baseline with `control-openhands api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"condenser\":{\"max_size\":300}}}'`; this API write is a precondition, not the UI proof. Run `control-openhands browser clock --offset-ms 360000` (makes the mounted query stale), `control-openhands browser network-state offline`, `control-openhands browser network-state online` and `control-openhands browser network --filter '/api/settings'`. After the real GET refetch, the unsaved `111` should remain and Save should stay enabled. Known failure (reproduced 2026-10-12, [#16948](https://github.com/OpenHands/OpenHands/issues/16948)): `control-openhands browser value 'testid=sdk-settings-condenser.max_size'` is `300` and `control-openhands browser enabled 'testid=save-button'` is `false`; the draft vanished without Save, reset or a scope change. Capture `control-openhands browser screenshot --feature F15.save-dirty-state --name refetch-discards-draft`. Restore with `control-openhands browser clock --offset-ms 0`, fill Max Size `240`, click Save and confirm `control-openhands api GET /api/settings --pick agent_settings.condenser.max_size` is `240`; networking stays online.",
          "source_line": 81,
          "expected": [
            "Save is disabled on load, enabled after an edit, and disabled again when the edit is reverted by hand."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#16948](https://github.com/OpenHands/OpenHands/issues/16948)): `control-openhands browser value 'testid=sdk-settings-condenser.max_size'` is `300` and `control-openhands browser enabled 'testid=save-button'` is `false`; the draft vanished without Save, reset or a scope change. Capture `control-openhands browser screenshot --feature F15.save-dirty-state --name refetch-discards-draft`. Restore with `control-openhands browser clock --offset-ms 0`, fill Max Size `240`, click Save and confirm `control-openhands api GET /api/settings --pick agent_settings.condenser.max_size` is `240`; networking stays online."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F15--negative-condenser-input-floor",
          "title": "Negative condenser input floor",
          "ids": [
            "F15.save-errors"
          ],
          "body": "On `/settings/condenser` select All, fill `testid=sdk-settings-condenser.max_size` with `-5` and read `browser enabled 'testid=save-button'`. Expected by [#15813](https://github.com/OpenHands/OpenHands/issues/15813): the input prevents values below zero. Known failure, reproduced 2026-10-12: `-5` remains and Save is enabled. Save shows `Settings validation failed`, while `api GET /api/settings --pick agent_settings.condenser.max_size` remains `240`; repeat filling `-5` and read Save enabled, then fill `240` to discard the invalid draft. The server guard works; the requested client input floor does not.",
          "raw": "**Negative condenser input floor (`F15.save-errors`).** On `/settings/condenser` select All, fill `testid=sdk-settings-condenser.max_size` with `-5` and read `browser enabled 'testid=save-button'`. Expected by [#15813](https://github.com/OpenHands/OpenHands/issues/15813): the input prevents values below zero. Known failure, reproduced 2026-10-12: `-5` remains and Save is enabled. Save shows `Settings validation failed`, while `api GET /api/settings --pick agent_settings.condenser.max_size` remains `240`; repeat filling `-5` and read Save enabled, then fill `240` to discard the invalid draft. The server guard works; the requested client input floor does not.",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12: `-5` remains and Save is enabled. Save shows `Settings validation failed`, while `api GET /api/settings --pick agent_settings.condenser.max_size` remains `240`; repeat filling `-5` and read Save enabled, then fill `240` to discard the invalid draft. The server guard works; the requested client input floor does not."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F15--default-verification-tabs",
          "title": "Default Verification tabs",
          "ids": [
            "F15.verification-page",
            "F15.verification-critic-fields"
          ],
          "body": "[#15811](https://github.com/OpenHands/OpenHands/issues/15811) asks whether Basic and Advanced should differ. On defaults, snapshot `testid=verification-settings-screen` in each tab: both show only Confirmation Mode and Enable Critic. Enable Confirmation Mode without saving; `browser count 'testid=sdk-settings-security_analyzer'` is `1` on Advanced, `0` on Basic and `1` again on Advanced. Reload to discard, then repeat the default/conditional check. Verified 2026-10-12: the default sameness is the current conditional-field behavior, not evidence that the two tiers always render the same fields.",
          "raw": "**Default Verification tabs (`F15.verification-page`, `F15.verification-critic-fields`).** [#15811](https://github.com/OpenHands/OpenHands/issues/15811) asks whether Basic and Advanced should differ. On defaults, snapshot `testid=verification-settings-screen` in each tab: both show only Confirmation Mode and Enable Critic. Enable Confirmation Mode without saving; `browser count 'testid=sdk-settings-security_analyzer'` is `1` on Advanced, `0` on Basic and `1` again on Advanced. Reload to discard, then repeat the default/conditional check. Verified 2026-10-12: the default sameness is the current conditional-field behavior, not evidence that the two tiers always render the same fields.",
          "source_line": 85,
          "expected": [
            "Verification shows Basic, Advanced and All tabs; with defaults every tier shows only **Confirmation Mode** and **Enable Critic**.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F15--datetime-refreshed-on-canvas-launch",
          "title": "Datetime refreshed on Canvas launch",
          "ids": [
            "F15.agent-context-page"
          ],
          "body": "Before a new chat, read `api GET /api/settings --pick agent_settings.agent_context.current_datetime`; record this public timestamp so it can be restored. Arrange an old persisted-state timestamp through the real API (`api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"agent_context\":{\"current_datetime\":\"2001-01-01T00:00:00+00:00\"}}}'`). Start a chat through the Home composer with the dummy loopback profile, then inspect the run-owned runtime `agent.agent_context.current_datetime` and `conversation events <id> --kinds SystemPromptEvent --grep 2001-01-01 --from-start --last 1`. Expected: runtime datetime matches the new launch and the old-date event search returns zero. Restore the original settings timestamp through PATCH; no successful model reply is required to inspect initialization.",
          "raw": "**Datetime refreshed on Canvas launch (`F15.agent-context-page`).** Before a new chat, read `api GET /api/settings --pick agent_settings.agent_context.current_datetime`; record this public timestamp so it can be restored. Arrange an old persisted-state timestamp through the real API (`api PATCH /api/settings --write --data '{\"agent_settings_diff\":{\"agent_context\":{\"current_datetime\":\"2001-01-01T00:00:00+00:00\"}}}'`). Start a chat through the Home composer with the dummy loopback profile, then inspect the run-owned runtime `agent.agent_context.current_datetime` and `conversation events <id> --kinds SystemPromptEvent --grep 2001-01-01 --from-start --last 1`. Expected: runtime datetime matches the new launch and the old-date event search returns zero. Restore the original settings timestamp through PATCH; no successful model reply is required to inspect initialization.",
          "source_line": 87,
          "expected": [
            "Agent Context shows a single **Persistent Agent Memory** switch with its help text and no tab bar."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        }
      ]
    },
    {
      "id": "F16",
      "title": "Application settings",
      "file": "F16-application-settings.md",
      "sha256": "f77feb5286fbab169158011528589c5898604141cfccdce0377d965f798b611b",
      "overview": "Settings → Application holds the user's app-wide preferences: UI language,\ncolour theme, anonymous usage data, sound notifications, the sidebar Getting\nStarted checklist, which LLM profile writes conversation titles, an optional\nvoice-input transcription endpoint, and the git identity used for commits. The\npage mixes two save models: language, analytics, sound, title model and git\nidentity wait for **Save Changes** and are stored on the backend; theme,\nchecklist and voice input apply instantly and live in this browser only.",
      "entry": "- Sidebar settings gear (`backend-selector-settings-link`), then **Application** in the settings navigation (`sidebar-settings-/settings/app`). The gear lands on `/settings/agents`, not on this page.\n- Direct URL `/settings/app` (also the default settings page for locked-to-Cloud deployments).\n- Command menu (`Control+k` or `Meta+k`, or `command-menu-trigger` in the sidebar): search \"Application\", choose **Application settings** (\"Change app language, theme, and preferences\").\n- **Manage LLM profiles** link under Conversation titles leaves this page for `/settings/llm` (F16.manage-profiles-link); the LLM settings row menu is how a user deletes a profile (owned by the LLM profiles family).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done; it stores analytics off, so the analytics switch starts unchecked).\n- `F16.title-model`, `F16.title-model-fallback` and the agent half of `F16.git-identity` need LLM profiles: `control-openhands llm preset deepseek --api-key-file <key file>` (or `--api-key-env DEEPSEEK_API_KEY`), which saves `deepseek-flash` (active) and `deepseek-pro`.\n- Read the starting values once: `control-openhands api GET /api/settings`; backend-saved fields are under `body.misc_settings.app_preferences` (`language`, `user_consents_to_analytics`, `enable_sound_notifications`, `title_llm_profile`, `git_user_name`, `git_user_email`). Keys absent there mean defaults.\n- The three switches are visually hidden native checkboxes inside a `<label>`: click the label text scoped to the form or focus them with Tab and toggle with Space, and read state with `control-openhands browser eval \"document.querySelector('[data-testid=enable-analytics-switch]').checked\"` (same for `enable-sound-notifications-switch`, `show-getting-started-checklist-switch`).\n- Every bullet starts on `/settings/app` at the desktop viewport unless it says otherwise (`control-openhands browser goto /settings/app`, then `control-openhands browser wait 'testid=submit-button'`).\n- `F16.analytics-cloud` is **blocked** without an OpenHands Cloud backend and account.",
      "gotchas": "- Two save models on one form: theme, checklist and voice input apply instantly and ignore Save; the rest is dropped silently if you navigate away before Save (filling `git-user-name-input`, then clicking `sidebar-settings-/settings/secrets` and back, shows the stored `openhands` again; `browser dialogs` is empty). Assert the instant ones without clicking Save, and never expect Save to enable for them.\n- The success toast uses the language that was active when you clicked Save, not the one you saved. In `F16.language` the French Save toasts `Settings saved`, the Arabic Save toasts `Paramètres enregistrés`, and the English restore toasts `تم حفظ الإعدادات`, so `wait-text 'Settings saved'` times out on the last two. Assert with `browser text 'testid=submit-button'` after a reload, or read the toast with `browser toasts`.\n- Some chrome strings stay English after a language switch: the sidebar **Automate** label in every language (the automation interface supplies it, not the translation file), and most of the command menu's own texts (placeholder `Search actions, pages, and settings...`, the search box's `Search commands` name, the group names and the item names) in French and 11 other languages. Only the Model Router and Agent Context items are translated (`Routeur de modèles` and `Contexte de l'agent` in French), because they reuse the settings navigation's titles. Do not treat the English ones as a language switch failure; the sidebar trigger's `Search commands` is translated.\n- In Arabic (`dir=\"rtl\"`) a switch that is on draws its thumb outside its track (on `show-getting-started-checklist-switch` the track spans `x` 778–818 and the thumb 822–838). Assert switch state with `.checked`, not from a screenshot.\n- The switches are visually hidden native checkboxes: Use the label as the pointer target. Click the label text scoped to `testid=app-settings-screen`, or use Tab/Space, and read `.checked` through `eval`.\n- `role=option[name^=...]` is not supported by the selector parser; use `role=option >> has-text=<profile>` for profile options whose names include the model.\n- `conversation start` leaves the browser on the conversation page, which also has a `testid=submit-button`; `goto /settings/app` before the next settings step or clicks hit the composer.\n- Which profile actually generated a title is not visible in the UI, the conversation API or the stack logs; `F16.title-model` proves the preference is stored and a title appears, not which model wrote it.\n- `onboard --skip` stores analytics off, so a fresh run starts with the switch unchecked even though the code defaults to on when the preference is missing.\n- `browser storage` lists `localStorage`, and `browser storage --session` lists `sessionStorage`, where the voice-input key lives. With `--values` that key prints as `<redacted>`, so the recipe reads its dummy value with `eval`. The key disappears in a new tab or browser session by design.\n- Deleting a profile through the LLM settings UI clears a matching title preference itself; only a profile removed outside the UI leaves a stale `title_llm_profile` that this page shows as Automatic.\n- The Title generation model combobox has a clear (×) button with no accessible name (`- button` in the snapshot, visible as × at phone width). At desktop width it only takes clicks after `browser hover` on the input (`F16.title-model-clear`); otherwise use the Automatic option.\n- `browser eval` wraps its argument as an expression: statements separated by `;` fail with `SyntaxError: Unexpected token ';'`; wrap them in `(() => { ...; return x; })()`.\n- With `service stop agent-server`, a `browser reload` of `/settings/app` shows the backend-unavailable screen (`agent-server-onboarding-screen`, Manage backends), not the form: stop the service only after the page is loaded, and `restart` afterwards.\n- Known product bugs (repro candidates): git identity never reaches the agent (#17899); a failed save discards the user's edits (`F16.save-states`) (#17927).\n- The harness builds with `VITE_DO_NOT_TRACK=1`, so PostHog never starts: `browser network --filter 'posthog|z\\.openhands\\.dev'` reports `total` `0`, also after analytics consent is granted (#18086). A browser-only Do Not Track setting is outside this recipe. Consent reversal requires the explicit non-DNT build and loopback-only analytics configuration described above, served with `launch --build never` (`F16.analytics`).\n- The title model's clear button has no test id: the recipe's xpath relies on DOM nesting to skip the unnamed `Show suggestions` button. If both ever match, Playwright's strict mode fails the click instead of pressing the wrong one; report it as harness drift.",
      "features": [
        {
          "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 server's message (`An error occurred` when it has none) 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.backend-switch",
          "description": "switching the active backend in the backend selector while this page is open shows the new backend's stored preferences (the footer already names it), also when switching back to a backend the page has already loaded; an unsaved edit is dropped on switch."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F16--open-from-navigation",
          "title": "Open from navigation",
          "ids": [
            "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]`.",
          "raw": "**Open from navigation (`F16.open`).** 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]`.",
          "source_line": 54,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--command-menu-and-url-entries",
          "title": "Command-menu and URL entries",
          "ids": [
            "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`).",
          "raw": "**Command-menu and URL entries (`F16.open`).** 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`).",
          "source_line": 55,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--language",
          "title": "Language",
          "ids": [
            "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`.",
          "raw": "**Language (`F16.language`).** 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`.",
          "source_line": 56,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--colour-theme",
          "title": "Colour theme",
          "ids": [
            "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`).",
          "raw": "**Colour theme (`F16.color-theme`).** 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`).",
          "source_line": 57,
          "expected": [
            "choosing a colour theme applies it immediately without Save, persists after a reload and on other pages, and is never sent to the backend."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--analytics-consent",
          "title": "Analytics consent",
          "ids": [
            "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`.",
          "raw": "**Analytics consent (`F16.analytics`).** 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`.",
          "source_line": 58,
          "expected": [
            "the \"Send anonymous usage data\" switch saves with the form and sets browser telemetry consent to granted/denied."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--cloud-forces-analytics",
          "title": "Cloud forces analytics",
          "ids": [
            "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.",
          "raw": "**Cloud forces analytics (`F16.analytics-cloud`).** 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.",
          "source_line": 59,
          "expected": [
            "on a Cloud backend the analytics switch is forced on and disabled."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F16--consent-reversal-with-an-in-flight-automation-write",
          "title": "Consent reversal with an in-flight Automation write",
          "ids": [
            "F16.analytics"
          ],
          "body": "With a non-DNT build using a dummy PostHog key and loopback-only `VITE_POSTHOG_HOST` (no external analytics delivery), save opt-in and confirm its consent POST returned 200. Run `service pause automation`, save opt-out then opt-in through the same switch, and `service resume automation` before the five-second request timeout. Record the UI transitions and read `browser network --filter 'telemetry/consent' --bodies`; the final granted value must be posted and acknowledged. Resume before `stop`. The default hard-DNT build cannot exercise this reversal.",
          "raw": "**Consent reversal with an in-flight Automation write (`F16.analytics`).** With a non-DNT build using a dummy PostHog key and loopback-only `VITE_POSTHOG_HOST` (no external analytics delivery), save opt-in and confirm its consent POST returned 200. Run `service pause automation`, save opt-out then opt-in through the same switch, and `service resume automation` before the five-second request timeout. Record the UI transitions and read `browser network --filter 'telemetry/consent' --bodies`; the final granted value must be posted and acknowledged. Resume before `stop`. The default hard-DNT build cannot exercise this reversal.",
          "source_line": 60,
          "expected": [
            "the \"Send anonymous usage data\" switch saves with the form and sets browser telemetry consent to granted/denied."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--sound-notifications",
          "title": "Sound notifications",
          "ids": [
            "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).",
          "raw": "**Sound notifications (`F16.sound`).** 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).",
          "source_line": 62,
          "expected": [
            "the \"Sound Notifications\" switch saves with the form; with it on, a sound plays when the agent finishes or needs input."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--getting-started-checklist",
          "title": "Getting Started checklist",
          "ids": [
            "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`.",
          "raw": "**Getting Started checklist (`F16.checklist`).** 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`.",
          "source_line": 63,
          "expected": [
            "the \"Show Getting Started checklist\" switch hides or shows the sidebar checklist immediately, without Save, and survives a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--title-generation-model",
          "title": "Title generation model",
          "ids": [
            "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`.",
          "raw": "**Title generation model (`F16.title-model`).** 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`.",
          "source_line": 64,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--manage-llm-profiles-link",
          "title": "Manage LLM profiles link",
          "ids": [
            "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.",
          "raw": "**Manage LLM profiles link (`F16.manage-profiles-link`).** 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.",
          "source_line": 65,
          "expected": [
            "\"Manage LLM profiles\" goes to `/settings/llm`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--deleted-profile-falls-back",
          "title": "Deleted profile falls back",
          "ids": [
            "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.",
          "raw": "**Deleted profile falls back (`F16.title-model-fallback`).** 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.",
          "source_line": 66,
          "expected": [
            "if the chosen profile is deleted (from LLM settings, or outside the UI), the dropdown shows Automatic and the form stays clean."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--voice-input-endpoint",
          "title": "Voice input endpoint",
          "ids": [
            "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).",
          "raw": "**Voice input endpoint (`F16.voice-endpoint`).** 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).",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--git-identity",
          "title": "Git identity",
          "ids": [
            "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.",
          "raw": "**Git identity (`F16.git-identity`).** 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.",
          "source_line": 68,
          "expected": [
            "Git Username and Git Email save with the form, persist after a reload, and are what the agent's commits use."
          ],
          "notes": [],
          "annotations": [
            "Known failure:** the observation is `0 Author identity unknown ...`: the identity is stored only in `misc_settings.app_preferences` and never reaches the agent."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F16--git-validation-and-defaults",
          "title": "Git validation and defaults",
          "ids": [
            "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.",
          "raw": "**Git validation and defaults (`F16.git-identity-validation`).** 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.",
          "source_line": 69,
          "expected": [
            "an email without `@` is refused by native validation; empty fields save the defaults `openhands` / `openhands@all-hands.dev`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--save-button-states",
          "title": "Save button states",
          "ids": [
            "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 'An error occurred'` (after three `PATCH /api/settings` attempts answer `502`, `browser network --filter /api/settings` shows them; the ingress's plain-text 502 body carries no server message, so the toast is the generic one, #18051), 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`.",
          "raw": "**Save button states (`F16.save-states`).** 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 'An error occurred'` (after three `PATCH /api/settings` attempts answer `502`, `browser network --filter /api/settings` shows them; the ingress's plain-text 502 body carries no server message, so the toast is the generic one, #18051), 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`.",
          "source_line": 70,
          "expected": [
            "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 server's message (`An error occurred` when it has none) and should keep the user's edits for a retry."
          ],
          "notes": [],
          "annotations": [
            "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`."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F16--keyboard-and-screen-readers",
          "title": "Keyboard and screen readers",
          "ids": [
            "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`. The first Tab lands on `enable-analytics-switch`, followed by `enable-sound-notifications-switch` and `show-getting-started-checklist-switch`; Space toggles the focused checkbox. `control-openhands browser snapshot 'testid=app-settings-screen' --feature F16.keyboard --name aria` exposes each checkbox label and checked state. The label draws a visible focus outline.\n\n  ```sh\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/app\n  control-openhands browser wait 'testid=submit-button'\n  control-openhands browser record start --feature F16.keyboard --name keyboard-desktop\n  control-openhands browser focus 'testid=color-theme-input'\n  control-openhands browser press Tab\n  control-openhands browser press Escape\n  control-openhands browser screenshot --feature F16.keyboard --name keyboard-desktop\n  control-openhands browser record stop --gif\n  control-openhands assert --field value --equals enable-analytics-switch -- browser eval \"document.activeElement.getAttribute('data-testid')\"\n  ```\n\n  ```sh\n  control-openhands browser viewport phone\n  control-openhands browser goto /settings/app\n  control-openhands browser wait 'testid=submit-button'\n  control-openhands browser record start --feature F16.keyboard --name keyboard-phone\n  control-openhands browser focus 'testid=color-theme-input'\n  control-openhands browser press Tab\n  control-openhands browser press Escape\n  control-openhands browser screenshot --feature F16.keyboard --name keyboard-phone\n  control-openhands browser record stop --gif\n  control-openhands assert --field value --equals enable-analytics-switch -- browser eval \"document.activeElement.getAttribute('data-testid')\"\n  ```",
          "raw": "**Keyboard and screen readers (`F16.keyboard`).** 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`. The first Tab lands on `enable-analytics-switch`, followed by `enable-sound-notifications-switch` and `show-getting-started-checklist-switch`; Space toggles the focused checkbox. `control-openhands browser snapshot 'testid=app-settings-screen' --feature F16.keyboard --name aria` exposes each checkbox label and checked state. The label draws a visible focus outline.\n\n  ```sh\n  control-openhands browser viewport desktop\n  control-openhands browser goto /settings/app\n  control-openhands browser wait 'testid=submit-button'\n  control-openhands browser record start --feature F16.keyboard --name keyboard-desktop\n  control-openhands browser focus 'testid=color-theme-input'\n  control-openhands browser press Tab\n  control-openhands browser press Escape\n  control-openhands browser screenshot --feature F16.keyboard --name keyboard-desktop\n  control-openhands browser record stop --gif\n  control-openhands assert --field value --equals enable-analytics-switch -- browser eval \"document.activeElement.getAttribute('data-testid')\"\n  ```\n\n  ```sh\n  control-openhands browser viewport phone\n  control-openhands browser goto /settings/app\n  control-openhands browser wait 'testid=submit-button'\n  control-openhands browser record start --feature F16.keyboard --name keyboard-phone\n  control-openhands browser focus 'testid=color-theme-input'\n  control-openhands browser press Tab\n  control-openhands browser press Escape\n  control-openhands browser screenshot --feature F16.keyboard --name keyboard-phone\n  control-openhands browser record stop --gif\n  control-openhands assert --field value --equals enable-analytics-switch -- browser eval \"document.activeElement.getAttribute('data-testid')\"\n  ```",
          "source_line": 71,
          "expected": [
            "every control, including the three switches, is reachable with Tab and exposed to assistive technology."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--phone-layout",
          "title": "Phone layout",
          "ids": [
            "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`.",
          "raw": "**Phone layout (`F16.phone`).** 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`.",
          "source_line": 98,
          "expected": [
            "the form fits a 390 px viewport without horizontal overflow and Save is reachable by scrolling."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--clear-the-title-model",
          "title": "Clear the title model",
          "ids": [
            "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`).",
          "raw": "**Clear the title model (`F16.title-model-clear`).** 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`).",
          "source_line": 99,
          "expected": [
            "the Title generation model combobox has a clear (×) button, shown on hover; it resets the choice to Automatic (recommended)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--filter-a-dropdown-by-typing",
          "title": "Filter a dropdown by typing",
          "ids": [
            "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`.",
          "raw": "**Filter a dropdown by typing (`F16.dropdown-filter`).** 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`.",
          "source_line": 100,
          "expected": [
            "typing into the Language (or any) combobox filters its options; Escape restores the stored value and leaves Save disabled."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--browser-vs-backend-scope",
          "title": "Browser vs backend scope",
          "ids": [
            "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`.",
          "raw": "**Browser vs backend scope (`F16.browser-scope`).** 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`.",
          "source_line": 101,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--backend-switch-in-place",
          "title": "Backend switch in place",
          "ids": [
            "F16.backend-switch"
          ],
          "body": "Needs a second backend with a different Git username: the second stack from F25's Preconditions added as `QA_Second` (F25 Add bullet), arranged with `control-openhands api PATCH /api/settings --run \"$OH_VERIFY_RUN_2\" --write --data '{\"misc_settings_diff\":{\"app_preferences\":{\"git_user_name\":\"QA Second Git\"}}}'`. With Local active, run `control-openhands browser goto /settings/app` and `control-openhands browser reload`; `browser value 'testid=git-user-name-input'` is `openhands`. Switch without leaving the page: `control-openhands 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`, wait a few seconds: the value is `QA Second Git` and `browser text 'testid=backend-synced-settings-badge'` names QA_Second (a first visit to a backend loads fresh). Switch back the same way to `Connected Local`: the value is `openhands` beside `synced from Local backend`, and it keeps following the active backend on further switches. The form remounts per backend, so an unsaved edit is dropped: on Local run `control-openhands browser fill 'testid=git-user-name-input' 'QA Local Edit'` (`browser enabled 'testid=submit-button'` is `true`), switch to `QA_Second` (`QA Second Git`, Save `false`) and back (`openhands`, Save `false`); `control-openhands api GET /api/settings --pick misc_settings.app_preferences.git_user_name` is unchanged. At `browser viewport phone` the selector is in the drawer (`testid=sidebar-mobile-menu-toggle`, then `'testid=sidebar-mobile-drawer >> testid=backend-selector'` and `role=option[name=\"Connected QA_Second\"]`; close the drawer with `browser mouse-click 370 400`) and the field follows the same way. Switch back to Local and remove `QA_Second` afterwards (F25).",
          "raw": "**Backend switch in place (`F16.backend-switch`).** Needs a second backend with a different Git username: the second stack from F25's Preconditions added as `QA_Second` (F25 Add bullet), arranged with `control-openhands api PATCH /api/settings --run \"$OH_VERIFY_RUN_2\" --write --data '{\"misc_settings_diff\":{\"app_preferences\":{\"git_user_name\":\"QA Second Git\"}}}'`. With Local active, run `control-openhands browser goto /settings/app` and `control-openhands browser reload`; `browser value 'testid=git-user-name-input'` is `openhands`. Switch without leaving the page: `control-openhands 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`, wait a few seconds: the value is `QA Second Git` and `browser text 'testid=backend-synced-settings-badge'` names QA_Second (a first visit to a backend loads fresh). Switch back the same way to `Connected Local`: the value is `openhands` beside `synced from Local backend`, and it keeps following the active backend on further switches. The form remounts per backend, so an unsaved edit is dropped: on Local run `control-openhands browser fill 'testid=git-user-name-input' 'QA Local Edit'` (`browser enabled 'testid=submit-button'` is `true`), switch to `QA_Second` (`QA Second Git`, Save `false`) and back (`openhands`, Save `false`); `control-openhands api GET /api/settings --pick misc_settings.app_preferences.git_user_name` is unchanged. At `browser viewport phone` the selector is in the drawer (`testid=sidebar-mobile-menu-toggle`, then `'testid=sidebar-mobile-drawer >> testid=backend-selector'` and `role=option[name=\"Connected QA_Second\"]`; close the drawer with `browser mouse-click 370 400`) and the field follows the same way. Switch back to Local and remove `QA_Second` afterwards (F25).",
          "source_line": 102,
          "expected": [
            "switching the active backend in the backend selector while this page is open shows the new backend's stored preferences (the footer already names it), also when switching back to a backend the page has already loaded; an unsaved edit is dropped on switch."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F16--after-each-group",
          "title": "After each group",
          "ids": [],
          "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.",
          "raw": "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.",
          "source_line": 103,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F16--hard-do-not-track",
          "title": "Hard Do Not Track",
          "ids": [
            "F16.analytics"
          ],
          "body": "With the harness build's `VITE_DO_NOT_TRACK=1`, open `/settings/app`, read `browser network --external --filter 'posthog|z.openhands.dev'` and `browser storage`. Grant analytics through the real switch and Save, reload, inspect those again, then revoke and Save to restore. Expected: zero PostHog requests and no PostHog storage key, including after opt-in. This checks the build flag; browser-only DNT and builds without it have separate paths.",
          "raw": "**Hard Do Not Track (`F16.analytics`).** With the harness build's `VITE_DO_NOT_TRACK=1`, open `/settings/app`, read `browser network --external --filter 'posthog|z.openhands.dev'` and `browser storage`. Grant analytics through the real switch and Save, reload, inspect those again, then revoke and Save to restore. Expected: zero PostHog requests and no PostHog storage key, including after opt-in. This checks the build flag; browser-only DNT and builds without it have separate paths.",
          "source_line": 105,
          "expected": [
            "the \"Send anonymous usage data\" switch saves with the form and sets browser telemetry consent to granted/denied."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        }
      ]
    },
    {
      "id": "F17",
      "title": "Customize hub and MCP servers",
      "file": "F17-mcp-servers.md",
      "sha256": "0e587211f6789aadd8e3ba3f6ea6823ed87640df12d8f0fbb1fe6868afb3b21f",
      "overview": "Customize groups MCP Servers, Skills, Plugins and Apps behind one sidebar\nentry. From 1024 px up `/customize` goes straight to `/mcp`; below that (phones\nand tablets) it is a list hub. At tablet widths (768–1023 px), each Customize subpage also has a compact selector for switching directly between siblings. The MCP page lets a user browse a library of catalog\nintegrations, install one through a modal (credentials masked, optionally also\nsaved as secrets), hand-author custom SSE, Streamable HTTP or STDIO servers,\ntest connections, check each installed server's health, enable or disable,\nedit and delete servers. Enabled servers are offered to every new\nconversation's agent.",
      "entry": "- Sidebar **Customize** (`sidebar-skills-link`); on desktop it lands on `/mcp`. On a phone, open the drawer (`sidebar-mobile-menu-toggle`) first; it lands on the hub.\n- Direct URLs `/customize` and `/mcp`.\n- Command menu (`Control+k`/`Meta+k`): **Customize** (to `/customize`, then `/mcp`) and **MCP servers** (to `/mcp`). The menu itself is F02.\n- Customize sub-navigation row **MCP Servers** (`sidebar-extensions-/mcp`), desktop aside or phone hub. Pinning Customize as home is F02.\n- The onboarding checklist item **Connect an MCP integration** links to `/mcp` and is ticked once a server is configured, before any conversation uses it (F02).\n- On `/mcp`: a library card or its + toggle (install modal), **Add custom server** (`mcp-add-custom-server`), an installed card (editor) and its health-row buttons.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) on a fresh run, so nothing is installed yet (`control-openhands api GET /api/settings --pick agent_settings.mcp_config` is `{}`).\n- `uvx` and `npx` on the machine. `F17.agent-uses-server`, `F17.custom-add-remote`'s agent check and the disabled-server check need an active LLM profile (`control-openhands llm preset deepseek`).\n- TLS-intercepting proxy (this sandbox): MCP stdio servers start with a minimal environment (`HOME`, `PATH`, no proxy or CA variables), so without trust settings `uvx`/`npx` cannot download packages and catalog STDIO installs fail. `launch` now seeds the run's private `HOME` when `SSL_CERT_FILE` or `NODE_EXTRA_CA_CERTS` points at a CA bundle: check with `cat \"$OH_VERIFY_RUN/private/home/.config/uv/uv.toml\"` (`native-tls = true`) and `cat \"$OH_VERIFY_RUN/private/home/.npmrc\"` (`cafile=<bundle>`). Only if they are missing, write them once: `mkdir -p \"$OH_VERIFY_RUN/private/home/.config/uv\"`, `printf 'native-tls = true\\n' > \"$OH_VERIFY_RUN/private/home/.config/uv/uv.toml\"` and `printf 'cafile=/root/.ccr/ca-bundle.crt\\n' > \"$OH_VERIFY_RUN/private/home/.npmrc\"` (use your proxy's CA bundle path). `F17.install-error` moves them aside for one step. Custom servers can also pass `UV_NATIVE_TLS=true` in their env. Skip all of this on a machine with direct internet.\n- `F17.custom-add-remote` needs a local Streamable HTTP MCP server (arrange, not proof; no `fixture` verb exists for it yet): from a shell, `cd \"$OH_VERIFY_RUN/workspace\" && (PORT=38417 nohup npx -y @modelcontextprotocol/server-everything streamableHttp > \"$OH_VERIFY_RUN/private/qa-shttp.log\" 2>&1 &)`, pick any free port, and wait until the log says `MCP Streamable HTTP Server listening on port 38417`. Stop it in Cleanup by its own PID (`pgrep -f 'server-everything streamableHttp'` lists it; never `pkill -f`).\n- `F17.cloud-nav` and `F17.native-git-tabs` need an OpenHands Cloud backend (with git providers configured); not available here.\n- `F17.stored-secret-reuse` and `F17.single-request-mutations` use `control-openhands fixture mcp-server --name qa-vault`; it prints the `<node>` binary and `<server.mjs>` path the recipes substitute.\n- `F17.credential-probe` sends a dummy token to GitHub's hosted MCP endpoint and, through the catalog's npx stdio server, to Slack; both need the network. Behind the TLS-intercepting proxy the stdio server's own HTTPS call fails unless it gets the proxy CA (`NODE_EXTRA_CA_CERTS`), which only the custom editor can pass.\n- Never type real credentials: every key below is a dummy.",
      "gotchas": "- Server errors on MCP mutations show one toast with the server's message, or `An error occurred` when the body has none. A custom server's **Save Server** or **Add Server** action whose pre-save connection test fails keeps the editor open. To check backend loss, stop the owned Agent Server with the editor open, click Save, then restore it with `control-openhands restart`; background settings polls can add separate error toasts, so count only the toast caused by the click. A failed add, edit or delete is retried three times (`withRetry` in `settings-service.api.ts`, 0.5 s and 1 s apart) before its single toast; that retry behavior is open [#17932](https://github.com/OpenHands/OpenHands/issues/17932).\n- Desktop `/customize` is a client-side redirect: `browser url` may still read `/customize` for a moment; use `wait-url '/mcp(\\?|$)'`.\n- From 768 to 1023 px the sidebar rail stays and the phone top bar is gone. The compact Customize selector above each detail page provides sibling navigation; the rail's **Customize** link (`sidebar-skills-link`, unscoped at this width) still returns to the hub.\n- On a phone the drawer and the hidden desktop rail both render `sidebar-skills-link`; scope it with `testid=sidebar-mobile-drawer >> ...` or the click fails with a strict-mode error, even though `browser testids` lists it once.\n- Editor dropdowns are comboboxes: click `server-type-dropdown` or `auth-mode-dropdown`, then `role=option[name=\"...\"]`. Server types read `SSE`, `STDIO` and `SHTTP` (not \"Streamable HTTP\"); auth modes read `None`, `Bearer token`, `Header` and `OAuth`. The type dropdown exists only when adding.\n- The timeout field is `type=\"number\"`: `browser fill` refuses letters, so `Timeout must be a valid number` cannot be reached from the UI.\n- A red test message stays on screen when a later validation error appears, so `css=p.text-feedback-error` can match two paragraphs; take `>> nth=0` (the validation error comes first).\n- Install-modal required fields use native browser validation (a tooltip, no paragraph): assert `validationMessage`.\n- The installed and library toggles are `role=switch` buttons: read `aria-checked`, not `aria-pressed`.\n- MCP stdio servers start with a minimal environment. Behind a TLS-intercepting proxy, without trust settings in the run's private `HOME`, `uvx` catalog installs fail with `Connection closed` (`<run>/private/stack.log` shows `UnknownIssuer`) and `npx` ones with `Connection timed out`. That is the sandbox, not the product. `launch` now seeds the trust files from `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS`, so a fresh run installs fine and `F17.install-error` must move them aside; once a package is in the run's uv cache it installs even without them.\n- An installed card's health row swallows clicks (so the probe button does not open the editor). On a card without a catalog description (`qa_broken`) that row is the card's centre, so a plain card click opens nothing; a catalog description moves it down. Open the editor by the card's name or `mcp-server-detail-<id>` line, or focus the card and press Enter.\n- `conversation start` leaves the browser on the conversation page; every MCP-page command after it needs `browser goto /mcp` first, or the per-card selectors silently wait on a page that has none.\n- `@modelcontextprotocol/server-everything sse` printed `Server is running` here but never answered on its port; use the `streamableHttp` mode for the remote-server fixture.\n- A server without a catalog credential probe reads `Reachable — credentials not verified`, never `Connected — credentials verified (N tool(s))`, even without credentials. Health after a save is seeded from the pre-save test and is lost on reload (`Not checked yet`).\n- Adding a custom server shows no toast; only catalog installs and deletes toast. Assert the card after reload.\n- Catalog installs are add-only: a second Time install becomes `time_1`. STDIO servers match the catalog by command plus leading args rather than name, so `time_1` and a custom server running a catalog command under its own name (`qa_time`) retain the description, badge and search keywords. A server that only borrows a catalog name does not (`F17.install-stdio`, `F17.custom-add`). Twelve provider-OAuth-only library entries, such as Sentry, HubSpot and Cloudflare OAuth, open an empty modal whose Install does nothing (`F17.install-unsupported`, [#17922](https://github.com/OpenHands/OpenHands/issues/17922)).\n- The section filter's trigger and menu are named `Filter MCP servers`.\n- Header and environment-variable format errors have distinct copy. `F17.custom-validation` expects `Headers must follow KEY=value format` for headers and `Environment variables must follow KEY=value format` for env lines. Feedback uses readable theme classes `text-feedback-error` and `text-feedback-success`.\n- A catalog stdio server runs with the Agent Server's minimal environment: behind a TLS-intercepting proxy its own HTTPS calls fail (`fetch failed`) even when npx could download it, and the install modal has no field for `NODE_EXTRA_CA_CERTS`; use the custom editor with the same command to see the provider's real answer (`F17.credential-probe`). The `Credential check failed: fetch failed` wording itself is #18161: the Slack interpreter turns any `is_error` tool result into a credential failure, though the server connected and listed its tools.\n- GitHub's hosted MCP endpoint answers an invalid token with HTTP 401 at connect, before tools are listed, so its `get_me` probe never runs and the modal shows the connection wording (`Connection failed: HTTPStatusError … 401 Unauthorized`), not `Credential check failed`; filed as [OpenHands/software-agent-sdk#5607](https://github.com/OpenHands/software-agent-sdk/issues/5607), since the test endpoint reports the 401 as `error_kind` `unknown`.\n- The shell-gated `qa_vault` fixture exits before the MCP handshake, which the Agent Server reports either at once as `Connection failed: McpError: Connection closed` or only at its deadline as `Connection timed out. Check the URL and try again.`; neither is the `Could not start the server command` wording, which needs a command that cannot be spawned at all. Wait on `text=Connection`, which matches both and not `Connected — …`.\n- `browser network --bodies` shows what the page sent, never a secret: a value under a key that names a credential (key, token, secret, auth, pass, session, sig, credential) and every value of an `env` or `headers` map becomes `<redacted N chars>` (N the real length), and a URL in any other string (a server's `url`) keeps its host and path but loses its userinfo and the values of its secret-named query parameters (`https://<redacted 2 chars>:<redacted 8 chars>@host/mcp?api_key=<redacted 17 chars>`), while the settings API's `**********` placeholder is kept as is, so a row tells \"placeholder sent\" from \"a value sent\". The MCP editor sends neither for an untouched secret: Test connection swaps the placeholder for the stored encrypted value (a `GET /api/settings` in `encrypted` mode first; Fernet tokens, so 120 chars for a 19-char token, 140 for 40, 268 for 142) and Save leaves `env` out of its sparse PATCH altogether (`F17.stored-secret-reuse`). Without `--bodies` no row carries a `body` field, and only app-origin writes keep one.\n- A browser verb's client call waits 120 s, or 10 s longer than the verb's own `--timeout` or `--observe-ms` when that is longer, so `browser wait 'testid=mcp-install-modal-error' --timeout 150000` runs its full deadline and a miss ends with Playwright's timeout and a failure screenshot (before this, every call was aborted at 120 s with `TimeoutError: The operation was aborted due to timeout` while the page kept going). For a first Slack install use a plain click, then that `wait`; `--observe-ms` is for a few transient states, not a minute-long outcome.\n- Known issue #17941: turning off an enabled card toggle can drop the click when the pointer moves in and presses at once (`F17.enable-disable`).\n- Known issue [#15421](https://github.com/OpenHands/OpenHands/issues/15421): A configured Linear MCP can be ignored while an explicitly disabled Linear skill is used instead. The new-chat and automation paths need a working authorized Linear MCP connection, a concrete issue ID and an approved model; the report's automation ZIP is also needed to replay its exact automation settings. (`F17.agent-uses-server`).\n- Known issue [#15430](https://github.com/OpenHands/OpenHands/issues/15430): An OAuth MCP callback in Docker can redirect to an unexposed dynamic localhost port. Completing the callback needs a Docker stack and an approved OAuth MCP provider/account (the report uses GitLab); a local unauthenticated Test connection cannot reach the redirect. (`F17.install-oauth`).\n- Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`).",
      "features": [
        {
          "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.tablet-selector",
          "description": "at 768–1023 px, the compact Customize selector exposes the same MCP, Skills, Plugins and Apps links as the desktop nav; it follows Cloud visibility/external-link rules, dismisses on selection/outside click/Escape, and supports Tab navigation. It is absent on phones, desktops and the hub."
        },
        {
          "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); label clicks and Space change the native checkbox, while its help button leaves the choice unchanged. The selection follows the colour theme and long secret names fit on phones. 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). Malformed header lines use a header-specific message, distinct from environment-variable errors."
        },
        {
          "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](https://github.com/OpenHands/software-agent-sdk/issues/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."
        }
      ],
      "scenarios": [
        {
          "name": "F17--desktop-entry-points",
          "title": "Desktop entry points",
          "ids": [
            "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`.",
          "raw": "**Desktop entry points (`F17.customize-redirect`).** 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`.",
          "source_line": 70,
          "expected": [
            "on desktop, the sidebar **Customize** link, the URL `/customize` and the command-menu items **Customize** and **MCP servers** all land on `/mcp`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--desktop-sub-navigation",
          "title": "Desktop sub-navigation",
          "ids": [
            "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.",
          "raw": "**Desktop sub-navigation (`F17.desktop-subnav`).** 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.",
          "source_line": 71,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--tablet-selector",
          "title": "Tablet selector",
          "ids": [
            "F17.tablet-selector"
          ],
          "body": "Run `control-openhands browser viewport tablet`, `control-openhands browser goto /mcp`, and `control-openhands browser click 'testid=extensions-compact-navigation >> role=button'`. `control-openhands browser snapshot 'testid=extensions-compact-navigation >> role=navigation'` lists MCP Servers (current), Skills, Plugins and Apps. `control-openhands browser click 'testid=extensions-compact-navigation >> testid=sidebar-extensions-/skills' --expect-url '/skills$'` navigates directly and closes the list. Repeat from Skills to Plugins, Apps and MCP; the selector's current label follows each route. Capture `browser screenshot --feature F17.tablet-selector --name customize`. Check 768 and 1023 px, then 767 and 1024 px where `browser count 'testid=extensions-compact-navigation'` is `0`. Escape after keyboard opening returns focus to the trigger. The same links have `sidebar-extensions-*` IDs in the hidden desktop nav, so scope every tablet action to `extensions-compact-navigation`.",
          "raw": "**Tablet selector (`F17.tablet-selector`).** Run `control-openhands browser viewport tablet`, `control-openhands browser goto /mcp`, and `control-openhands browser click 'testid=extensions-compact-navigation >> role=button'`. `control-openhands browser snapshot 'testid=extensions-compact-navigation >> role=navigation'` lists MCP Servers (current), Skills, Plugins and Apps. `control-openhands browser click 'testid=extensions-compact-navigation >> testid=sidebar-extensions-/skills' --expect-url '/skills$'` navigates directly and closes the list. Repeat from Skills to Plugins, Apps and MCP; the selector's current label follows each route. Capture `browser screenshot --feature F17.tablet-selector --name customize`. Check 768 and 1023 px, then 767 and 1024 px where `browser count 'testid=extensions-compact-navigation'` is `0`. Escape after keyboard opening returns focus to the trigger. The same links have `sidebar-extensions-*` IDs in the hidden desktop nav, so scope every tablet action to `extensions-compact-navigation`.",
          "source_line": 72,
          "expected": [
            "at 768–1023 px, the compact Customize selector exposes the same MCP, Skills, Plugins and Apps links as the desktop nav; it follows Cloud visibility/external-link rules, dismisses on selection/outside click/Escape, and supports Tab navigation. It is absent on phones, desktops and the hub."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--phone-hub",
          "title": "Phone hub",
          "ids": [
            "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`.",
          "raw": "**Phone hub (`F17.mobile-hub`, `F17.phone`).** 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`.",
          "source_line": 73,
          "expected": [
            "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`.",
            "the MCP page, the install modal and the custom editor fit a 390 px viewport."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--widths-around-the-breakpoint",
          "title": "Widths around the breakpoint",
          "ids": [
            "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`.",
          "raw": "**Widths around the breakpoint (`F17.breakpoint-sweep`).** 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`.",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--page-and-empty-state",
          "title": "Page and empty state",
          "ids": [
            "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-\"]'` (`56` with `@openhands/extensions` 0.30.0, #18336, which added an installable Bitbucket card; `55` before; 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`.",
          "raw": "**Page and empty state (`F17.page-states`).** 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-\"]'` (`56` with `@openhands/extensions` 0.30.0, #18336, which added an installable Bitbucket card; `55` before; 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`.",
          "source_line": 75,
          "expected": [
            "`/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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--search",
          "title": "Search",
          "ids": [
            "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 `56`.",
          "raw": "**Search (`F17.search`).** 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 `56`.",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--section-filter",
          "title": "Section filter",
          "ids": [
            "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`.",
          "raw": "**Section filter (`F17.section-filter`).** 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`.",
          "source_line": 77,
          "expected": [
            "the All / Installed / Library dropdown shows only the chosen sections."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--open-and-close-the-install-modal",
          "title": "Open and close the install modal",
          "ids": [
            "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`).",
          "raw": "**Open and close the install modal (`F17.library-catalog`).** 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`).",
          "source_line": 78,
          "expected": [
            "library cards (logo, name, transport, description, + toggle) open the install modal by click, + toggle or Enter; the modal closes with Cancel, X or Escape."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--remote-entry-masked-key",
          "title": "Remote entry, masked key",
          "ids": [
            "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.",
          "raw": "**Remote entry, masked key (`F17.install-remote`).** 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.",
          "source_line": 79,
          "expected": [
            "a remote (HTTP) entry shows its fixed URL read-only and a `type=\"password\"` credential field that is required."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--oauth-entry",
          "title": "OAuth entry",
          "ids": [
            "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.",
          "raw": "**OAuth entry (`F17.install-oauth`).** 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.",
          "source_line": 80,
          "expected": [
            "an entry whose server runs OAuth itself shows the OAuth info panel instead of a key field."
          ],
          "notes": [
            "Known issue [#15430](https://github.com/OpenHands/OpenHands/issues/15430): An OAuth MCP callback in Docker can redirect to an unexposed dynamic localhost port. Completing the callback needs a Docker stack and an approved OAuth MCP provider/account (the report uses GitLab); a local unauthenticated Test connection cannot reach the redirect. (`F17.install-oauth`).",
            "Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F17--entry-that-cannot-be-installed",
          "title": "Entry that cannot be installed",
          "ids": [
            "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.",
          "raw": "**Entry that cannot be installed (`F17.install-unsupported`).** 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.",
          "source_line": 81,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--install-fails-modal-stays",
          "title": "Install fails, modal stays",
          "ids": [
            "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.",
          "raw": "**Install fails, modal stays (`F17.install-error`).** 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.",
          "source_line": 82,
          "expected": [
            "a failed install test keeps the modal open with a red error and saves nothing."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--install-a-stdio-entry",
          "title": "Install a STDIO entry",
          "ids": [
            "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.",
          "raw": "**Install a STDIO entry (`F17.install-stdio`).** 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.",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--argument-fields",
          "title": "Argument fields",
          "ids": [
            "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\"]`.",
          "raw": "**Argument fields (`F17.install-args`).** 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\"]`.",
          "source_line": 84,
          "expected": [
            "an entry with argument fields (Filesystem's **Paths (space separated)**) requires them and appends each whitespace-separated token to the stored command's args."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--secret-selection-before-install",
          "title": "Secret selection before install",
          "ids": [
            "F17.save-as-secret"
          ],
          "body": "No provider key or install is needed. For each theme `OpenHands-Neutral` and `Light+`, run `control-openhands browser goto /settings/app`, `control-openhands browser click 'testid=color-theme-input'`, and `control-openhands browser click 'role=option[name=\"<theme>\"]'`. For each viewport `desktop` and `phone`, run `control-openhands browser viewport <viewport>`, `control-openhands browser goto /mcp`, and `control-openhands browser click 'testid=mcp-marketplace-card-github'`. `control-openhands browser bbox 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN'` shows `insideViewport: true` and `pageHorizontalOverflow: false`; the full secret name and help fit inside the row. Start `control-openhands browser record start --feature F17.save-as-secret --name <theme>-<viewport>-selection`. Run `control-openhands browser focus 'testid=mcp-install-modal >> testid=mcp-install-field-api_key'` and `control-openhands browser press Tab` twice (the first Tab visits **GitHub settings**, the second the native checkbox), take `control-openhands browser screenshot --feature F17.save-as-secret --name <theme>-<viewport>-focus`, then `control-openhands browser press Space`. `control-openhands browser eval \"document.querySelector('[data-testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN] input').checked\"` is `false`, and the focus outline, label and secret name remain readable. Run `control-openhands browser focus 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN >> role=button'`, `control-openhands browser text 'role=tooltip'`, and `control-openhands browser press Enter`; the tooltip explains sharing with automations and the same `.checked` read stays `false`. Run `control-openhands browser click 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN'`; `.checked` becomes `true`. Stop with `control-openhands browser record stop`, then close with `control-openhands browser click 'testid=mcp-install-cancel'` before the next viewport/theme. Restore desktop and OpenHands-Neutral through the same theme picker before continuing. This is selection proof only; the next bullet proves install persistence.",
          "raw": "**Secret selection before install (`F17.save-as-secret`).** No provider key or install is needed. For each theme `OpenHands-Neutral` and `Light+`, run `control-openhands browser goto /settings/app`, `control-openhands browser click 'testid=color-theme-input'`, and `control-openhands browser click 'role=option[name=\"<theme>\"]'`. For each viewport `desktop` and `phone`, run `control-openhands browser viewport <viewport>`, `control-openhands browser goto /mcp`, and `control-openhands browser click 'testid=mcp-marketplace-card-github'`. `control-openhands browser bbox 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN'` shows `insideViewport: true` and `pageHorizontalOverflow: false`; the full secret name and help fit inside the row. Start `control-openhands browser record start --feature F17.save-as-secret --name <theme>-<viewport>-selection`. Run `control-openhands browser focus 'testid=mcp-install-modal >> testid=mcp-install-field-api_key'` and `control-openhands browser press Tab` twice (the first Tab visits **GitHub settings**, the second the native checkbox), take `control-openhands browser screenshot --feature F17.save-as-secret --name <theme>-<viewport>-focus`, then `control-openhands browser press Space`. `control-openhands browser eval \"document.querySelector('[data-testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN] input').checked\"` is `false`, and the focus outline, label and secret name remain readable. Run `control-openhands browser focus 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN >> role=button'`, `control-openhands browser text 'role=tooltip'`, and `control-openhands browser press Enter`; the tooltip explains sharing with automations and the same `.checked` read stays `false`. Run `control-openhands browser click 'testid=mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN'`; `.checked` becomes `true`. Stop with `control-openhands browser record stop`, then close with `control-openhands browser click 'testid=mcp-install-cancel'` before the next viewport/theme. Restore desktop and OpenHands-Neutral through the same theme picker before continuing. This is selection proof only; the next bullet proves install persistence.",
          "source_line": 85,
          "expected": [
            "password credential fields carry **Also save as secret** (checked by default, with a tooltip); label clicks and Space change the native checkbox, while its help button leaves the choice unchanged. The selection follows the colour theme and long secret names fit on phones. After install the value is listed in Settings → Secrets."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--save-as-secret",
          "title": "Save as secret",
          "ids": [
            "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.",
          "raw": "**Save as secret (`F17.save-as-secret`).** 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.",
          "source_line": 86,
          "expected": [
            "password credential fields carry **Also save as secret** (checked by default, with a tooltip); label clicks and Space change the native checkbox, while its help button leaves the choice unchanged. The selection follows the colour theme and long secret names fit on phones. After install the value is listed in Settings → Secrets."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--editor-validation",
          "title": "Editor validation",
          "ids": [
            "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-feedback-error >> 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. `control-openhands browser text 'testid=add-mcp-server-form >> css=p.text-feedback-error >> nth=0'` is `Headers must follow KEY=value format`. Take `control-openhands browser screenshot --feature F17.custom-validation --name header-format`, Close the editor with `control-openhands browser press Escape`, run `control-openhands browser viewport phone`, reopen with `control-openhands browser click 'testid=mcp-add-custom-server'` and repeat the SHTTP header-format commands. Restore `control-openhands browser viewport desktop`, leaving that SHTTP editor open for Test connection.",
          "raw": "**Editor validation (`F17.custom-validation`).** 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-feedback-error >> 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. `control-openhands browser text 'testid=add-mcp-server-form >> css=p.text-feedback-error >> nth=0'` is `Headers must follow KEY=value format`. Take `control-openhands browser screenshot --feature F17.custom-validation --name header-format`, Close the editor with `control-openhands browser press Escape`, run `control-openhands browser viewport phone`, reopen with `control-openhands browser click 'testid=mcp-add-custom-server'` and repeat the SHTTP header-format commands. Restore `control-openhands browser viewport desktop`, leaving that SHTTP editor open for Test connection.",
          "source_line": 87,
          "expected": [
            "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). Malformed header lines use a header-specific message, distinct from environment-variable errors."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--test-connection",
          "title": "Test connection",
          "ids": [
            "F17.test-connection"
          ],
          "body": "Failure: in the SHTTP form left open by validation, run `control-openhands browser click 'testid=add-mcp-server-form >> testid=auth-mode-dropdown'`, `control-openhands browser click 'role=option[name=\"None\"]'` and `control-openhands browser fill 'testid=add-mcp-server-form >> testid=timeout-input' 10`. With URL `http://127.0.0.1:9/mcp`, 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-feedback-error`. Clicking **Add Server** leaves the editor open (`browser count 'testid=mcp-custom-editor'` is `1`). Success: run `control-openhands browser click 'testid=add-mcp-server-form >> testid=server-type-dropdown'` and `control-openhands browser click 'role=option[name=\"STDIO\"]'`, then 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-feedback-success`. `control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.test-connection --name stdio-success`.",
          "raw": "**Test connection (`F17.test-connection`).** Failure: in the SHTTP form left open by validation, run `control-openhands browser click 'testid=add-mcp-server-form >> testid=auth-mode-dropdown'`, `control-openhands browser click 'role=option[name=\"None\"]'` and `control-openhands browser fill 'testid=add-mcp-server-form >> testid=timeout-input' 10`. With URL `http://127.0.0.1:9/mcp`, 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-feedback-error`. Clicking **Add Server** leaves the editor open (`browser count 'testid=mcp-custom-editor'` is `1`). Success: run `control-openhands browser click 'testid=add-mcp-server-form >> testid=server-type-dropdown'` and `control-openhands browser click 'role=option[name=\"STDIO\"]'`, then 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-feedback-success`. `control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.test-connection --name stdio-success`.",
          "source_line": 88,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--add-the-server",
          "title": "Add the server",
          "ids": [
            "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`.",
          "raw": "**Add the server (`F17.custom-add`).** 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`.",
          "source_line": 89,
          "expected": [
            "**Add Server** saves and closes; the card is listed after a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--custom-remote-server",
          "title": "Custom remote server",
          "ids": [
            "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.",
          "raw": "**Custom remote server (`F17.custom-add-remote`).** 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.",
          "source_line": 90,
          "expected": [
            "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>`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--card-health",
          "title": "Card health",
          "ids": [
            "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`.",
          "raw": "**Card health (`F17.server-health`).** 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`.",
          "source_line": 91,
          "expected": [
            "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**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--delete",
          "title": "Delete",
          "ids": [
            "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`.",
          "raw": "**Delete (`F17.delete-server`).** 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`.",
          "source_line": 92,
          "expected": [
            "**Delete** in the editor asks for confirmation; Cancel keeps the server, Confirm removes it with `MCP server removed.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--edit",
          "title": "Edit",
          "ids": [
            "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`.",
          "raw": "**Edit (`F17.custom-edit`).** 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`.",
          "source_line": 93,
          "expected": [
            "clicking a card opens **Edit MCP server** prefilled (secrets shown as `**********`); Save tests and persists the change."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--stored-secrets-are-reused-not-their-placeholder",
          "title": "Stored secrets are reused, not their placeholder",
          "ids": [
            "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`.",
          "raw": "**Stored secrets are reused, not their placeholder (`F17.stored-secret-reuse`).** 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`.",
          "source_line": 94,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--one-request-per-mutation",
          "title": "One request per mutation",
          "ids": [
            "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`.",
          "raw": "**One request per mutation (`F17.single-request-mutations`).** 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`.",
          "source_line": 95,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--credential-probe",
          "title": "Credential probe",
          "ids": [
            "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).",
          "raw": "**Credential probe (`F17.credential-probe`).** 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).",
          "source_line": 96,
          "expected": [
            "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 ("
          ],
          "notes": [],
          "annotations": [
            "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](https://github.com/OpenHands/software-agent-sdk/issues/5607)); a server the catalog does not know gets no probe."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F17--one-error-per-stale-mutation",
          "title": "One error per stale mutation",
          "ids": [
            "F17.delete-server",
            "F17.enable-disable"
          ],
          "body": "For each viewport `desktop` and `phone`, run `control-openhands browser viewport <viewport>`. Arrange a disposable fixture with `control-openhands fixture mcp-server --name qa-toasts`; substitute its printed `<node>` and `<server.mjs>` in `control-openhands api POST /api/settings/mcp/qa_toasts --write --data '{\"command\":\"<node>\",\"args\":[\"<server.mjs>\"],\"enabled\":true}' --pick agent_settings.mcp_config`. Run `control-openhands browser goto /mcp`, `control-openhands browser reload`, and `control-openhands browser click 'role=button[name=\"Edit qa_toasts\"]'`. Remove it behind the loaded editor with `control-openhands api DELETE /api/settings/mcp/qa_toasts --write --pick agent_settings.mcp_config` (arrange a stale view, not UI deletion proof). Start `control-openhands browser record start --feature F17.delete-server --name <viewport>-stale-delete`, then `control-openhands browser click 'testid=mcp-custom-editor-delete'` and `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button' --observe 'role=status' --observe-ms 3000`. `control-openhands browser toasts --history` contains exactly one error `MCP server 'qa_toasts' was not found`; the editor stays open. Capture `control-openhands browser screenshot --feature F17.delete-server --name <viewport>-stale-delete`, stop recording, close `testid=mcp-custom-editor-close`, and reload. Recreate the fixture with the same POST, reload, then repeat the API DELETE while the card is still loaded. Start `control-openhands browser record start --feature F17.enable-disable --name <viewport>-stale-toggle`, run `control-openhands browser click 'testid=mcp-installed-toggle-qa_toasts' --hover-first --observe 'role=status' --observe-ms 3000`, and read `control-openhands browser toasts --history`: again exactly one error with that text. Capture under `F17.enable-disable`, stop recording, and reload. `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_toasts` has no value; restore desktop after the phone pass. The Agent Server's 404s and browser HTTP-error diagnostics are expected in these checks.",
          "raw": "**One error per stale mutation (`F17.delete-server`, `F17.enable-disable`).** For each viewport `desktop` and `phone`, run `control-openhands browser viewport <viewport>`. Arrange a disposable fixture with `control-openhands fixture mcp-server --name qa-toasts`; substitute its printed `<node>` and `<server.mjs>` in `control-openhands api POST /api/settings/mcp/qa_toasts --write --data '{\"command\":\"<node>\",\"args\":[\"<server.mjs>\"],\"enabled\":true}' --pick agent_settings.mcp_config`. Run `control-openhands browser goto /mcp`, `control-openhands browser reload`, and `control-openhands browser click 'role=button[name=\"Edit qa_toasts\"]'`. Remove it behind the loaded editor with `control-openhands api DELETE /api/settings/mcp/qa_toasts --write --pick agent_settings.mcp_config` (arrange a stale view, not UI deletion proof). Start `control-openhands browser record start --feature F17.delete-server --name <viewport>-stale-delete`, then `control-openhands browser click 'testid=mcp-custom-editor-delete'` and `control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button' --observe 'role=status' --observe-ms 3000`. `control-openhands browser toasts --history` contains exactly one error `MCP server 'qa_toasts' was not found`; the editor stays open. Capture `control-openhands browser screenshot --feature F17.delete-server --name <viewport>-stale-delete`, stop recording, close `testid=mcp-custom-editor-close`, and reload. Recreate the fixture with the same POST, reload, then repeat the API DELETE while the card is still loaded. Start `control-openhands browser record start --feature F17.enable-disable --name <viewport>-stale-toggle`, run `control-openhands browser click 'testid=mcp-installed-toggle-qa_toasts' --hover-first --observe 'role=status' --observe-ms 3000`, and read `control-openhands browser toasts --history`: again exactly one error with that text. Capture under `F17.enable-disable`, stop recording, and reload. `control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_toasts` has no value; restore desktop after the phone pass. The Agent Server's 404s and browser HTTP-error diagnostics are expected in these checks.",
          "source_line": 97,
          "expected": [
            "**Delete** in the editor asks for confirmation; Cancel keeps the server, Confirm removes it with `MCP server removed.`",
            "the card toggle disables a server without removing its configuration; the state persists and a disabled server is withheld from new conversations."
          ],
          "notes": [
            "Known issue #17941: turning off an enabled card toggle can drop the click when the pointer moves in and presses at once (`F17.enable-disable`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F17--the-agent-uses-it",
          "title": "The agent uses it",
          "ids": [
            "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.",
          "raw": "**The agent uses it (`F17.agent-uses-server`).** 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.",
          "source_line": 98,
          "expected": [
            "a conversation started after the save can call the server's tools."
          ],
          "notes": [
            "Known issue [#15421](https://github.com/OpenHands/OpenHands/issues/15421): A configured Linear MCP can be ignored while an explicitly disabled Linear skill is used instead. The new-chat and automation paths need a working authorized Linear MCP connection, a concrete issue ID and an approved model; the report's automation ZIP is also needed to replay its exact automation settings. (`F17.agent-uses-server`).",
            "Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F17--enable-and-disable",
          "title": "Enable and disable",
          "ids": [
            "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`.",
          "raw": "**Enable and disable (`F17.enable-disable`).** 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`.",
          "source_line": 99,
          "expected": [
            "the card toggle disables a server without removing its configuration; the state persists and a disabled server is withheld from new conversations."
          ],
          "notes": [
            "Known issue #17941: turning off an enabled card toggle can drop the click when the pointer moves in and presses at once (`F17.enable-disable`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F17--phone-modals",
          "title": "Phone modals",
          "ids": [
            "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`.",
          "raw": "**Phone modals (`F17.phone`).** 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`.",
          "source_line": 100,
          "expected": [
            "the MCP page, the install modal and the custom editor fit a 390 px viewport."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F17--cloud-backend",
          "title": "Cloud backend",
          "ids": [
            "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`.",
          "raw": "**Cloud backend (`F17.cloud-nav`, `F17.native-git-tabs`).** 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`.",
          "source_line": 101,
          "expected": [
            "with a Cloud backend active, Plugins and Apps are hidden and Skills becomes an external link to `<cloud host>/settings/skills` (blocked here).",
            "on a Cloud backend, GitHub/GitLab/Bitbucket offer a recommended native-integration tab next to the MCP tab (blocked here)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F17--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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.",
          "raw": "**Cleanup.** 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.",
          "source_line": 102,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F17--health-belongs-to-a-backend",
          "title": "Health belongs to a backend",
          "ids": [
            "F17.server-health"
          ],
          "body": "Arrange a real `fixture mcp-server` echo entry. Register a second disposable backend identity against the own stack's direct Agent Server port (the first uses its ingress); this exercises separate registry identities with one physical stack. On Local open `/mcp`, probe the echo server and wait for `Reachable — credentials not verified`. Switch through `backend-selector` → `role=listbox >> text=\"QA_SCOPE\"` and read the same card without `browser goto` or reload (both clear the global store). Expected: the unprobed identity is unchecked. Known failure, reproduced twice 2026-10-12 ([#16954](https://github.com/OpenHands/OpenHands/issues/16954)): the new identity shows the first identity's Reachable verdict. Return to Local, reload only before the next independent trial, probe and switch again. This proves the identity collision; different credentials, delayed probes and revision rotation on independent servers are additional branches. Restore Local and remove the alias afterwards.",
          "raw": "**Health belongs to a backend (`F17.server-health`).** Arrange a real `fixture mcp-server` echo entry. Register a second disposable backend identity against the own stack's direct Agent Server port (the first uses its ingress); this exercises separate registry identities with one physical stack. On Local open `/mcp`, probe the echo server and wait for `Reachable — credentials not verified`. Switch through `backend-selector` → `role=listbox >> text=\"QA_SCOPE\"` and read the same card without `browser goto` or reload (both clear the global store). Expected: the unprobed identity is unchecked. Known failure, reproduced twice 2026-10-12 ([#16954](https://github.com/OpenHands/OpenHands/issues/16954)): the new identity shows the first identity's Reachable verdict. Return to Local, reload only before the next independent trial, probe and switch again. This proves the identity collision; different credentials, delayed probes and revision rotation on independent servers are additional branches. Restore Local and remove the alias afterwards.",
          "source_line": 104,
          "expected": [
            "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**."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#16954](https://github.com/OpenHands/OpenHands/issues/16954)): the new identity shows the first identity's Reachable verdict. Return to Local, reload only before the next independent trial, probe and switch again. This proves the identity collision; different credentials, delayed probes and revision rotation on independent servers are additional branches. Restore Local and remove the alias afterwards."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F17--incomplete-sibling-must-not-hide-working-tools",
          "title": "Incomplete sibling must not hide working tools",
          "ids": [
            "F17.custom-add",
            "F17.agent-uses-server"
          ],
          "body": "Use `fixture mcp-server --name qa-settings-echo` and the real settings API to store its printed stdio command/args under `qa_settings_echo`. Probe it through the MCP card; a UI-started dummy-loopback conversation's `SystemPromptEvent` includes `qa_echo`. Arrange `api POST /api/settings/mcp/qa_empty --write --data '{}'`. Expected: 422 and no entry; valid tools remain available. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5643](https://github.com/OpenHands/software-agent-sdk/issues/5643)): 201 stores `{\"enabled\":true}`, the entry has no card in the UI, and a real Home-composer start initializes without the valid echo tool. Delete `qa_empty` and recheck the good-only baseline. No successful model reply is required to inspect the initialized tool list.",
          "raw": "**Incomplete sibling must not hide working tools (`F17.custom-add`, `F17.agent-uses-server`).** Use `fixture mcp-server --name qa-settings-echo` and the real settings API to store its printed stdio command/args under `qa_settings_echo`. Probe it through the MCP card; a UI-started dummy-loopback conversation's `SystemPromptEvent` includes `qa_echo`. Arrange `api POST /api/settings/mcp/qa_empty --write --data '{}'`. Expected: 422 and no entry; valid tools remain available. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5643](https://github.com/OpenHands/software-agent-sdk/issues/5643)): 201 stores `{\"enabled\":true}`, the entry has no card in the UI, and a real Home-composer start initializes without the valid echo tool. Delete `qa_empty` and recheck the good-only baseline. No successful model reply is required to inspect the initialized tool list.",
          "source_line": 106,
          "expected": [
            "**Add Server** saves and closes; the card is listed after a reload.",
            "a conversation started after the save can call the server's tools."
          ],
          "notes": [
            "Known issue [#15421](https://github.com/OpenHands/OpenHands/issues/15421): A configured Linear MCP can be ignored while an explicitly disabled Linear skill is used instead. The new-chat and automation paths need a working authorized Linear MCP connection, a concrete issue ID and an approved model; the report's automation ZIP is also needed to replay its exact automation settings. (`F17.agent-uses-server`).",
            "Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5643](https://github.com/OpenHands/software-agent-sdk/issues/5643)): 201 stores `{\"enabled\":true}`, the entry has no card in the UI, and a real Home-composer start initializes without the valid echo tool. Delete `qa_empty` and recheck the good-only baseline. No successful model reply is required to inspect the initialized tool list."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F17--unreachable-mcp-degrades-initialization",
          "title": "Unreachable MCP degrades initialization",
          "ids": [
            "F17.agent-uses-server"
          ],
          "body": "Beside the actual echo fixture arrange first a missing stdio executable, then a `streamable-http` server at `http://127.0.0.1:1/mcp`; for each, start a chat through Home with the dummy loopback model and inspect `conversation events <id> --kinds SystemPromptEvent --from-start --last 1`. Expected: initialization completes and retains `qa_settings_echo_qa_echo`, then reaches the refused LLM endpoint. This checks connection-failure degradation before a successful reply. Structural invalid config has the separate [OpenHands/software-agent-sdk#5643](https://github.com/OpenHands/software-agent-sdk/issues/5643) failure above. Remove only the own bad entry.",
          "raw": "**Unreachable MCP degrades initialization (`F17.agent-uses-server`).** Beside the actual echo fixture arrange first a missing stdio executable, then a `streamable-http` server at `http://127.0.0.1:1/mcp`; for each, start a chat through Home with the dummy loopback model and inspect `conversation events <id> --kinds SystemPromptEvent --from-start --last 1`. Expected: initialization completes and retains `qa_settings_echo_qa_echo`, then reaches the refused LLM endpoint. This checks connection-failure degradation before a successful reply. Structural invalid config has the separate [OpenHands/software-agent-sdk#5643](https://github.com/OpenHands/software-agent-sdk/issues/5643) failure above. Remove only the own bad entry.",
          "source_line": 108,
          "expected": [
            "a conversation started after the save can call the server's tools."
          ],
          "notes": [
            "Known issue [#15421](https://github.com/OpenHands/OpenHands/issues/15421): A configured Linear MCP can be ignored while an explicitly disabled Linear skill is used instead. The new-chat and automation paths need a working authorized Linear MCP connection, a concrete issue ID and an approved model; the report's automation ZIP is also needed to replay its exact automation settings. (`F17.agent-uses-server`).",
            "Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F17--mcp-timeout-leaves-initialization",
          "title": "MCP timeout leaves initialization",
          "ids": [
            "F17.agent-uses-server"
          ],
          "body": "An own stdio process that keeps stdin open for 90 seconds without answering the MCP handshake is a real unresponsive-server precondition. Configure it beside the echo fixture, then start a chat from Home using the dummy loopback model. Expected: after the bounded initialization timeout, a SystemPromptEvent and user message persist and the run reaches the refused LLM endpoint. Inspect the initialized tools separately: proceeding past the timeout alone does not establish healthy-sibling retention or a successful model reply. Remove the own slow entry and doctor.",
          "raw": "**MCP timeout leaves initialization (`F17.agent-uses-server`).** An own stdio process that keeps stdin open for 90 seconds without answering the MCP handshake is a real unresponsive-server precondition. Configure it beside the echo fixture, then start a chat from Home using the dummy loopback model. Expected: after the bounded initialization timeout, a SystemPromptEvent and user message persist and the run reaches the refused LLM endpoint. Inspect the initialized tools separately: proceeding past the timeout alone does not establish healthy-sibling retention or a successful model reply. Remove the own slow entry and doctor.",
          "source_line": 110,
          "expected": [
            "a conversation started after the save can call the server's tools."
          ],
          "notes": [
            "Known issue [#15421](https://github.com/OpenHands/OpenHands/issues/15421): A configured Linear MCP can be ignored while an explicitly disabled Linear skill is used instead. The new-chat and automation paths need a working authorized Linear MCP connection, a concrete issue ID and an approved model; the report's automation ZIP is also needed to replay its exact automation settings. (`F17.agent-uses-server`).",
            "Known issue [OpenHands/software-agent-sdk#5375](https://github.com/OpenHands/software-agent-sdk/issues/5375): A successful GitLab OAuth login can be followed by an invalid localhost redirect URI on the first ticket-to-code-change MCP action. Reproduction needs the authorized self-hosted GitLab MCP endpoint/account and the ticket-to-code-change plugin/workflow; the reported endpoint is not a public fixture. (`F17.install-oauth`, `F17.agent-uses-server`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        }
      ]
    },
    {
      "id": "F18",
      "title": "Skills catalog",
      "file": "F18-skills.md",
      "sha256": "26b68d154659096914ce6013087208af8d8369ff47ac67f7b1284a1f5f3e685c",
      "overview": "The Skills page (Customize → Skills, `/skills`) lists every skill the backend can\nload: the built-in catalog bundled with the frontend plus personal and project\nskills reported by the Agent Server. A user searches and filters the cards\n(filters are mirrored into the URL), opens a detail modal, switches skills on or\noff (saved to settings straight away, effective for new conversations only),\nlaunches a skill into chat, and reads how to add one with `/add-skill`; after the\nagent installs a skill in a conversation, a banner offers a fresh conversation\nthat loads it.",
      "entry": "- Sidebar **Customize** (`sidebar-skills-link`, goes to `/customize`, which redirects to `/mcp` on desktop), then **Skills** in the Customize navigation (`sidebar-extensions-/skills`).\n- Direct URL `/skills`, with optional filters: `/skills?q=docker&state=enabled&recommendation=recommended&source=project&category=agent-authoring&type=knowledge`.\n- Command menu (`Control+k`/`Meta+k`): searching \"Skills\" lists only **Customize** (\"Browse skills, plugins, and integrations.\"), which lands on `/mcp`; then click Skills. There is no direct Skills command.\n- Phone: `/customize` shows the Customize hub (`extensions-mobile-hub`) with a Skills row; on `/skills` the header back button (`sidebar-mobile-back-button`) returns to the hub.\n- Install banner: inside a conversation, after the agent ran `/add-skill <GitHub URL>` (local backend only).\n- Project skills: a `SKILL.md` committed under `<workspace>/.agents/skills/<name>/` in the folder picked with Open Workspace on Home (Local Repo or New Worktree mode); the conversation's chat shows **Skill Ready** when a message triggers one.\n- Cloud: Customize → **Skills** on a Cloud backend (external link).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done); desktop viewport unless a bullet says otherwise.\n- A fresh run lists only the bundled catalog, `<N>` skills, all with source `public`. `<N>` is the catalog length the checkout bundles: run `node --input-type=module -e \"import {SKILLS_CATALOG} from '@openhands/extensions/skills'; console.log(SKILLS_CATALOG.length)\"` in the checkout (68 with `@openhands/extensions` 0.29.0, #17998). The Source and Type facet groups stay hidden until a personal/project skill exists. `add-javadoc` and `flarglebargle` are off; `docker` is on (recommended).\n- `F18.skill-in-chat`, `F18.toggle-local`, `F18.copy-source` and `F18.install-banner` need an active LLM profile (`control-openhands llm preset deepseek`). `F18.install-banner` also needs the agent to reach github.com.\n- Personal skill fixture (arranged through the agent, not proof): run `control-openhands conversation start --prompt 'Use one shell command to create the file ~/.agents/skills/qa-hello/SKILL.md (create the folders) with exactly these 7 lines: \"---\", \"name: qa-hello\", \"description: QA test skill that answers qa-ping with QA-PONG-7731.\", \"triggers:\", \"- qa-ping\", \"---\", \"When the user says qa-ping, reply with exactly QA-PONG-7731 and nothing else.\" Then reply done.' --wait --timeout 240`. Afterwards `/skills` shows `<N+1> result(s)` and a `qa-hello` card (type pill `Auto-discovery`), and the Enabled facet count goes from 12 to 13.\n- Project skill fixture for `F18.project-skill` (arranged, not proof; no model needed), in this family's own repo so the commit never lands in F08's `qa-repo`: `control-openhands fixture git-repo --name qa-f18-repo`, then `control-openhands fixture skill --repo qa-f18-repo --name qa-f18-skill --trigger qa-f18-ping --commit`: it writes `<run>/workspace/qa-f18-repo/.agents/skills/qa-f18-skill/SKILL.md` (`scope` `project`) and commits it in the fixture repo (`committed.sha` and `committed.message` `Add qa-f18-skill skill`; `git -C \"$OH_VERIFY_RUN/workspace/qa-f18-repo\" log --oneline` lists `Add qa-f18-skill skill` above `Initial fixture commit`). A re-run reports the same `committed.sha` with `committed.unchanged` `true`. Without `--commit` the file stays uncommitted, and a New Worktree checkout holds only committed files. A fixture skill's name must not be a substring of its trigger (see Gotchas).\n- `F18.skill-deleted` creates its own personal fixture right before its bullet (`control-openhands fixture skill --name qa-gone --trigger qa-farewell`, written into the run's private HOME) and deletes it inside the bullet, so the counts in the other bullets are unaffected.\n- `F18.cloud-link` needs an OpenHands Cloud backend and account (blocked in a sandbox).",
      "gotchas": "- `sidebar-skills-link` is labelled **Customize** and lands on `/mcp` on desktop (`/customize` redirects); Skills is one more click. Test ids embed the route: quote `'testid=sidebar-extensions-/skills'`.\n- A `browser click` on an **enabled** card toggle is lost when the pointer was not already over it: `pointerenter` swaps the checkmark icon for the X icon between mousedown and mouseup, so Chrome fires no click and no `PATCH /api/settings` goes out (#17941). `browser hover` the toggle first (or click twice). Turning a skill on is not affected. Recorded as `fail` on `F18.toggle`.\n- The card toggle and the facet rows are `role=switch`/`role=checkbox` buttons: read `aria-checked`. The modal switch is an `sr-only` checkbox (keyboard reachable): click the row text and read `.checked` with `browser eval`.\n- Typing writes `?q=` with `replace` after a 300 ms debounce, so read `browser url` a second later; facet clicks push a history entry immediately.\n- Facet counts are disjunctive: a row counts skills matching the other groups' selections, so Disabled reads `0` (and is disabled) while Recommended is checked.\n- Groups with fewer than two values are hidden (Source and Type on a fresh run) unless the URL selects one of their values.\n- The run's `HOME` lives under `/tmp`, so a skill in `~/.agents/skills` is filed under Source **This project** (`source=project`), not **Personal**; with a real `/home/<user>` or `/Users/<user>` home it would be Personal.\n- `conversation events` rows show a user `MessageEvent`'s activated skills as `skills: [...]` (since #18162). `api GET '/api/conversations/<id>/events/search?limit=5' --max-bytes 2000000` shows the raw `activated_skills`, but only when the user row is among those five events. On 2026-10-08 it was not: the first five were the system prompt and state updates.\n- Skill lists are cached for 10 minutes: `browser reload` (or `goto`) after the agent creates a skill file, or the new card does not appear.\n- `navigator.clipboard.readText()` through `browser eval` hangs (no clipboard permission in the page); read what a copy button wrote with `control-openhands browser clipboard` and assert the button's `aria-label` too. The app runs on `127.0.0.1` (a secure context), so the unguarded `clipboard.writeText` calls cannot be tested for insecure-origin failures here.\n- With the Agent Server stopped, reloading `/skills` shows the \"Manage backends / Disconnected\" screen instead of the page: load the page first, then stop the service. After the failed save the card keeps showing the new state until a reload (#17941).\n- Escape from the modal's focused `+N` overflow trigger closes only the popover and restores trigger focus; the next Escape closes the detail modal. The card popover uses the same Escape behavior.\n- The Add skill example, its Copy button and the add-skill **Use skill** message share the `.../skills/code-review` path.\n- Skill changes apply to new conversations only; an open conversation keeps the skills it started with.\n- `conversation events --grep` searches whole events, the user's message text included: a trigger that contains the skill name (`qa-gone-ping` for `qa-gone`) matches the user `MessageEvent` even when the skill is not loaded. Give fixture skills a trigger that does not contain their name, or grep for `<name>qa-gone</name>`.\n- `fixture skill --repo` writes the project `SKILL.md` and records it in the repo only with `--commit` (`committed.sha`; a re-run reports `unchanged`); a New Worktree conversation checks out the repo's commits, so pass `--commit`. Workspace skills are loaded per conversation and never appear on `/skills`.\n- Without an LLM key the skill set is still observable: the `SystemPromptEvent` (available skills), the user `MessageEvent` (`activated_skills`) and the chat's **Skill Ready** row (`generic-event-message-title`; Expand lists the skills under `Triggered Skill Knowledge:`) all exist before the first model call, after which the conversation ends in `error`.\n- Known issue [#16298](https://github.com/OpenHands/OpenHands/issues/16298): A Slack-monitor setup can leave the agent without the expected skill. The report omits the concrete monitor setup and loaded skill name/version; reproducing the runtime also needs an approved model and a configured Slack integration. Listing the bundled catalog alone cannot settle it. (`F18.skill-in-chat`).\n- Known issue [#15458](https://github.com/OpenHands/OpenHands/issues/15458): The global Skills page can list AGENTS.md files from cloned automation-run worktrees. The report says the discovery trigger is unknown; replay needs the affected automation workspace/discovery-root layout or captured request parameters, rather than only a normal project-skill fixture. (`F18.page`, `F18.project-skill`).",
      "features": [
        {
          "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; Escape closes the popover first and returns focus to its trigger, and a second Escape closes 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)."
        }
      ],
      "scenarios": [
        {
          "name": "F18--open-from-the-sidebar",
          "title": "Open from the sidebar",
          "ids": [
            "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.",
          "raw": "**Open from the sidebar (`F18.page`).** 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.",
          "source_line": 61,
          "expected": [
            "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.)"
          ],
          "notes": [
            "Known issue [#15458](https://github.com/OpenHands/OpenHands/issues/15458): The global Skills page can list AGENTS.md files from cloned automation-run worktrees. The report says the discovery trigger is unknown; replay needs the affected automation workspace/discovery-root layout or captured request parameters, rather than only a normal project-skill fixture. (`F18.page`, `F18.project-skill`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F18--command-menu-entry",
          "title": "Command-menu entry",
          "ids": [
            "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(\\?|$)'`.",
          "raw": "**Command-menu entry (`F18.page`).** 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(\\?|$)'`.",
          "source_line": 62,
          "expected": [
            "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.)"
          ],
          "notes": [
            "Known issue [#15458](https://github.com/OpenHands/OpenHands/issues/15458): The global Skills page can list AGENTS.md files from cloned automation-run worktrees. The report says the discovery trigger is unknown; replay needs the affected automation workspace/discovery-root layout or captured request parameters, rather than only a normal project-skill fixture. (`F18.page`, `F18.project-skill`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F18--search",
          "title": "Search",
          "ids": [
            "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.",
          "raw": "**Search (`F18.search`).** 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.",
          "source_line": 63,
          "expected": [
            "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.\""
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--back-and-forward",
          "title": "Back and forward",
          "ids": [
            "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`.",
          "raw": "**Back and forward (`F18.search-history`).** 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`.",
          "source_line": 64,
          "expected": [
            "browser Back/Forward put the matching query back into the search box."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--facets",
          "title": "Facets",
          "ids": [
            "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`.",
          "raw": "**Facets (`F18.facets`).** 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`.",
          "source_line": 65,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--source-and-type-facets",
          "title": "Source and Type facets",
          "ids": [
            "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.",
          "raw": "**Source and Type facets (`F18.facets`).** 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.",
          "source_line": 66,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--deep-link",
          "title": "Deep link",
          "ids": [
            "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`.",
          "raw": "**Deep link (`F18.facets`).** 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`.",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--card-and-pill-overflow",
          "title": "Card and pill overflow",
          "ids": [
            "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`.",
          "raw": "**Card and pill overflow (`F18.card`).** 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`.",
          "source_line": 68,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--keyboard-open",
          "title": "Keyboard open",
          "ids": [
            "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.",
          "raw": "**Keyboard open (`F18.card`).** 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.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--copy-the-source-path",
          "title": "Copy the source path",
          "ids": [
            "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`.",
          "raw": "**Copy the source path (`F18.copy-source`).** 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`.",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--toggle-a-built-in-skill",
          "title": "Toggle a built-in skill",
          "ids": [
            "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).",
          "raw": "**Toggle a built-in skill (`F18.toggle`).** 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).",
          "source_line": 71,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--toggle-a-personal-skill",
          "title": "Toggle a personal skill",
          "ids": [
            "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.",
          "raw": "**Toggle a personal skill (`F18.toggle-local`).** 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.",
          "source_line": 72,
          "expected": [
            "switching a personal/project skill off writes `disabled_skills`; new conversations then do not load it, and do again once it is back on."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--skill-reaches-chat",
          "title": "Skill reaches chat",
          "ids": [
            "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?`).",
          "raw": "**Skill reaches chat (`F18.skill-in-chat`).** 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?`).",
          "source_line": 73,
          "expected": [
            "an enabled skill is loaded into new conversations and fires on its trigger; a disabled one is not."
          ],
          "notes": [
            "Known issue [#16298](https://github.com/OpenHands/OpenHands/issues/16298): A Slack-monitor setup can leave the agent without the expected skill. The report omits the concrete monitor setup and loaded skill name/version; reproducing the runtime also needs an approved model and a configured Slack integration. Listing the bundled catalog alone cannot settle it. (`F18.skill-in-chat`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F18--project-skill-in-a-workspace",
          "title": "Project skill in a workspace",
          "ids": [
            "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`).",
          "raw": "**Project skill in a workspace (`F18.project-skill`).** 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`).",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#15458](https://github.com/OpenHands/OpenHands/issues/15458): The global Skills page can list AGENTS.md files from cloned automation-run worktrees. The report says the discovery trigger is unknown; replay needs the affected automation workspace/discovery-root layout or captured request parameters, rather than only a normal project-skill fixture. (`F18.page`, `F18.project-skill`)."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F18--deleted-personal-skill",
          "title": "Deleted personal skill",
          "ids": [
            "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.",
          "raw": "**Deleted personal skill (`F18.skill-deleted`).** 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.",
          "source_line": 75,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--both-personal-skill-folders",
          "title": "Both personal skill folders",
          "ids": [
            "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`.",
          "raw": "**Both personal skill folders (`F18.personal-skill-dirs`).** 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`.",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--save-failure",
          "title": "Save failure",
          "ids": [
            "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`.",
          "raw": "**Save failure (`F18.toggle-error`).** 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`.",
          "source_line": 77,
          "expected": [
            "a failed save shows an error toast and nothing is persisted."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--detail-modal-and-switch",
          "title": "Detail modal and switch",
          "ids": [
            "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`.",
          "raw": "**Detail modal and switch (`F18.detail-modal`).** 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`.",
          "source_line": 78,
          "expected": [
            "the detail modal shows source, an Enabled/Disabled switch, description, pills and the read-only content; switching off disables **Use skill**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--close-the-modal",
          "title": "Close the modal",
          "ids": [
            "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`.",
          "raw": "**Close the modal (`F18.detail-close`).** 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`.",
          "source_line": 79,
          "expected": [
            "the X, **Close**, Escape and a backdrop click close the detail modal."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--pill-overflow-in-the-modal",
          "title": "Pill overflow in the modal",
          "ids": [
            "F18.detail-pills"
          ],
          "body": "Run `control-openhands browser click 'testid=skill-card-add-javadoc'`, `control-openhands browser text 'testid=skill-modal-pills-add-javadoc'` (at desktop: `Trigger-based`, `Code quality & review`, `MIT`, `+4`; license and compatibility appear only in the modal), `control-openhands browser attr 'testid=skill-modal-pills-add-javadoc-overflow' aria-label` (`Show 4 more` at desktop; `Show 5 more` at phone width), `control-openhands browser click 'testid=skill-modal-pills-add-javadoc-overflow'` and `control-openhands browser text 'testid=skill-modal-pills-add-javadoc-overflow-popover'` (`Requires Java source files`, `javadoc`, `java documentation`, `document java` at desktop; `MIT` joins them at phone width); `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'`; `control-openhands browser count 'testid=skill-modal-pills-add-javadoc-overflow-popover'` is `0` while the detail modal stays open. Reopen it with `control-openhands browser click 'testid=skill-modal-pills-add-javadoc-overflow'`. With focus on the overflow trigger, run `control-openhands browser record start --feature F18.detail-pills --name escape`, `control-openhands browser press Escape` and `control-openhands browser wait 'testid=skill-modal-pills-add-javadoc-overflow-popover' --state detached`. `control-openhands browser count 'testid=skill-detail-modal'` stays `1`; focus returns to the overflow trigger. Press Escape again and `control-openhands browser wait 'testid=skill-detail-modal' --state detached`, then `control-openhands browser record stop`. Repeat this modal/pill sequence at `browser viewport phone`, then restore `browser viewport desktop`.",
          "raw": "**Pill overflow in the modal (`F18.detail-pills`).** Run `control-openhands browser click 'testid=skill-card-add-javadoc'`, `control-openhands browser text 'testid=skill-modal-pills-add-javadoc'` (at desktop: `Trigger-based`, `Code quality & review`, `MIT`, `+4`; license and compatibility appear only in the modal), `control-openhands browser attr 'testid=skill-modal-pills-add-javadoc-overflow' aria-label` (`Show 4 more` at desktop; `Show 5 more` at phone width), `control-openhands browser click 'testid=skill-modal-pills-add-javadoc-overflow'` and `control-openhands browser text 'testid=skill-modal-pills-add-javadoc-overflow-popover'` (`Requires Java source files`, `javadoc`, `java documentation`, `document java` at desktop; `MIT` joins them at phone width); `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'`; `control-openhands browser count 'testid=skill-modal-pills-add-javadoc-overflow-popover'` is `0` while the detail modal stays open. Reopen it with `control-openhands browser click 'testid=skill-modal-pills-add-javadoc-overflow'`. With focus on the overflow trigger, run `control-openhands browser record start --feature F18.detail-pills --name escape`, `control-openhands browser press Escape` and `control-openhands browser wait 'testid=skill-modal-pills-add-javadoc-overflow-popover' --state detached`. `control-openhands browser count 'testid=skill-detail-modal'` stays `1`; focus returns to the overflow trigger. Press Escape again and `control-openhands browser wait 'testid=skill-detail-modal' --state detached`, then `control-openhands browser record stop`. Repeat this modal/pill sequence at `browser viewport phone`, then restore `browser viewport desktop`.",
          "source_line": 80,
          "expected": [
            "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; Escape closes the popover first and returns focus to its trigger, and a second Escape closes the modal."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--use-skill",
          "title": "Use skill",
          "ids": [
            "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.",
          "raw": "**Use skill (`F18.use-skill`).** 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.",
          "source_line": 81,
          "expected": [
            "**Use skill** closes the modal, opens `/conversations` and pre-fills the composer with `/<skill-name> `."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--add-skill-instructions",
          "title": "Add skill instructions",
          "ids": [
            "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.",
          "raw": "**Add skill instructions (`F18.add-skill-modal`).** 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.",
          "source_line": 82,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--install-banner",
          "title": "Install banner",
          "ids": [
            "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).",
          "raw": "**Install banner (`F18.install-banner`).** 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).",
          "source_line": 83,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--same-name-source-filtering",
          "title": "Same-name source filtering",
          "ids": [
            "F18.facets"
          ],
          "body": "Expected: a personal and a built-in definition with the same name appear once each with their own source; **This project** excludes the built-in card, and **Built in** excludes the personal card. Start with no personal `skill-creator` fixture. Run `control-openhands browser viewport desktop`, `control-openhands browser goto /skills`, `control-openhands browser fill 'testid=skills-search-input' skill-creator`, and `control-openhands browser wait-url '[?&]q=skill-creator'`; `control-openhands browser count 'testid=skill-card-skill-creator'` is `1`. Arrange `control-openhands fixture skill --name skill-creator --body 'QA_F18_LOCAL_COLLISION. This local fixture is never loaded into an agent.'`; note its run-owned path for cleanup. Reload and count: expected `2` distinct sources. Read them with `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=skill-card-skill-creator]')].map(c=>c.querySelector('[data-testid=skill-source-skill-creator]')?.title)\"`. Start `control-openhands browser record start --feature F18.facets --name desktop-same-name`. Run `control-openhands browser click 'testid=skill-facet-source-project'` (expected one local card), `control-openhands browser click 'testid=skills-clear-filters'`, `control-openhands browser click 'testid=skill-facet-source-public'` (expected one public card), Clear filters, then **This project** again. Read the count and source array after each filter. Capture `control-openhands browser screenshot --feature F18.facets --name desktop-repeated-project` and stop recording. Repeat on phone after `control-openhands browser viewport phone` and `control-openhands browser goto '/skills?q=skill-creator'`: before each filter open `testid=skills-filters-button`, scope the facet click to `testid=skill-filters-modal >> testid=skill-facet-source-project` or `...-public`, and close with `testid=skill-filters-modal-done`. Clear through `testid=skill-filters-modal-clear` while the modal is open. Record and capture under `F18.facets` with phone names. **Known failure (reproduced 2026-10-12, [#18287](https://github.com/OpenHands/OpenHands/issues/18287)):** the public-filter step keeps an extra local card; returning to **This project** shows two cards with the identical local source path on Local macOS browser, desktop and phone. Origin unconfirmed; this does not establish a causal PR or verify the original Windows installer environment. Cleanup: after checking that the fixture path is inside this run and its file contains `QA_F18_LOCAL_COLLISION`, remove only `$OH_VERIFY_RUN/private/home/.agents/skills/skill-creator`; navigate to `/skills?q=skill-creator`, reload and count (`1`, public only). Restore desktop before continuing. No profile, model call or real key is needed.",
          "raw": "**Same-name source filtering (`F18.facets`).** Expected: a personal and a built-in definition with the same name appear once each with their own source; **This project** excludes the built-in card, and **Built in** excludes the personal card. Start with no personal `skill-creator` fixture. Run `control-openhands browser viewport desktop`, `control-openhands browser goto /skills`, `control-openhands browser fill 'testid=skills-search-input' skill-creator`, and `control-openhands browser wait-url '[?&]q=skill-creator'`; `control-openhands browser count 'testid=skill-card-skill-creator'` is `1`. Arrange `control-openhands fixture skill --name skill-creator --body 'QA_F18_LOCAL_COLLISION. This local fixture is never loaded into an agent.'`; note its run-owned path for cleanup. Reload and count: expected `2` distinct sources. Read them with `control-openhands browser eval \"[...document.querySelectorAll('[data-testid=skill-card-skill-creator]')].map(c=>c.querySelector('[data-testid=skill-source-skill-creator]')?.title)\"`. Start `control-openhands browser record start --feature F18.facets --name desktop-same-name`. Run `control-openhands browser click 'testid=skill-facet-source-project'` (expected one local card), `control-openhands browser click 'testid=skills-clear-filters'`, `control-openhands browser click 'testid=skill-facet-source-public'` (expected one public card), Clear filters, then **This project** again. Read the count and source array after each filter. Capture `control-openhands browser screenshot --feature F18.facets --name desktop-repeated-project` and stop recording. Repeat on phone after `control-openhands browser viewport phone` and `control-openhands browser goto '/skills?q=skill-creator'`: before each filter open `testid=skills-filters-button`, scope the facet click to `testid=skill-filters-modal >> testid=skill-facet-source-project` or `...-public`, and close with `testid=skill-filters-modal-done`. Clear through `testid=skill-filters-modal-clear` while the modal is open. Record and capture under `F18.facets` with phone names. **Known failure (reproduced 2026-10-12, [#18287](https://github.com/OpenHands/OpenHands/issues/18287)):** the public-filter step keeps an extra local card; returning to **This project** shows two cards with the identical local source path on Local macOS browser, desktop and phone. Origin unconfirmed; this does not establish a causal PR or verify the original Windows installer environment. Cleanup: after checking that the fixture path is inside this run and its file contains `QA_F18_LOCAL_COLLISION`, remove only `$OH_VERIFY_RUN/private/home/.agents/skills/skill-creator`; navigate to `/skills?q=skill-creator`, reload and count (`1`, public only). Restore desktop before continuing. No profile, model call or real key is needed.",
          "source_line": 84,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-12, [#18287](https://github.com/OpenHands/OpenHands/issues/18287)):** the public-filter step keeps an extra local card; returning to **This project** shows two cards with the identical local source path on Local macOS browser, desktop and phone. Origin unconfirmed; this does not establish a causal PR or verify the original Windows installer environment. Cleanup: after checking that the fixture path is inside this run and its file contains `QA_F18_LOCAL_COLLISION`, remove only `$OH_VERIFY_RUN/private/home/.agents/skills/skill-creator`; navigate to `/skills?q=skill-creator`, reload and count (`1`, public only). Restore desktop before continuing. No profile, model call or real key is needed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F18--filters-modal-on-a-phone",
          "title": "Filters modal on a phone",
          "ids": [
            "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.",
          "raw": "**Filters modal on a phone (`F18.filters-modal`).** 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.",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--phone-layout-and-hub",
          "title": "Phone layout and hub",
          "ids": [
            "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`.",
          "raw": "**Phone layout and hub (`F18.phone`).** 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`.",
          "source_line": 86,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--cloud-link",
          "title": "Cloud link",
          "ids": [
            "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.",
          "raw": "**Cloud link (`F18.cloud-link`).** 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.",
          "source_line": 87,
          "expected": [
            "with a Cloud backend the Customize **Skills** item opens the Cloud skills page in a new tab instead of `/skills` (blocked here)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F18--restore",
          "title": "Restore",
          "ids": [],
          "body": "Leave `add-javadoc` and `flarglebargle` off and `qa-hello` on; the fixture skill and the conversations vanish with the run's private state.",
          "raw": "**Restore.** Leave `add-javadoc` and `flarglebargle` off and `qa-hello` on; the fixture skill and the conversations vanish with the run's private state.",
          "source_line": 88,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F18--colliding-skill-names-do-not-leave-ghost-cards",
          "title": "Colliding skill names do not leave ghost cards",
          "ids": [
            "F18.card",
            "F18.facets"
          ],
          "body": "Arrange `fixture skill --name skill-creator --trigger qa-collision-ping` in the run's isolated personal folder beside the bundled same-name skill, plus `qa-toggle`. Reload `/skills`, search `skill-creator`, then search `qa-toggle`; repeat the two searches. Expected: only the one matching toggle card and no `skill-card-skill-creator`. Known failure, reproduced 2026-10-12 ([#18287](https://github.com/OpenHands/OpenHands/issues/18287)): `skills-result-summary` says `1 result(s)` while two then three stale same-source skill-creator cards remain. Read their source titles and screenshot the mismatched list. Remove only the own colliding fixture and reload to clear ghosts.",
          "raw": "**Colliding skill names do not leave ghost cards (`F18.card`, `F18.facets`).** Arrange `fixture skill --name skill-creator --trigger qa-collision-ping` in the run's isolated personal folder beside the bundled same-name skill, plus `qa-toggle`. Reload `/skills`, search `skill-creator`, then search `qa-toggle`; repeat the two searches. Expected: only the one matching toggle card and no `skill-card-skill-creator`. Known failure, reproduced 2026-10-12 ([#18287](https://github.com/OpenHands/OpenHands/issues/18287)): `skills-result-summary` says `1 result(s)` while two then three stale same-source skill-creator cards remain. Read their source titles and screenshot the mismatched list. Remove only the own colliding fixture and reload to clear ghosts.",
          "source_line": 90,
          "expected": [
            "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.",
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([#18287](https://github.com/OpenHands/OpenHands/issues/18287)): `skills-result-summary` says `1 result(s)` while two then three stale same-source skill-creator cards remain. Read their source titles and screenshot the mismatched list. Remove only the own colliding fixture and reload to clear ghosts."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F18--skill-state-survives-a-delayed-sample",
          "title": "Skill state survives a delayed sample",
          "ids": [
            "F18.toggle"
          ],
          "body": "With an own enabled `qa-toggle` fixture, use its real switch with `--hover-first 250` when removing; drive disabled → enabled → disabled, observe for eight seconds and read `aria-checked`, settings `disabled_skills` and a reload. Expected: the switch stays false and the server keeps `qa-toggle` disabled across reload. Repeat the cycle, then restore enabled; a bounded sample does not establish every possible delay.",
          "raw": "**Skill state survives a delayed sample (`F18.toggle`).** With an own enabled `qa-toggle` fixture, use its real switch with `--hover-first 250` when removing; drive disabled → enabled → disabled, observe for eight seconds and read `aria-checked`, settings `disabled_skills` and a reload. Expected: the switch stays false and the server keeps `qa-toggle` disabled across reload. Repeat the cycle, then restore enabled; a bounded sample does not establish every possible delay.",
          "source_line": 92,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F18--backend-switch-refreshes-skill-data",
          "title": "Backend switch refreshes skill data",
          "ids": [
            "F18.page"
          ],
          "body": "Use Local and the disposable `QA_SCOPE` identity of the own stack described in F17. Load/reload the Local Skills page once, then arrange a new personal fixture `qa-scope-new-3`; clear the skills network log. Switch to QA_SCOPE through the actual selector and inspect the still-mounted Skills page, with no `goto` or reload between the switch and observation. Expected: a skills request for the selected backend and the new card. Known failure, reproduced twice 2026-10-12 ([#16893](https://github.com/OpenHands/OpenHands/issues/16893)): zero `/api/skills` requests and card count 0; a subsequent reload requests actual data and shows the card (1). Repeat in the other direction with a new marker `qa-scope-new-4`. Two registry identities / real fixture changes share one physical run here; separate Cloud data and conversation-scoped caches are further branches. Restore Local and remove only the fixtures/alias.",
          "raw": "**Backend switch refreshes skill data (`F18.page`).** Use Local and the disposable `QA_SCOPE` identity of the own stack described in F17. Load/reload the Local Skills page once, then arrange a new personal fixture `qa-scope-new-3`; clear the skills network log. Switch to QA_SCOPE through the actual selector and inspect the still-mounted Skills page, with no `goto` or reload between the switch and observation. Expected: a skills request for the selected backend and the new card. Known failure, reproduced twice 2026-10-12 ([#16893](https://github.com/OpenHands/OpenHands/issues/16893)): zero `/api/skills` requests and card count 0; a subsequent reload requests actual data and shows the card (1). Repeat in the other direction with a new marker `qa-scope-new-4`. Two registry identities / real fixture changes share one physical run here; separate Cloud data and conversation-scoped caches are further branches. Restore Local and remove only the fixtures/alias.",
          "source_line": 94,
          "expected": [
            "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.)"
          ],
          "notes": [
            "Known issue [#15458](https://github.com/OpenHands/OpenHands/issues/15458): The global Skills page can list AGENTS.md files from cloned automation-run worktrees. The report says the discovery trigger is unknown; replay needs the affected automation workspace/discovery-root layout or captured request parameters, rather than only a normal project-skill fixture. (`F18.page`, `F18.project-skill`)."
          ],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([#16893](https://github.com/OpenHands/OpenHands/issues/16893)): zero `/api/skills` requests and card count 0; a subsequent reload requests actual data and shows the card (1). Repeat in the other direction with a new marker `qa-scope-new-4`. Two registry identities / real fixture changes share one physical run here; separate Cloud data and conversation-scoped caches are further branches. Restore Local and remove only the fixtures/alias."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F18--persistence-folder-skill-loads",
          "title": "Persistence folder skill loads",
          "ids": [
            "F18.personal-skill-dirs"
          ],
          "body": "Arrange a valid `qa-legacy` fixture under `private/skills/qa-legacy/SKILL.md` (move the CLI-created personal fixture there; `private` is this run's persistence directory). Reload Skills, read the card/source path and start a chat through Home with `qa-legacy-ping. Do not run tools; reply in one line.` using the dummy loopback profile. Expected: the card appears and the initialized SystemPromptEvent/user message includes `qa-legacy` in available/activated skills. This checks discovery and activation before the model call. Remove the own skill afterwards.",
          "raw": "**Persistence folder skill loads (`F18.personal-skill-dirs`).** Arrange a valid `qa-legacy` fixture under `private/skills/qa-legacy/SKILL.md` (move the CLI-created personal fixture there; `private` is this run's persistence directory). Reload Skills, read the card/source path and start a chat through Home with `qa-legacy-ping. Do not run tools; reply in one line.` using the dummy loopback profile. Expected: the card appears and the initialized SystemPromptEvent/user message includes `qa-legacy` in available/activated skills. This checks discovery and activation before the model call. Remove the own skill afterwards.",
          "source_line": 96,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        }
      ]
    },
    {
      "id": "F19",
      "title": "Plugins and plugin launch",
      "file": "F19-plugins.md",
      "sha256": "e73c945e7d7a295f4eef5557ce05047b1d1a835d83e72ea02bfe6d190e9d3d3a",
      "overview": "Plugins bundle skills, commands and files that the agent loads into a\nconversation. Under Customize → Plugins a user browses one card grid that merges\nthe marketplace catalog, installed plugins and read-only local plugins; searches\nand filters it; installs, enables, disables, updates and uninstalls plugins;\nbrowses a plugin's bundled skills and files; and starts a conversation with a\nplugin. Starting goes through `/launch`, a review screen (also reachable as a\ndeep link) that lists the plugins, lets the user edit plugin parameters and\nrequires a trust checkbox before it creates the conversation.",
      "entry": "- Sidebar **Customize** (`sidebar-skills-link`, lands on `/mcp`), then **Plugins** in the Customize navigation (`sidebar-extensions-/plugins`).\n- Direct URL `/plugins`.\n- Command menu (`Control+k`/`Meta+k`): \"Plugins\" finds only **Customize** (\"Browse skills, plugins, and integrations\"), which lands on `/mcp`; then click Plugins. There is no direct Plugins command.\n- Phone width: `/customize` shows the Customize hub; its Plugins row (`extensions-mobile-hub >> sidebar-extensions-/plugins`) opens `/plugins`, and the top bar's Back chevron (`sidebar-mobile-back-button`) returns to `/customize`.\n- `/launch`: from a plugin's detail modal (**Start Conversation**), or a deep link `/launch?plugins=<base64 JSON array>&message=<text>` (production format) or `/launch?plugin_source=…` (dev format).\n- Attaching plugins from the home composer (**Plugins** pill) is F03 (`F03.plugin-picker`); the conversation's **Show Plugins** list is F27 (`F27.plugins-modal`).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done), local backend, desktop viewport.\n- An active LLM profile (`control-openhands llm preset deepseek`) for Enabled plugins auto-load and the two Start-a-conversation bullets.\n- No plugin installed: `control-openhands api GET /api/plugins/installed` returns `{\"plugins\": []}`.\n- The catalog comes from the launcher's cached public-skills checkout and needs no network: `control-openhands api GET /api/plugins/marketplace` lists `city-weather`, `magic-test` and others with local-path `source` values. `magic-test` answers \"alakazam\" with `The magic worked! Plugin loaded successfully. 🎩✨`, which makes plugin loading cheap to prove.\n- Right before the Local plugin bullet (not earlier: it changes card counts and the Local filter), put a local plugin in the Agent Server's `~/.agents/plugins`. No CLI verb does this (harness gap); from the checkout run `H=\"$OH_VERIFY_RUN/private/home/.agents/plugins\"; mkdir -p \"$H\" && cp -r \"$OH_VERIFY_RUN/private/cache/skills/public-skills/plugins/magic-test\" \"$H/qa-local-plugin\" && sed -i 's/\"name\": \"magic-test\"/\"name\": \"qa-local-plugin\"/' \"$H/qa-local-plugin/.plugin/plugin.json\"`. The rename matters: a local plugin whose name is already in the catalog is hidden.\n- The Add plugin bullet installs from `github:OpenHands/extensions`, which needs outbound access to GitHub.",
      "gotchas": "- The card switch swaps its icon on hover. A plain `browser click` that moves the pointer onto it from elsewhere (for example right after clicking a filter segment) is often swallowed (#17941): no `PATCH /api/plugins/installed/<name>` shows in `browser network`. Run `browser hover` on the switch first, then click, and assert `aria-checked` and the API, not the click result.\n- Installed plugins lose their source and their enabled state, intermittently ([OpenHands/software-agent-sdk#5496](https://github.com/OpenHands/software-agent-sdk/issues/5496)). The Agent Server rewrites `<run>/private/plugins/installed/.installed.json` with a plain truncate-and-write on every load (SDK `InstallationMetadata.save_to_dir`), so a concurrent `GET /api/plugins/installed` (page loads fire several) can read an empty file: it logs `Failed to load installed extension metadata … Invalid JSON: EOF while parsing` and `Discovered untracked extension`, then saves every plugin with `\"source\": \"local\"`, `\"enabled\": true` and a fresh `installed_at`. It hit catalog installs in most trials and a disabled plugin on 2 of 3 page revisits; GitHub installs kept their source in this run. Once it hits, the card shows `local`, **Update** fails with a 500, **Start Conversation** builds an unusable `/launch` link, and a disabled plugin silently auto-loads again. Prove the launch path from a card that is not installed, and re-read the API after every navigation.\n- A failed plugin install, update or uninstall shows one toast with the server's reason (enable uses the same handler; its failure is not driven here). A response with no reason in it, such as the ingress's 502 while the Agent Server is stopped, shows the generic `An error occurred`; read the cause in the run's `private/stack.log`.\n- Installing a plugin whose name is already installed points the user to **Update** or **Uninstall**, as Apps do (`F20.install-duplicate`): with the catalog `magic-test` installed, **Add plugin** with `github:OpenHands/extensions` and Path `plugins/magic-test` answers 409 (`browser network --filter 'plugins/install'`) and `browser toasts --history` holds one toast, `This plugin is already installed. Open its card to Update or Uninstall it.`, instead of the server's `force=true` hint; the modal stays open with the typed values (`browser screenshot --feature F19.add-plugin-error --name duplicate-409`). Other install errors still toast the server's reason (`F19.add-plugin-error`). The 409 adds one `http-error` and one `console.error` to `browser errors --app-only`.\n- **Uninstall** acts immediately, with no confirmation (MCP server delete and App uninstall confirm).\n- `/launch` with a `message` starts the agent as soon as **Start Conversation** is clicked. Prefer links without a message and send `alakazam` yourself; a `city-weather` launch with a message makes the agent call an external weather API (stop it with `testid=stop-button`).\n- Edited launch parameters cannot be observed after creation: the conversation's client metadata (`localStorage['openhands-agent-server-conversation-metadata']`) strips them and the Agent Server does not return a conversation's plugins. Proving delivery needs request-body capture (harness gap).\n- `conversation start` and launches leave the browser on a conversation page, which has its own `chat-input`; `browser goto /plugins` before the next card step.\n- Build deep links with URL-encoded base64 (`%2B`, `%2F`, `%3D`): a raw `+` decodes to a space and gives the invalid-format error. Non-ASCII text must not be used in payloads (`atob` decodes bytes as Latin-1).\n- The page loads three queries (marketplace, installed, local) behind skeleton rows; wait for `testid=plugins-toolbar` before counting cards. With no plugins at all the page shows `No plugins found.` (`plugins-empty`), which the bundled catalog makes unreachable; on a Cloud backend **Add plugin** and card actions are disabled (not driven: no Cloud account).\n- Local plugins under `~/.agents/plugins` appear on the next page load without a restart; the Agent Server's `HOME` is `<run>/private/home`.\n- Known issue [OpenHands/software-agent-sdk#5985](https://github.com/OpenHands/software-agent-sdk/issues/5985): Update can re-enable a disabled plugin when an installed-list request overlaps the force-reinstall directory swap. A live check needs an owned disabled plugin and a real concurrent list that lands in that window; the report's monkeypatched `copytree` schedule is not Canvas live proof. No overlap has been proved in this pass. (`F19.refresh`, `F19.enable-toggle`).",
      "features": [
        {
          "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; a plugin that is already installed (409) toasts `This plugin is already installed. Open its card to Update or Uninstall it.` instead (#18110)."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F19--open-the-page",
          "title": "Open the page",
          "ids": [
            "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`), `control-openhands browser wait '[data-testid^=\"plugin-card-\"]' --timeout 60000` (the catalog loads after the page: an immediate count can read `0`) 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**.",
          "raw": "**Open the page (`F19.page`).** 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`), `control-openhands browser wait '[data-testid^=\"plugin-card-\"]' --timeout 60000` (the catalog loads after the page: an immediate count can read `0`) 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**.",
          "source_line": 62,
          "expected": [
            "`/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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--command-menu-entry",
          "title": "Command-menu entry",
          "ids": [
            "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(\\?|$)'`.",
          "raw": "**Command-menu entry (`F19.page`).** 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(\\?|$)'`.",
          "source_line": 63,
          "expected": [
            "`/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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--search",
          "title": "Search",
          "ids": [
            "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.",
          "raw": "**Search (`F19.search`).** 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.",
          "source_line": 64,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--status-filter",
          "title": "Status filter",
          "ids": [
            "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.",
          "raw": "**Status filter (`F19.status-filter`).** `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.",
          "source_line": 65,
          "expected": [
            "the All / Installed / Available / Local segments filter the grid; the active one has `aria-pressed=\"true\"`; the filter resets to All on reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--detail-modal",
          "title": "Detail modal",
          "ids": [
            "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.",
          "raw": "**Detail modal (`F19.detail-modal`).** 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.",
          "source_line": 66,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--files",
          "title": "Files",
          "ids": [
            "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`.",
          "raw": "**Files (`F19.files-browser`).** 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`.",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--install-from-a-card",
          "title": "Install from a card",
          "ids": [
            "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`.",
          "raw": "**Install from a card (`F19.install-card`).** 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`.",
          "source_line": 68,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--enable-switch",
          "title": "Enable switch",
          "ids": [
            "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`.",
          "raw": "**Enable switch (`F19.enable-toggle`).** 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`.",
          "source_line": 69,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5985](https://github.com/OpenHands/software-agent-sdk/issues/5985): Update can re-enable a disabled plugin when an installed-list request overlaps the force-reinstall directory swap. A live check needs an owned disabled plugin and a real concurrent list that lands in that window; the report's monkeypatched `copytree` schedule is not Canvas live proof. No overlap has been proved in this pass. (`F19.refresh`, `F19.enable-toggle`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F19--enabled-plugins-auto-load",
          "title": "Enabled plugins auto-load",
          "ids": [
            "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.",
          "raw": "**Enabled plugins auto-load (`F19.enabled-autoload`).** 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.",
          "source_line": 70,
          "expected": [
            "an installed, enabled plugin loads into every new conversation; disabled, it does not."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--update",
          "title": "Update",
          "ids": [
            "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-09): a page revisit after a catalog install usually rewrites the source to `local` ([OpenHands/software-agent-sdk#5496](https://github.com/OpenHands/software-agent-sdk/issues/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.",
          "raw": "**Update (`F19.refresh`).** 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-09): a page revisit after a catalog install usually rewrites the source to `local` ([OpenHands/software-agent-sdk#5496](https://github.com/OpenHands/software-agent-sdk/issues/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.",
          "source_line": 71,
          "expected": [
            "**Update** in an installed plugin's modal re-fetches it from its recorded source and shows `Plugin updated.`"
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5985](https://github.com/OpenHands/software-agent-sdk/issues/5985): Update can re-enable a disabled plugin when an installed-list request overlaps the force-reinstall directory swap. A live check needs an owned disabled plugin and a real concurrent list that lands in that window; the report's monkeypatched `copytree` schedule is not Canvas live proof. No overlap has been proved in this pass. (`F19.refresh`, `F19.enable-toggle`)."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-09): a page revisit after a catalog install usually rewrites the source to `local` ([OpenHands/software-agent-sdk#5496](https://github.com/OpenHands/software-agent-sdk/issues/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."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F19--start-a-conversation-from-an-installed-plugin",
          "title": "Start a conversation from an installed plugin",
          "ids": [
            "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`.",
          "raw": "**Start a conversation from an installed plugin (`F19.start-conversation`).** 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`.",
          "source_line": 72,
          "expected": [
            "**Start Conversation** in the detail modal opens `/launch?plugins=<base64 JSON>` for that plugin."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--uninstall",
          "title": "Uninstall",
          "ids": [
            "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\": []}`.",
          "raw": "**Uninstall (`F19.uninstall`).** 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\": []}`.",
          "source_line": 73,
          "expected": [
            "**Uninstall** removes the plugin at once (no confirmation), shows `Plugin uninstalled.`, closes the modal and returns the card to **Install**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--install-from-the-modal",
          "title": "Install from the modal",
          "ids": [
            "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`.",
          "raw": "**Install from the modal (`F19.install-modal`).** 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`.",
          "source_line": 74,
          "expected": [
            "**Install** in the detail modal does the same for the open plugin; the modal stays open and switches to the installed footer."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--cancel-add-plugin",
          "title": "Cancel Add plugin",
          "ids": [
            "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`.",
          "raw": "**Cancel Add plugin (`F19.add-plugin-cancel`).** 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`.",
          "source_line": 75,
          "expected": [
            "the Add modal's X, **Close**, Escape and a backdrop click close it without installing; reopening starts with empty fields."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--add-plugin-form-and-error",
          "title": "Add plugin form and error",
          "ids": [
            "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`).",
          "raw": "**Add plugin form and error (`F19.add-plugin`, `F19.add-plugin-error`).** 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`).",
          "source_line": 76,
          "expected": [
            "**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.",
            "an unusable source shows one error toast with the server's reason and keeps the modal open with the typed values; a plugin that is already installed (409) toasts `This plugin is already installed. Open its card to Update or Uninstall it.` instead (#18110)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--add-plugin-from-github",
          "title": "Add plugin from GitHub",
          "ids": [
            "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`.",
          "raw": "**Add plugin from GitHub (`F19.add-plugin`).** 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`.",
          "source_line": 77,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--installed-coordinates",
          "title": "Installed coordinates",
          "ids": [
            "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.",
          "raw": "**Installed coordinates (`F19.installed-coordinates`).** 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.",
          "source_line": 78,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--local-plugin",
          "title": "Local plugin",
          "ids": [
            "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`.",
          "raw": "**Local plugin (`F19.local-plugin`).** 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`.",
          "source_line": 79,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--start-a-conversation-from-the-catalog",
          "title": "Start a conversation from the catalog",
          "ids": [
            "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.",
          "raw": "**Start a conversation from the catalog (`F19.start-conversation`, `F19.launch-trust`).** 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.",
          "source_line": 80,
          "expected": [
            "**Start Conversation** in the detail modal opens `/launch?plugins=<base64 JSON>` for that plugin.",
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--review-screen",
          "title": "Review screen",
          "ids": [
            "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`.",
          "raw": "**Review screen (`F19.launch-review`).** 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`.",
          "source_line": 81,
          "expected": [
            "`/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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--parameters",
          "title": "Parameters",
          "ids": [
            "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).",
          "raw": "**Parameters (`F19.launch-parameters`).** 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).",
          "source_line": 82,
          "expected": [
            "plugins with `parameters` get an expanded, collapsible section with a text input, number input (blank becomes 0) or checkbox per value; edits are kept."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--dev-format",
          "title": "Dev format",
          "ids": [
            "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`.",
          "raw": "**Dev format (`F19.launch-dev-params`).** 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`.",
          "source_line": 83,
          "expected": [
            "`/launch?plugin_source=…&plugin_ref=…&plugin_repo_path=…&message=…` opens the same review for one plugin."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--error-states",
          "title": "Error states",
          "ids": [
            "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.",
          "raw": "**Error states (`F19.launch-errors`).** 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.",
          "source_line": 84,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--creation-failure",
          "title": "Creation failure",
          "ids": [
            "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.",
          "raw": "**Creation failure (`F19.launch-creation-failed`).** 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.",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--non-ascii-deep-link",
          "title": "Non-ASCII deep link",
          "ids": [
            "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`.",
          "raw": "**Non-ASCII deep link (`F19.launch-unicode`).** 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`.",
          "source_line": 86,
          "expected": [
            "a deep link whose JSON contains non-ASCII text (UTF-8 base64) shows that text correctly."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F19.phone`).** 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`.",
          "source_line": 87,
          "expected": [
            "at 390 px the toolbar stacks, cards fit, and the detail, Add and launch modals stay inside the viewport."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--errors-sweep",
          "title": "Errors sweep",
          "ids": [
            "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).",
          "raw": "**Errors sweep (`F19.page`).** `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).",
          "source_line": 88,
          "expected": [
            "`/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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F19--launch-trust-shows-actual-host",
          "title": "Launch trust shows actual host",
          "ids": [
            "F19.launch-trust"
          ],
          "body": "Run `control-openhands browser goto '/launch?plugin_source=https%3A%2F%2Fevil.example%2Fgithub.com%2FAll-Hands-AI%2Fopenhands-plugin'` and `browser text 'testid=plugin-launch-modal'`. Expected: the trust sentence contains the full `https://evil.example/...` source. Known failure, reproduced 2026-10-12 ([#17162](https://github.com/OpenHands/OpenHands/issues/17162)): it shows `All-Hands-AI/openhands-plugin` and omits `evil.example` throughout the dialog. `browser enabled 'testid=start-conversation-button'` is `false`. Screenshot, reload and repeat the modal text; leave without ticking trust or starting. This display check does not download or run that source.",
          "raw": "**Launch trust shows actual host (`F19.launch-trust`).** Run `control-openhands browser goto '/launch?plugin_source=https%3A%2F%2Fevil.example%2Fgithub.com%2FAll-Hands-AI%2Fopenhands-plugin'` and `browser text 'testid=plugin-launch-modal'`. Expected: the trust sentence contains the full `https://evil.example/...` source. Known failure, reproduced 2026-10-12 ([#17162](https://github.com/OpenHands/OpenHands/issues/17162)): it shows `All-Hands-AI/openhands-plugin` and omits `evil.example` throughout the dialog. `browser enabled 'testid=start-conversation-button'` is `false`. Screenshot, reload and repeat the modal text; leave without ticking trust or starting. This display check does not download or run that source.",
          "source_line": 90,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([#17162](https://github.com/OpenHands/OpenHands/issues/17162)): it shows `All-Hands-AI/openhands-plugin` and omits `evil.example` throughout the dialog. `browser enabled 'testid=start-conversation-button'` is `false`. Screenshot, reload and repeat the modal text; leave without ticking trust or starting. This display check does not download or run that source."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F19--unicode-parameter-round-trip",
          "title": "Unicode parameter round-trip",
          "ids": [
            "F19.launch-unicode"
          ],
          "body": "Open `/launch?plugins=W3sic291cmNlIjogImh0dHBzOi8vZ2l0aHViLmNvbS9leGFtcGxlL3BsdWdpbiIsICJwYXJhbWV0ZXJzIjogeyJ0YXNrIjogIuaVtOeQhuWPkeW4g%2BivtOaYjiDwn5qAIn19XQ%3D%3D` (UTF-8 base64 of `[{\"source\":\"https://github.com/example/plugin\",\"parameters\":{\"task\":\"整理发布说明 🚀\"}}]`). Run `browser value 'testid=plugin-0-param-task'`, screenshot and reload/read again. Expected: exactly `整理发布说明 🚀`. Known failure, reproduced 2026-10-12 ([#16735](https://github.com/OpenHands/OpenHands/issues/16735)): both reads contain Latin-1 mojibake. Leave without trust or Start. This tests the live launch decoder; the source-side URL builder's exception is a separate branch.",
          "raw": "**Unicode parameter round-trip (`F19.launch-unicode`).** Open `/launch?plugins=W3sic291cmNlIjogImh0dHBzOi8vZ2l0aHViLmNvbS9leGFtcGxlL3BsdWdpbiIsICJwYXJhbWV0ZXJzIjogeyJ0YXNrIjogIuaVtOeQhuWPkeW4g%2BivtOaYjiDwn5qAIn19XQ%3D%3D` (UTF-8 base64 of `[{\"source\":\"https://github.com/example/plugin\",\"parameters\":{\"task\":\"整理发布说明 🚀\"}}]`). Run `browser value 'testid=plugin-0-param-task'`, screenshot and reload/read again. Expected: exactly `整理发布说明 🚀`. Known failure, reproduced 2026-10-12 ([#16735](https://github.com/OpenHands/OpenHands/issues/16735)): both reads contain Latin-1 mojibake. Leave without trust or Start. This tests the live launch decoder; the source-side URL builder's exception is a separate branch.",
          "source_line": 92,
          "expected": [
            "a deep link whose JSON contains non-ASCII text (UTF-8 base64) shows that text correctly."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([#16735](https://github.com/OpenHands/OpenHands/issues/16735)): both reads contain Latin-1 mojibake. Leave without trust or Start. This tests the live launch decoder; the source-side URL builder's exception is a separate branch."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F19--cached-source-rejects-an-unknown-ref",
          "title": "Cached source rejects an unknown ref",
          "ids": [
            "F19.add-plugin"
          ],
          "body": "Arrange a run-owned local Git plugin with `.plugin/plugin.json` name `qa-pin-plugin`, a valid skill, tag `v1` at version 1.0.0 and a later main commit at version 2.0.0. Add its `file://` source with Ref `v1` through the real form and uninstall it, leaving the Git cache warm. Add the same source with Ref `qa-does-not-exist`; inspect the modal, toast and installed version, then repeat after uninstalling. Expected: missing-ref error and no installation. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5645](https://github.com/OpenHands/software-agent-sdk/issues/5645)): the modal closes with `Plugin installed.` and cached version 1.0.0 is installed. No external Git host or account is required.",
          "raw": "**Cached source rejects an unknown ref (`F19.add-plugin`).** Arrange a run-owned local Git plugin with `.plugin/plugin.json` name `qa-pin-plugin`, a valid skill, tag `v1` at version 1.0.0 and a later main commit at version 2.0.0. Add its `file://` source with Ref `v1` through the real form and uninstall it, leaving the Git cache warm. Add the same source with Ref `qa-does-not-exist`; inspect the modal, toast and installed version, then repeat after uninstalling. Expected: missing-ref error and no installation. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5645](https://github.com/OpenHands/software-agent-sdk/issues/5645)): the modal closes with `Plugin installed.` and cached version 1.0.0 is installed. No external Git host or account is required.",
          "source_line": 94,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5645](https://github.com/OpenHands/software-agent-sdk/issues/5645)): the modal closes with `Plugin installed.` and cached version 1.0.0 is installed. No external Git host or account is required."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F19--update-retains-a-requested-pin",
          "title": "Update retains a requested pin",
          "ids": [
            "F19.refresh"
          ],
          "body": "Using that own Git fixture, install Ref `v1` and privately read only its public `requested_ref` metadata in `private/plugins/installed/.installed.json`; it is `v1`. Click the card's Update and wait for the completed `Plugin updated.` toast before reading again. Expected: retain `v1`. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#4363](https://github.com/OpenHands/software-agent-sdk/issues/4363)): the stored requested ref becomes null. The visible version remained 1.0.0 in this cache, so this diagnostic proves loss of the requested pin, not a default-HEAD version jump. Earlier completed updates also had null; keep that limit explicit and uninstall the fixture.",
          "raw": "**Update retains a requested pin (`F19.refresh`).** Using that own Git fixture, install Ref `v1` and privately read only its public `requested_ref` metadata in `private/plugins/installed/.installed.json`; it is `v1`. Click the card's Update and wait for the completed `Plugin updated.` toast before reading again. Expected: retain `v1`. Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#4363](https://github.com/OpenHands/software-agent-sdk/issues/4363)): the stored requested ref becomes null. The visible version remained 1.0.0 in this cache, so this diagnostic proves loss of the requested pin, not a default-HEAD version jump. Earlier completed updates also had null; keep that limit explicit and uninstall the fixture.",
          "source_line": 96,
          "expected": [
            "**Update** in an installed plugin's modal re-fetches it from its recorded source and shows `Plugin updated.`"
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5985](https://github.com/OpenHands/software-agent-sdk/issues/5985): Update can re-enable a disabled plugin when an installed-list request overlaps the force-reinstall directory swap. A live check needs an owned disabled plugin and a real concurrent list that lands in that window; the report's monkeypatched `copytree` schedule is not Canvas live proof. No overlap has been proved in this pass. (`F19.refresh`, `F19.enable-toggle`)."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 ([OpenHands/software-agent-sdk#4363](https://github.com/OpenHands/software-agent-sdk/issues/4363)): the stored requested ref becomes null. The visible version remained 1.0.0 in this cache, so this diagnostic proves loss of the requested pin, not a default-HEAD version jump. Earlier completed updates also had null; keep that limit explicit and uninstall the fixture."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F19--a-malformed-optional-plugin-does-not-block-the-first-message",
          "title": "A malformed optional plugin does not block the first message",
          "ids": [
            "F19.enabled-autoload"
          ],
          "body": "Arrange a local own plugin directory with an invalid JSON `.plugin/plugin.json` and open its `plugin_source` launch path. Review/trust only that dummy local fixture and Start; creation yields an idle conversation because plugin loading is deferred. Send a real first message with the dummy loopback model, then read the UI error card, `conversation events <id> --kinds SystemPromptEvent --from-start --last 1` and status; repeat with a fresh launch. Expected: skip/report the broken optional plugin and continue initialization. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5718](https://github.com/OpenHands/software-agent-sdk/issues/5718)): the UI shows `Invalid JSON` and `Sending...`, no event/system prompt is persisted and status stays idle. A valid manifest with malformed SKILL.md instead did initialize and reached the refused model endpoint; do not merge those different outcomes. The fixture contains no runnable entry point or external dependency; remove it afterwards.",
          "raw": "**A malformed optional plugin does not block the first message (`F19.enabled-autoload`).** Arrange a local own plugin directory with an invalid JSON `.plugin/plugin.json` and open its `plugin_source` launch path. Review/trust only that dummy local fixture and Start; creation yields an idle conversation because plugin loading is deferred. Send a real first message with the dummy loopback model, then read the UI error card, `conversation events <id> --kinds SystemPromptEvent --from-start --last 1` and status; repeat with a fresh launch. Expected: skip/report the broken optional plugin and continue initialization. Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5718](https://github.com/OpenHands/software-agent-sdk/issues/5718)): the UI shows `Invalid JSON` and `Sending...`, no event/system prompt is persisted and status stays idle. A valid manifest with malformed SKILL.md instead did initialize and reached the refused model endpoint; do not merge those different outcomes. The fixture contains no runnable entry point or external dependency; remove it afterwards.",
          "source_line": 98,
          "expected": [
            "an installed, enabled plugin loads into every new conversation; disabled, it does not."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced twice 2026-10-12 ([OpenHands/software-agent-sdk#5718](https://github.com/OpenHands/software-agent-sdk/issues/5718)): the UI shows `Invalid JSON` and `Sending...`, no event/system prompt is persisted and status stays idle. A valid manifest with malformed SKILL.md instead did initialize and reached the refused model endpoint; do not merge those different outcomes. The fixture contains no runnable entry point or external dependency; remove it afterwards."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F19--disabled-plugin-reconciles-on-resume",
          "title": "Disabled plugin reconciles on resume",
          "ids": [
            "F19.enabled-autoload"
          ],
          "body": "Arrange an own installed `qa-resume-plugin` with a valid `.plugin/plugin.json`, `commands/qa-hello.md`, `skills/qa-plugin-skill/SKILL.md` and a `UserPromptSubmit` hook containing only `echo QA_HOOK`. Start a real Home chat with the dummy loopback profile; inspect its HookExecutionEvent (one) and plugin skill names through `api GET '/api/conversations/<id>?include_skills=true' --pick agent.agent_context.skills` (keep the full response private). Disable the real `plugin-toggle-qa-resume-plugin` with `--hover-first 250`, verify `GET /api/plugins/installed` says false, leave Plugins, `restart`, `doctor`, reload the conversation and `conversation send <id> --prompt '/qa-resume-plugin:qa-hello'`. Expected: the disabled command does not activate and plugin skills disappear. Known failure, reproduced in two independent persisted conversations on 2026-10-12 ([OpenHands/software-agent-sdk#5628](https://github.com/OpenHands/software-agent-sdk/issues/5628)): the resumed user MessageEvent activated `qa-resume-plugin:qa-hello`, and both plugin skills remained; the original hook count stayed one, so hooks did follow disable. A fresh chat begun while disabled activated no command and ran no hook. These are real pre-model activation/state observations; no successful reply or plugin MCP reconciliation is claimed. Uninstall only the own plugin and remove its source fixture afterwards.",
          "raw": "**Disabled plugin reconciles on resume (`F19.enabled-autoload`).** Arrange an own installed `qa-resume-plugin` with a valid `.plugin/plugin.json`, `commands/qa-hello.md`, `skills/qa-plugin-skill/SKILL.md` and a `UserPromptSubmit` hook containing only `echo QA_HOOK`. Start a real Home chat with the dummy loopback profile; inspect its HookExecutionEvent (one) and plugin skill names through `api GET '/api/conversations/<id>?include_skills=true' --pick agent.agent_context.skills` (keep the full response private). Disable the real `plugin-toggle-qa-resume-plugin` with `--hover-first 250`, verify `GET /api/plugins/installed` says false, leave Plugins, `restart`, `doctor`, reload the conversation and `conversation send <id> --prompt '/qa-resume-plugin:qa-hello'`. Expected: the disabled command does not activate and plugin skills disappear. Known failure, reproduced in two independent persisted conversations on 2026-10-12 ([OpenHands/software-agent-sdk#5628](https://github.com/OpenHands/software-agent-sdk/issues/5628)): the resumed user MessageEvent activated `qa-resume-plugin:qa-hello`, and both plugin skills remained; the original hook count stayed one, so hooks did follow disable. A fresh chat begun while disabled activated no command and ran no hook. These are real pre-model activation/state observations; no successful reply or plugin MCP reconciliation is claimed. Uninstall only the own plugin and remove its source fixture afterwards.",
          "source_line": 100,
          "expected": [
            "an installed, enabled plugin loads into every new conversation; disabled, it does not."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced in two independent persisted conversations on 2026-10-12 ([OpenHands/software-agent-sdk#5628](https://github.com/OpenHands/software-agent-sdk/issues/5628)): the resumed user MessageEvent activated `qa-resume-plugin:qa-hello`, and both plugin skills remained; the original hook count stayed one, so hooks did follow disable. A fresh chat begun while disabled activated no command and ran no hook. These are real pre-model activation/state observations; no successful reply or plugin MCP reconciliation is claimed. Uninstall only the own plugin and remove its source fixture afterwards."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F19--unavailable-plugin-source-is-a-handled-update-error",
          "title": "Unavailable plugin source is a handled Update error",
          "ids": [
            "F19.refresh"
          ],
          "body": "Expected:400 for a vanished source, with the installation unchanged. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5897](https://github.com/OpenHands/software-agent-sdk/issues/5897)): two real Update clicks after moving only the owned source aside showed the missing-path error and returned500; installed version/enabled state stayed unchanged. This is the plugin branch; Canvas has no skill-management Refresh action.\n\n  ```sh known-bug F19.refresh missing-source\n  QA_PLUGIN_SOURCE=\"$OH_VERIFY_RUN/workspace/qa-known-refresh-plugin\"\n  mkdir -p \"$QA_PLUGIN_SOURCE/.plugin\"\n  printf '%s\\n' '{\"name\":\"qa-known-refresh-plugin\",\"version\":\"0.1.0\",\"description\":\"Inert disposable Update check.\"}' > \"$QA_PLUGIN_SOURCE/.plugin/plugin.json\"\n  trap 'if [ -d \"$QA_PLUGIN_SOURCE.hidden\" ]; then mv \"$QA_PLUGIN_SOURCE.hidden\" \"$QA_PLUGIN_SOURCE\"; fi; control-openhands api DELETE /api/plugins/installed/qa-known-refresh-plugin --write >/dev/null; rm -rf \"$QA_PLUGIN_SOURCE\"; control-openhands browser reload >/dev/null' EXIT\n  control-openhands browser viewport desktop\n  control-openhands browser goto /plugins\n  control-openhands browser click 'testid=plugins-add-plugin-button'\n  control-openhands browser fill 'testid=add-plugin-source-input' \"$QA_PLUGIN_SOURCE\"\n  control-openhands browser click 'testid=add-plugin-submit'\n  control-openhands browser wait 'testid=add-plugin-modal' --state hidden --timeout 30000\n  control-openhands assert --field status --equals 200 -- api GET /api/plugins/installed/qa-known-refresh-plugin\n  control-openhands browser click 'testid=plugin-card-qa-known-refresh-plugin'\n  mv \"$QA_PLUGIN_SOURCE\" \"$QA_PLUGIN_SOURCE.hidden\"\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=plugin-detail-refresh-qa-known-refresh-plugin' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --equals 400 --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/plugins/installed/qa-known-refresh-plugin/refresh\"' -- browser network --filter '/api/plugins/installed/qa-known-refresh-plugin/refresh'  # bug\n  ```",
          "raw": "**Unavailable plugin source is a handled Update error (`F19.refresh`).** Expected:400 for a vanished source, with the installation unchanged. Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5897](https://github.com/OpenHands/software-agent-sdk/issues/5897)): two real Update clicks after moving only the owned source aside showed the missing-path error and returned500; installed version/enabled state stayed unchanged. This is the plugin branch; Canvas has no skill-management Refresh action.\n\n  ```sh known-bug F19.refresh missing-source\n  QA_PLUGIN_SOURCE=\"$OH_VERIFY_RUN/workspace/qa-known-refresh-plugin\"\n  mkdir -p \"$QA_PLUGIN_SOURCE/.plugin\"\n  printf '%s\\n' '{\"name\":\"qa-known-refresh-plugin\",\"version\":\"0.1.0\",\"description\":\"Inert disposable Update check.\"}' > \"$QA_PLUGIN_SOURCE/.plugin/plugin.json\"\n  trap 'if [ -d \"$QA_PLUGIN_SOURCE.hidden\" ]; then mv \"$QA_PLUGIN_SOURCE.hidden\" \"$QA_PLUGIN_SOURCE\"; fi; control-openhands api DELETE /api/plugins/installed/qa-known-refresh-plugin --write >/dev/null; rm -rf \"$QA_PLUGIN_SOURCE\"; control-openhands browser reload >/dev/null' EXIT\n  control-openhands browser viewport desktop\n  control-openhands browser goto /plugins\n  control-openhands browser click 'testid=plugins-add-plugin-button'\n  control-openhands browser fill 'testid=add-plugin-source-input' \"$QA_PLUGIN_SOURCE\"\n  control-openhands browser click 'testid=add-plugin-submit'\n  control-openhands browser wait 'testid=add-plugin-modal' --state hidden --timeout 30000\n  control-openhands assert --field status --equals 200 -- api GET /api/plugins/installed/qa-known-refresh-plugin\n  control-openhands browser click 'testid=plugin-card-qa-known-refresh-plugin'\n  mv \"$QA_PLUGIN_SOURCE\" \"$QA_PLUGIN_SOURCE.hidden\"\n  control-openhands browser network --clear\n  control-openhands browser click 'testid=plugin-detail-refresh-qa-known-refresh-plugin' --observe 'role=status' --observe-ms 1000\n  control-openhands assert --field recent.0.status --equals 400 --bug 'recent.0.status eq 500 && recent.0.path eq \"/api/plugins/installed/qa-known-refresh-plugin/refresh\"' -- browser network --filter '/api/plugins/installed/qa-known-refresh-plugin/refresh'  # bug\n  ```",
          "source_line": 102,
          "expected": [
            "**Update** in an installed plugin's modal re-fetches it from its recorded source and shows `Plugin updated.`"
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5985](https://github.com/OpenHands/software-agent-sdk/issues/5985): Update can re-enable a disabled plugin when an installed-list request overlaps the force-reinstall directory swap. A live check needs an owned disabled plugin and a real concurrent list that lands in that window; the report's monkeypatched `copytree` schedule is not Canvas live proof. No overlap has been proved in this pass. (`F19.refresh`, `F19.enable-toggle`)."
          ],
          "annotations": [
            "Known failure (reproduced twice 2026-10-16, [OpenHands/software-agent-sdk#5897](https://github.com/OpenHands/software-agent-sdk/issues/5897)): two real Update clicks after moving only the owned source aside showed the missing-path error and returned500; installed version/enabled state stayed unchanged. This is the plugin branch; Canvas has no skill-management Refresh action."
          ],
          "states": [
            "failure",
            "issue"
          ]
        }
      ]
    },
    {
      "id": "F20",
      "title": "Canvas apps",
      "file": "F20-canvas-apps.md",
      "sha256": "eac2e2ec35ec841158bda84ebe147f1daa11ee7e5e74e6e8588ae2841f5d1721",
      "overview": "Canvas apps (Canvas Extensions) are trusted browser modules installed on the\nactive local Agent Server. Under Customize → Apps a user installs an app from a\nlocal path or a Git source (it arrives disabled), reviews its card, enables it\nthrough a trust confirmation, updates, disables or uninstalls it, and opens the\npages it contributes from the main sidebar rail at `/extensions/<name>/<path>`.",
      "entry": "- Main sidebar **Customize** (`sidebar-skills-link`, lands on `/mcp`), then **Apps** in the Customize navigation (`sidebar-extensions-/apps`).\n- Command menu (`Control+k`/`Meta+k` or `command-menu-trigger`): search `Customize`, Enter (lands on `/mcp`), then **Apps**. Searching `Apps` finds nothing.\n- Phone: `/customize` shows the Customize hub (`extensions-mobile-hub`) instead of redirecting; tap **Apps**.\n- Direct URL `/apps`.\n- App pages are not in the command menu; only the rail (or a URL) reaches them.\n- App pages: the app's entry in the main sidebar rail (phone: the sidebar drawer, `sidebar-mobile-menu-toggle`), or the direct URL `/extensions/<name>/<path>[/<sub-path>]`.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) on the launcher's local Agent Server. No LLM is needed.\n- No app is installed: `control-openhands api GET /api/canvas-extensions/installed` returns `\"canvas_extensions\": []`.\n- Run commands from the checkout root: recipes install the bundled, reviewed fixture `$PWD/src/fixtures/canvas-extensions/demo-page` (name `demo-page`, display name `Demo page`, one page `hello` at `/hello`). Never install or enable arbitrary third-party code: enabling runs it inside Canvas.\n- Git bullets (tree URL, Ref and Path, busy lock, duplicate): the Agent Server can read `https://github.com/OpenHands/OpenHands` anonymously (read-only; no account needed), and that repo's `main` holds the same reviewed fixture at `src/fixtures/canvas-extensions/demo-page`. Check both before installing, with a read-only shell step: `git clone -q --depth 1 --filter=blob:none --sparse https://github.com/OpenHands/OpenHands \"$OH_VERIFY_RUN/private/qa-upstream\" && git -C \"$OH_VERIFY_RUN/private/qa-upstream\" sparse-checkout set src/fixtures/canvas-extensions && diff -r \"$OH_VERIFY_RUN/private/qa-upstream/src/fixtures/canvas-extensions/demo-page\" src/fixtures/canvas-extensions/demo-page && git -C \"$OH_VERIFY_RUN/private/qa-upstream\" rev-parse HEAD` must print no diff and a commit. Enable a Git-installed copy only when the card's **Ref** equals that commit. If GitHub is unreachable or the diff is not empty, these bullets are blocked (a local smart-HTTP Git server would do, but `control-openhands` has no verb for one; dumb HTTP such as `python3 -m http.server` fails the Agent Server's fetch).\n- Desktop viewport unless a bullet says otherwise.",
      "gotchas": "- The card's refresh button (`canvas-extension-refresh-<name>`) is labelled **Update**, not \"Refresh\".\n- The trust confirmation has no title and reuses the shared `confirmation-modal`; its body repeats the amber notice. Space on the disabled app's Enable switch focuses **Cancel**. Tab and Shift+Tab wrap between Cancel and Enable trusted app; Escape and Cancel close it and restore focus to the still-disabled Enable switch.\n- `nav_label` is dropped by the Agent Server: `GET /api/canvas-extensions/installed` returns pages with only `id`, `title` and `path` (Agent Server 1.50.1), so the rail shows the page title ([OpenHands/software-agent-sdk#5501](https://github.com/OpenHands/software-agent-sdk/issues/5501)). Keep `F20.rail-label` failing until the backend returns it.\n- Install and update errors show the server's reason as sent, even when it says `Failed to fetch extension from <url>` (an unreachable Git source). A response with no reason in it, such as the ingress's `502` while the Agent Server is stopped, shows the generic `An error occurred`. Only a request that gets no response at all shows `Disconnected (check URL or network)…`; no `control-openhands` verb takes the ingress offline, so that state is not driven here.\n- The page states (empty, error, unsupported) and the `/extensions/...` unavailable card have no test ids; assert on text with `browser snapshot`.\n- The Agent Server fetches Git sources with a shallow clone and appends `.git` to the repo URL: a test Git server must speak smart HTTP and answer both `qa-repo` and `qa-repo.git`.\n- The Git bullets read the public upstream repo, so they depend on its `main` still holding the identical fixture; the precondition's `diff -r` guards that. A Git install or Update clones in about 0.4 to 2 s here (Updates measured about 0.4 to 1.5 s on 2026-10-06), long enough for the busy-lock checks when they run in the same shell line as the click and read both controls in one `eval`; on a slower network raise the `wait` timeouts, not the expectations.\n- A duplicate install answers `409` with the Agent Server's API hint (`Canvas extension already installed. Use force=true to overwrite.`); the form has no force option, so the toast replaces it with the Update/Uninstall advice. Plugins give the same advice since #18110 (F19 Gotchas).\n- Busy states with a local-path source last about 50 ms: `--observe` catches `Installing…`, but checking that controls are disabled during Update needs a Git source (an upstream Update lasts about 0.4 to 2 s).\n- `control-openhands launch` passes the launcher only a fixed list of environment variables (path, locale, proxy) plus its own flags: `OH_AGENT_SERVER_VERSION=<old>` in the environment is ignored (the run still gets the pinned server); use `--sdk-ref v<version>`. Any server older than 1.47.0 is gated by Canvas before `/apps` renders.\n- Stopping the Agent Server makes the next full page load show the **Manage backends** gate; always `restart` and `doctor` afterwards.\n- A trailing `browser errors --app-only` sweep shows a `400` per failed install by design; count only other errors.\n- `App backend view unavailable` on the demo page is the expected text while `GET /server_info` lists no `canvas_app_backend_bridge_v1` capability (Agent Server 1.53.0); it does not mean the app failed to load. Cloud backends and the no-backend state never get a backend view either.\n- Known issue [OpenHands/software-agent-sdk#5648](https://github.com/OpenHands/software-agent-sdk/issues/5648) (`F20.app-backend-view`): app-backend WebSocket subprotocols are reported dropped. Recheck on a Linux Agent Server with configured public ingress and reviewed platform-matching backend artifacts, using a genuine admitted app-backend session and subprotocol negotiation. SDK 1.55.0 supports only Linux app-backend artifacts, so a native macOS server cannot drive this branch. A default demo without the bridge capability cannot exercise the proxied WebSocket path.\n- Known issue [OpenHands/software-agent-sdk#5935](https://github.com/OpenHands/software-agent-sdk/issues/5935) (`F20.app-backend-view`): an explicit `OH_APP_BACKEND_PUBLIC_URL_IS_NONE=1` does not clear a bridge URL set in the Agent Server JSON configuration. Canvas can therefore be offered an app-backend view at the unwanted configured ingress. Recheck an owned bridge-capable server with a JSON public URL and that environment-null override, then inspect `/server_info` and the real app page; the default launcher has neither conflicting configuration source.\n- Known issue [OpenHands/software-agent-sdk#5914](https://github.com/OpenHands/software-agent-sdk/issues/5914) (`F20.app-backend-view`): app-backend HTTP responses can carry both the backend's and the Agent Server's `Date` and `Server` headers, producing an invalid joined date and disclosing the backend stack. Recheck a reviewed HTTP app backend on a supported Linux Agent Server that sends these headers through a real admitted app-backend session. A default demo page without the bridge capability or a direct backend request cannot establish the proxied response.",
      "features": [
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F20--sidebar-entry-and-empty-page",
          "title": "Sidebar entry and empty page",
          "ids": [
            "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`.",
          "raw": "**Sidebar entry and empty page (`F20.entry-points`, `F20.page`).** 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`.",
          "source_line": 57,
          "expected": [
            "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.",
            "`/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.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--command-menu-entry",
          "title": "Command-menu entry",
          "ids": [
            "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`.",
          "raw": "**Command-menu entry (`F20.entry-points`).** 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`.",
          "source_line": 58,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--add-form-and-its-closers",
          "title": "Add form and its closers",
          "ids": [
            "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).",
          "raw": "**Add form and its closers (`F20.add-modal`).** 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).",
          "source_line": 59,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--install-error-local-path",
          "title": "Install error, local path",
          "ids": [
            "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`.",
          "raw": "**Install error, local path (`F20.install-error`).** 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`.",
          "source_line": 60,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--install-error-git-source",
          "title": "Install error, Git source",
          "ids": [
            "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.",
          "raw": "**Install error, Git source (`F20.install-error`).** 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.",
          "source_line": 61,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--install-the-demo",
          "title": "Install the demo",
          "ids": [
            "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`).",
          "raw": "**Install the demo (`F20.install`).** 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`).",
          "source_line": 62,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--card",
          "title": "Card",
          "ids": [
            "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`.",
          "raw": "**Card (`F20.card`).** 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`.",
          "source_line": 63,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--trust-confirmation",
          "title": "Trust confirmation",
          "ids": [
            "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`.",
          "raw": "**Trust confirmation (`F20.enable-confirm`).** 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`.",
          "source_line": 64,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--rail-entry",
          "title": "Rail entry",
          "ids": [
            "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).",
          "raw": "**Rail entry (`F20.rail-entry`, `F20.rail-label`).** 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).",
          "source_line": 65,
          "expected": [
            "an enabled app's page appears in the main sidebar rail as `sidebar-canvas-extension-<name>-<pageId>` linking to `/extensions/<name>/<path>`.",
            "the rail entry is labelled with the page's `nav_label` (`Extension demo` for the demo fixture), falling back to its title."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--render-the-page",
          "title": "Render the page",
          "ids": [
            "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`.",
          "raw": "**Render the page (`F20.page-render`).** 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`.",
          "source_line": 66,
          "expected": [
            "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`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--app-backend-view",
          "title": "App backend view",
          "ids": [
            "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`.",
          "raw": "**App backend view (`F20.app-backend-view`).** 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`.",
          "source_line": 67,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5648](https://github.com/OpenHands/software-agent-sdk/issues/5648) (`F20.app-backend-view`): app-backend WebSocket subprotocols are reported dropped. Recheck on a Linux Agent Server with configured public ingress and reviewed platform-matching backend artifacts, using a genuine admitted app-backend session and subprotocol negotiation. SDK 1.55.0 supports only Linux app-backend artifacts, so a native macOS server cannot drive this branch. A default demo without the bridge capability cannot exercise the proxied WebSocket path.",
            "Known issue [OpenHands/software-agent-sdk#5935](https://github.com/OpenHands/software-agent-sdk/issues/5935) (`F20.app-backend-view`): an explicit `OH_APP_BACKEND_PUBLIC_URL_IS_NONE=1` does not clear a bridge URL set in the Agent Server JSON configuration. Canvas can therefore be offered an app-backend view at the unwanted configured ingress. Recheck an owned bridge-capable server with a JSON public URL and that environment-null override, then inspect `/server_info` and the real app page; the default launcher has neither conflicting configuration source.",
            "Known issue [OpenHands/software-agent-sdk#5914](https://github.com/OpenHands/software-agent-sdk/issues/5914) (`F20.app-backend-view`): app-backend HTTP responses can carry both the backend's and the Agent Server's `Date` and `Server` headers, producing an invalid joined date and disclosing the backend stack. Recheck a reviewed HTTP app backend on a supported Linux Agent Server that sends these headers through a real admitted app-backend session. A default demo page without the bridge capability or a direct backend request cannot establish the proxied response."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F20--active-and-collapsed-rail-entry",
          "title": "Active and collapsed rail entry",
          "ids": [
            "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`.",
          "raw": "**Active and collapsed rail entry (`F20.rail-active`).** 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`.",
          "source_line": 68,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--unavailable-page",
          "title": "Unavailable page",
          "ids": [
            "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.",
          "raw": "**Unavailable page (`F20.page-unavailable`).** 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.",
          "source_line": 69,
          "expected": [
            "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.`"
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F20.phone`).** 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`.",
          "source_line": 70,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--update",
          "title": "Update",
          "ids": [
            "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`.",
          "raw": "**Update (`F20.refresh`).** 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`.",
          "source_line": 71,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--survives-a-restart",
          "title": "Survives a restart",
          "ids": [
            "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`.",
          "raw": "**Survives a restart (`F20.persist-restart`).** 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`.",
          "source_line": 72,
          "expected": [
            "installed apps, their enabled state and rail entries survive a stack restart."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--update-with-the-backend-down",
          "title": "Update with the backend down",
          "ids": [
            "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.",
          "raw": "**Update with the backend down (`F20.refresh`).** 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.",
          "source_line": 73,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--disable",
          "title": "Disable",
          "ids": [
            "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`.",
          "raw": "**Disable (`F20.disable`).** 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`.",
          "source_line": 74,
          "expected": [
            "switching an enabled app off happens immediately without confirmation, removes its rail entry and makes its page unavailable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--uninstall",
          "title": "Uninstall",
          "ids": [
            "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`.",
          "raw": "**Uninstall (`F20.uninstall`).** 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`.",
          "source_line": 75,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--local-source-plus-path",
          "title": "Local source plus Path",
          "ids": [
            "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).",
          "raw": "**Local source plus Path (`F20.install`).** 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).",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--git-tree-url",
          "title": "Git tree URL",
          "ids": [
            "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`.",
          "raw": "**Git tree URL (`F20.git-tree-url`).** 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`.",
          "source_line": 77,
          "expected": [
            "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**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--duplicate-install",
          "title": "Duplicate install",
          "ids": [
            "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`.",
          "raw": "**Duplicate install (`F20.install-duplicate`).** 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`.",
          "source_line": 78,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--busy-lock",
          "title": "Busy lock",
          "ids": [
            "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 `[]`.",
          "raw": "**Busy lock (`F20.busy-lock`).** 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 `[]`.",
          "source_line": 79,
          "expected": [
            "while Update, Uninstall or Enable is pending, every card control is inert, the switch included."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--ref-and-path-with-the-github-shorthand",
          "title": "Ref and Path with the `github:` shorthand",
          "ids": [
            "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.",
          "raw": "**Ref and Path with the `github:` shorthand (`F20.git-ref-path`).** 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.",
          "source_line": 80,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F20--list-error-with-retry",
          "title": "List error with Retry",
          "ids": [
            "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.",
          "raw": "**List error with Retry (`F20.load-error`).** 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.",
          "source_line": 81,
          "expected": [
            "when listing apps fails, the Installed section shows the error and a **Retry** button."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F20--unsupported-backends",
          "title": "Unsupported backends",
          "ids": [
            "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`).",
          "raw": "**Unsupported backends (`F20.unsupported`).** 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`).",
          "source_line": 82,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F20--malformed-tree-url-escapes",
          "title": "Malformed tree-URL escapes",
          "ids": [
            "F20.git-tree-url",
            "F20.install-error"
          ],
          "body": "On `/apps`, open `canvas-extensions-add-button`, fill `add-canvas-extension-source-input` with `https://github.com/o/r/tree/main%/apps/demo`, and click `add-canvas-extension-submit`. Inspect `browser errors --app-only`, `browser toasts --history`, and the modal count. Repeat with `https://gitlab.com/group/repo/-/tree/main/apps/%E0%A4%A`. Expected: submission produces a usable install/validation outcome. Known failure, reproduced 2026-10-12 on `fa1f724` with Agent Server 1.54.0: each submit raises `URI malformed`, emits no toast, skips the install request, and leaves the modal open ([#17783](https://github.com/OpenHands/OpenHands/issues/17783)). A reviewed local demo source still installs successfully as the control; no malformed source is executed.",
          "raw": "**Malformed tree-URL escapes (`F20.git-tree-url`, `F20.install-error`).** On `/apps`, open `canvas-extensions-add-button`, fill `add-canvas-extension-source-input` with `https://github.com/o/r/tree/main%/apps/demo`, and click `add-canvas-extension-submit`. Inspect `browser errors --app-only`, `browser toasts --history`, and the modal count. Repeat with `https://gitlab.com/group/repo/-/tree/main/apps/%E0%A4%A`. Expected: submission produces a usable install/validation outcome. Known failure, reproduced 2026-10-12 on `fa1f724` with Agent Server 1.54.0: each submit raises `URI malformed`, emits no toast, skips the install request, and leaves the modal open ([#17783](https://github.com/OpenHands/OpenHands/issues/17783)). A reviewed local demo source still installs successfully as the control; no malformed source is executed.",
          "source_line": 84,
          "expected": [
            "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**.",
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 on `fa1f724` with Agent Server 1.54.0: each submit raises `URI malformed`, emits no toast, skips the install request, and leaves the modal open ([#17783](https://github.com/OpenHands/OpenHands/issues/17783)). A reviewed local demo source still installs successfully as the control; no malformed source is executed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F20--extension-page-home-pin",
          "title": "Extension page home pin",
          "ids": [
            "F20.rail-entry"
          ],
          "body": "Install and enable the reviewed local `src/fixtures/canvas-extensions/demo-page` through the form and trust confirmation. Hover `sidebar-canvas-extension-demo-page-hello`, inspect `browser testids 'role=complementary' --filter pin`, and compare with hovering `sidebar-automations-link`. Expected: the enabled extension page offers **Pin as home page** and the normal home-pin lifecycle. Known failure, reproduced 2026-10-12 on `fa1f724`: only Automate exposes `sidebar-pin-home-toggle-automations`; the extension page has no pin control ([#16995](https://github.com/OpenHands/OpenHands/issues/16995)).",
          "raw": "**Extension page home pin (`F20.rail-entry`).** Install and enable the reviewed local `src/fixtures/canvas-extensions/demo-page` through the form and trust confirmation. Hover `sidebar-canvas-extension-demo-page-hello`, inspect `browser testids 'role=complementary' --filter pin`, and compare with hovering `sidebar-automations-link`. Expected: the enabled extension page offers **Pin as home page** and the normal home-pin lifecycle. Known failure, reproduced 2026-10-12 on `fa1f724`: only Automate exposes `sidebar-pin-home-toggle-automations`; the extension page has no pin control ([#16995](https://github.com/OpenHands/OpenHands/issues/16995)).",
          "source_line": 85,
          "expected": [
            "an enabled app's page appears in the main sidebar rail as `sidebar-canvas-extension-<name>-<pageId>` linking to `/extensions/<name>/<path>`."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 on `fa1f724`: only Automate exposes `sidebar-pin-home-toggle-automations`; the extension page has no pin control ([#16995](https://github.com/OpenHands/OpenHands/issues/16995))."
          ],
          "states": [
            "failure"
          ]
        }
      ]
    },
    {
      "id": "F21",
      "title": "Automations dashboard and actions",
      "file": "F21-automations-dashboard.md",
      "sha256": "ab4403758e3a3440fd9ed2f53e7a82b63dd9007a9b88828b370be1e60fca9750",
      "overview": "The Automate dashboard (`/automations`) is where a user sees every automation at\na glance and acts on one without opening it. It shows four overview tiles, a\nsearch box, a Filters popover (status, trigger, sort), a grid/list view toggle,\na **Drafts** group of saved setup drafts (Resume, Edit, Test, Delete), and Active\nand Inactive groups of cards or rows with run health, a recent-runs sparkline\nand stats. Each automation has a Play (Run now) button and a kebab menu with Run\nnow, View, Export, Edit, Turn on/off and Delete; Edit opens the setup page\n(`/automations/setup?automationId=<id>`, F22) in edit mode. The page sits in\na Dashboard/Templates sub-page shell. It is reached from the sidebar, the command\nmenu or a pinned home route, and it only exists when the automation interface\nmanifest from `@openhands/extensions` is admitted.",
      "entry": "- Sidebar **Automate** (`sidebar-automations-link`); hovering it reveals the pin (`sidebar-pin-home-toggle-automations`).\n- Direct URL `/automations`; the Templates sub-page is `/automations/templates` (F22).\n- Command menu (`Control+k`/`Meta+k` or `command-menu-trigger`): type `Automations` or a keyword such as `cron`, press Enter.\n- With Automate pinned, `/` (logo, a bare visit) lands on `/automations`.\n- Sub-page navigation: `automations-navbar-desktop` (aside) or `automations-navbar-mobile` (phone strip), links `automations-navigation-list` and `automations-navigation-templates`.\n- Header **Add Automation** (`automations-add-automation`) → **Create Automation** (`automations-add-automation-create`, `/automations/setup`, F22) or **Import automation** (`automations-import-automation`, F22); on the empty dashboard, the Recommended automations rail cards (`recommended-automation-rail-card-<id>`).\n- Getting-started checklist item **Schedule a task** links to `/automations` (onboarding checklist family).\n- Saved drafts: the **Drafts** group rows (`automation-setup-draft-resume-<draft-id>`, `-edit-`, `-test-`, `-delete-`).\n- Per automation: Play (`automation-run-now-<id>`, name \"Run now\") and kebab (name \"Automation actions\") on each card or row; the card itself and the row link open the detail page (F23).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) with an active LLM profile for Run now (`control-openhands llm preset deepseek`, or `--api-key-file <file>` when the key is in a file).\n- No automations exist (`control-openhands api GET /api/automation/v1` shows `\"total\": 0`); the recipes start from the empty state.\n- Fixtures are imported through **Add Automation → Import automation** (F22 owns that flow; here it only arranges). Create the files first; each command prints the path to upload (`$OH_VERIFY_RUN/evidence/_fixtures/<name>`):\n  `control-openhands fixture file --name qa-f21-alpha.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F21 Alpha\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":false,\"prompt\":\"Reply with the single word: pong. Do not run any tools.\"}}'`,\n  `control-openhands fixture file --name qa-f21-beta.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F21 Beta\",\"trigger\":{\"type\":\"event\",\"source\":\"github\",\"on\":\"pull_request.opened\"},\"enabled\":false,\"prompt\":\"Beta event-driven QA fixture; never runs.\"}}'`,\n  `control-openhands fixture file --name qa-f21-fail.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F21 Fail\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":false,\"prompt\":\"Reply with the single word: pong.\",\"timeout\":1}}'`.\n  The yearly cron (`0 0 1 1 *`) keeps a turned-on fixture from firing on its own.\n- Selectors below avoid run-specific ids: `[data-testid^=\"automation-card-\"] >> has-text=<name>` (grid) and `[data-testid^=\"automation-list-row-\"] >> has-text=<name>` (list). The kebab menu renders in a portal with no test ids; scope its items as `role=list >> has-text=Export >> role=button[name=\"...\"]`.",
      "gotchas": "- Both sub-page navs are always in the DOM (one hidden by CSS). Unscoped `testid=automations-navigation-templates` is a strict-mode violation; scope it with `testid=automations-navbar-desktop >> ` or `testid=automations-navbar-mobile >> `.\n- The pin toggle is invisible until the Automate item is hovered on a fine pointer: `browser hover 'testid=sidebar-automations-link'` before clicking it. The pin is stored per backend and org in `localStorage` (`oh:pinned-home-route:<backend>:<org>`); unpin before leaving the run.\n- The Filters popover's status and trigger choices do not apply to the Drafts group: with status Active and trigger Event-driven, a cron draft stays listed and the filtered empty state does not show (search does filter drafts). Intent undocumented in #17242.\n- The setup page's success toast (`Automation updated`) covers its toolbar's Test, Save and Close and does not time out while the pointer rests on it; a click there fails with \"subtree intercepts pointer events\". Move the pointer away (`browser hover` on a form field), wait about 5 s, then click.\n- `testid=dropdown-trigger` is reused by the backend selector, the Filters button and every inner dropdown. Always scope it (`testid=automations-filters >> testid=dropdown-trigger >> nth=0` for the Filters button, `testid=automations-filter-status >> testid=dropdown-trigger` for a field).\n- The Filters popover stays open while choices change and closes only on a click outside it or on the Filters button; Escape does not close it. Open, it covers the right part of the list (in list view the first rows' Play buttons): close it with a click on `role=heading[name=\"Dashboard\"]` before clicking row or card controls.\n- A card whose automation was deleted elsewhere stays on the dashboard after its Run now fails with `Automation not found`. Expected: the card drops out once the dispatch returns 404. Known failure: nothing refetches the list after the failed dispatch, so the card stays until a reload or another list refetch (still there 2 minutes later) ([#18062](https://github.com/OpenHands/OpenHands/issues/18062)).\n- The delete confirmation has no test ids; scope it as the named dialog `role=dialog[name=\"Delete automation\"]`. It opens with focus on Cancel.\n- Delete closes the dialog at once and shows no success toast; confirm with a reload. Turn off on the dashboard never asks for confirmation (home cards do).\n- The kebab menu has no test ids; `role=list >> has-text=Export` is the reliable scope. A card's own **Run now** has the same accessible name as the kebab item, so scope Play to the card.\n- Imported automations are always disabled (`Import automation` says so), and Run now is disabled until you **Turn on**. Use the yearly cron `0 0 1 1 *` or an event trigger so an enabled fixture never fires by itself, and turn it back off.\n- Run now uses the active LLM profile when the automation's model is `Active profile`. A successful run creates a conversation titled `<automation name> — <UTC timestamp>` that stays after the automation is deleted. Delete it separately (`control-openhands conversation list`, conversations family) to leave the run clean.\n- A timeout of 1 s does not fail fast: the run sat in `Installing dependencies` and failed with `Timed out: command timed out or was killed` about 3-4 minutes after dispatch. Budget for that when you need a failing run for Needs attention.\n- The Automations tile counts only loaded pages: with 51 automations it reads `50` until **Load more** is clicked, although the API `total` is 51.\n- The row health dot and label reflect the latest run only; a disabled automation with no runs reads `No activity yet`, not `Disabled`.\n- In grid view a cron automation without `schedule_human` shows the pill `cron`, while list view shows the raw expression (`0 0 1 1 *`).\n- The view mode persists in `localStorage` (`openhands-automations-view`), and the toggle is disabled when the org has no automations. Switch back to Grid before deleting the last automation, or the next run starts in List.\n- Filters, search and sort are page state only: a reload resets them.\n- `service stop automation` stops only this run's automation service; `restart` brings the whole stack back with the same state and the browser on the same page. Run `control-openhands doctor` afterwards.\n- The **Automation dispatched** toast lasts only a few seconds: run `browser wait-text 'Automation dispatched'` right after the click, not after a long `--observe` window.\n- `browser screenshot --full-page` at phone width does not capture below the fold, because the page scrolls inside the main pane; screenshot sections or use `bbox`.\n- Known issue [OpenHands/automation#246](https://github.com/OpenHands/automation/issues/246) (`F21.backend-unavailable`, `F21.list-error`): The report says only that local automation returns 404. It omits the URL, UI action, release and launcher/service setup, so a normal successful dashboard visit would not reproduce or resolve its unknown branch. Ask for those exact prerequisites before writing a failing recipe.\n- Known issue [OpenHands/automation#347](https://github.com/OpenHands/automation/issues/347) (`F21.card-grid`, `F21.overview-tiles`): per-automation run-summary reads lack an aggregate contract, and the report describes a 30 s shared-resource stall with HTTP 500 plus SQLite pool settings that do not apply. A failing recipe needs the original database/lock workload and redacted service-log/HAR timing evidence, which the report withholds. Successful summary reads under the Canvas request throttle do not establish those backend contracts.",
      "features": [
        {
          "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 all Automate entry points, including the Home checklist and recommendations, are hidden without error toasts. Known failure: Canvas hides the sidebar entry but leaves a dead Home checklist link and raises two manifest errors ([#18210](https://github.com/OpenHands/OpenHands/issues/18210); [draft fix PR #18212](https://github.com/OpenHands/OpenHands/pull/18212))."
        },
        {
          "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 guided setup page at `/automations/setup` (F22)."
        },
        {
          "id": "F21.empty-state",
          "description": "with zero automations and zero saved drafts the page shows **No automations configured**, the \"Create an automation\" instructions, a docs link, **Create Automation** (opens `/automations/setup`, F22, without creating a conversation) 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 automations the groups list from the loaded org list (hidden DRAFT test automations from a draft Test are left out): 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, and the saved drafts by name, prompt, kind or trigger; 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 setup page in edit mode (`/automations/setup?automationId=<id>`) prefilled from the automation, with **Test**, **Save** and **Close** in its toolbar; Save sends one PATCH and toasts **Automation updated**, Close returns to `/automations` (field-level editing is F23). For a script (tarball) automation it should summarize the saved bundle (**Existing code bundle**). Known failure (reproduced 2026-10-13, #18341): from the kebab it shows generated starter Python and a default setup script instead; a cold load of the same URL shows the bundle summary."
        },
        {
          "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.saved-drafts",
          "description": "saved setup drafts (#17242) appear in a **Drafts** group above Active/Inactive, one row each with the name (Resume), a pencil (**Edit**), Play (**Test draft**, tooltip **Test Run**; disabled for event-triggered or non-dispatchable drafts) and a trash (**Delete draft**). Resume and Edit open `/automations/setup?draftId=<id>`; Test dispatches a test run (toast **Test run started.**) on a materialized DRAFT-state automation that the groups do not list; Delete asks **Delete draft?** (`Delete “<name>”? This removes the saved setup draft, but does not delete any final automations.`) with Cancel / **Delete draft** and toasts **Draft deleted.**, also removing the draft's materialized test automation. The confirmation is the named modal dialog **Delete draft?** (the automation delete dialog with draft copy): it opens with focus on Cancel, keeps Tab inside, closes on Escape, X, Cancel or a backdrop click and returns focus to the trash button."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F21--sidebar-entry",
          "title": "Sidebar entry",
          "ids": [
            "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.",
          "raw": "**Sidebar entry (`F21.sidebar-entry`).** 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.",
          "source_line": 80,
          "expected": [
            "the sidebar **Automate** item opens `/automations` with the heading **Dashboard** and the Automate sub-page aside."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--command-menu",
          "title": "Command menu",
          "ids": [
            "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`.",
          "raw": "**Command menu (`F21.command-menu`).** 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`.",
          "source_line": 81,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--pin-as-home",
          "title": "Pin as home",
          "ids": [
            "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 `/`.",
          "raw": "**Pin as home (`F21.pin-home`).** 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 `/`.",
          "source_line": 82,
          "expected": [
            "the pin on the sidebar Automate item makes `/` redirect to `/automations`; unpinning restores the normal home."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--sub-page-navigation",
          "title": "Sub-page navigation",
          "ids": [
            "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`.",
          "raw": "**Sub-page navigation (`F21.subpage-nav`).** 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`.",
          "source_line": 83,
          "expected": [
            "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\"`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--backend-unavailable",
          "title": "Backend unavailable",
          "ids": [
            "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.",
          "raw": "**Backend unavailable (`F21.backend-unavailable`).** 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.",
          "source_line": 84,
          "expected": [
            "when the health check fails, the page shows **Automations Unavailable** with a Retry button."
          ],
          "notes": [
            "Known issue [OpenHands/automation#246](https://github.com/OpenHands/automation/issues/246) (`F21.backend-unavailable`, `F21.list-error`): The report says only that local automation returns 404. It omits the URL, UI action, release and launcher/service setup, so a normal successful dashboard visit would not reproduce or resolve its unknown branch. Ask for those exact prerequisites before writing a failing recipe."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F21--list-error",
          "title": "List error",
          "ids": [
            "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.",
          "raw": "**List error (`F21.list-error`).** 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.",
          "source_line": 85,
          "expected": [
            "when the list request fails with nothing cached, the page shows **Failed to load automations** with Retry."
          ],
          "notes": [
            "Known issue [OpenHands/automation#246](https://github.com/OpenHands/automation/issues/246) (`F21.backend-unavailable`, `F21.list-error`): The report says only that local automation returns 404. It omits the URL, UI action, release and launcher/service setup, so a normal successful dashboard visit would not reproduce or resolve its unknown branch. Ask for those exact prerequisites before writing a failing recipe."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F21--list-loading",
          "title": "List loading",
          "ids": [
            "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.",
          "raw": "**List loading (`F21.list-loading`).** 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.",
          "source_line": 86,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--health-loading",
          "title": "Health loading",
          "ids": [
            "F21.health-loading"
          ],
          "body": "Blocked on this run. 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.",
          "raw": "**Health loading (`F21.health-loading`).** Blocked on this run. 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.",
          "source_line": 87,
          "expected": [
            "while the health check runs, the page shows the title and three card skeletons."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--missing-manifest",
          "title": "Missing manifest",
          "ids": [
            "F21.manifest-404"
          ],
          "body": "Use an isolated worktree at the frozen target and a private copy of its pinned `@openhands/extensions` package. Remove only the `AUTOMATION_INTERFACE` export from the copied `automations/index.js` before `npm run build:app`; leave Canvas source and the lockfile unchanged. Serve that frontend against an owned healthy Local backend. Open `/` and `/automations`: the sidebar Automate link should be absent, `/automations` should render 404, and Home should have no **Schedule a task** link, recommendation section, or no-manifest error toast. On `main@3500d9e5` the route/sidebar checks pass but Home shows the dead link and two `No automation interface manifest is admitted` toasts ([#18210](https://github.com/OpenHands/OpenHands/issues/18210); [draft fix PR #18212](https://github.com/OpenHands/OpenHands/pull/18212)). Record the alternate build as fault injection; the unmodified pinned package does include the manifest. Stop the private frontend and remove only this test worktree/build afterward.",
          "raw": "**Missing manifest (`F21.manifest-404`).** Use an isolated worktree at the frozen target and a private copy of its pinned `@openhands/extensions` package. Remove only the `AUTOMATION_INTERFACE` export from the copied `automations/index.js` before `npm run build:app`; leave Canvas source and the lockfile unchanged. Serve that frontend against an owned healthy Local backend. Open `/` and `/automations`: the sidebar Automate link should be absent, `/automations` should render 404, and Home should have no **Schedule a task** link, recommendation section, or no-manifest error toast. On `main@3500d9e5` the route/sidebar checks pass but Home shows the dead link and two `No automation interface manifest is admitted` toasts ([#18210](https://github.com/OpenHands/OpenHands/issues/18210); [draft fix PR #18212](https://github.com/OpenHands/OpenHands/pull/18212)). Record the alternate build as fault injection; the unmodified pinned package does include the manifest. Stop the private frontend and remove only this test worktree/build afterward.",
          "source_line": 88,
          "expected": [
            "without an admitted automation interface manifest, `/automations` is a 404 from the layout error boundary, and all Automate entry points, including the Home checklist and recommendations, are hidden without error toasts."
          ],
          "notes": [],
          "annotations": [
            "Known failure: Canvas hides the sidebar entry but leaves a dead Home checklist link and raises two manifest errors ([#18210](https://github.com/OpenHands/OpenHands/issues/18210); [draft fix PR #18212](https://github.com/OpenHands/OpenHands/pull/18212))."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F21--empty-state",
          "title": "Empty state",
          "ids": [
            "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`. `control-openhands browser click 'testid=automations-empty >> testid=automations-create-automation' --expect-url '/automations/setup'` opens the setup page (form plus docked composer, F22) and `control-openhands conversation list` is unchanged (no conversation until the user sends a prompt). The empty-state copy still says \"Start a new conversation and tell OpenHands to Create an automation\".",
          "raw": "**Empty state (`F21.empty-state`).** 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`. `control-openhands browser click 'testid=automations-empty >> testid=automations-create-automation' --expect-url '/automations/setup'` opens the setup page (form plus docked composer, F22) and `control-openhands conversation list` is unchanged (no conversation until the user sends a prompt). The empty-state copy still says \"Start a new conversation and tell OpenHands to Create an automation\".",
          "source_line": 89,
          "expected": [
            "with zero automations and zero saved drafts the page shows **No automations configured**, the \"Create an automation\" instructions, a docs link, **Create Automation** (opens `/automations/setup`, F22, without creating a conversation) and the Recommended automations rail; the view toggle is disabled."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--add-automation-menu",
          "title": "Add Automation menu",
          "ids": [
            "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 and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`: the agent-assisted setup page opens. Go back to `/automations`, reopen the menu, and `control-openhands browser click 'testid=automations-import-automation'`: the import modal is covered by F22.",
          "raw": "**Add Automation menu (`F21.add-menu`).** 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 and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`: the agent-assisted setup page opens. Go back to `/automations`, reopen the menu, and `control-openhands browser click 'testid=automations-import-automation'`: the import modal is covered by F22.",
          "source_line": 90,
          "expected": [
            "the header **Add Automation** menu offers **Create Automation** and **Import automation**; Create Automation opens the guided setup page at `/automations/setup` (F22)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--recommended-rail-on-the-empty-dashboard",
          "title": "Recommended rail on the empty dashboard",
          "ids": [
            "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).",
          "raw": "**Recommended rail on the empty dashboard (`F21.empty-rail`).** 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).",
          "source_line": 91,
          "expected": [
            "a Recommended automations card on the empty dashboard opens its template setup at `/automations/new/<template-id>` (F22 owns the setup flow)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--saved-drafts",
          "title": "Saved drafts",
          "ids": [
            "F21.saved-drafts",
            "F21.search"
          ],
          "body": "Arrange one draft through the setup page (F22's **Open and save a draft**: name `QA_F22 Delta Draft`, prompt `Reply pong. Do not run any tools.`, custom cron `0 0 1 1 *`, Save draft), then `control-openhands browser goto /automations`. `control-openhands browser text 'role=main'` lists `Drafts 1` / `QA_F22 Delta Draft` above the groups, and `control-openhands browser testids 'role=main' --filter draft` lists `automation-setup-draft-<draft-id>`, `-resume-`, `-open-`, `-edit-` (`Edit`), `-test-` (`Test draft`) and `-delete-` (`Delete draft`). `control-openhands browser tooltip 'testid=automation-setup-draft-test-<draft-id>'` is `Test Run`; clicking it toasts `Test run started.` and the draft (`api GET /api/automation/v1/drafts/<draft-id>`) gains `materialized_automation_id` and `last_test_run_id`; that automation has `state` `DRAFT`, is not listed in any group, and its detail page shows the Draft badge (F23.header). Once its run is `COMPLETED` (poll `api GET '/api/automation/v1/<materialized-id>/runs?limit=10'`), `control-openhands browser reload` leaves the tiles at `Automations 0 / 0 active`, `Total runs 0` and `Average duration —` (`browser text 'testid=overview-tile-total-runs'`): the test automation is not counted. `testid=automation-setup-draft-resume-<draft-id>` and `testid=automation-setup-draft-edit-<draft-id>` each open `/automations/setup?draftId=<draft-id>` (`--expect-url '/automations/setup'`) with the saved values. Search narrows drafts too: filling `Delta` leaves 0 rows and 1 draft; `zzqa-nomatch` shows the filtered empty state with no draft. Delete: click `testid=automation-setup-draft-delete-<draft-id>`; `control-openhands browser snapshot 'role=dialog[name=\"Delete draft?\"]'` shows `button \"Close\"`, heading `Delete draft?`, `Delete “QA_F22 Delta Draft”? This removes the saved setup draft, but does not delete any final automations.`, `Cancel` and `Delete draft`, and `control-openhands browser eval \"document.activeElement.textContent\"` is `Cancel`; `browser press Tab` (repeated) cycles Delete draft, Close and Cancel without leaving the dialog. `control-openhands browser press Escape` makes `browser count 'role=dialog[name=\"Delete draft?\"]'` `0` with the draft kept and focus back on `automation-setup-draft-delete-<draft-id>`; reopening and closing with `'role=dialog[name=\"Delete draft?\"] >> role=button[name=\"Close\"]'`, `... role=button[name=\"Cancel\"]` and a backdrop click (`control-openhands browser mouse-click 1400 950`) each give `0` too. At phone width the confirm button is `insideViewport` (`browser bbox 'testid=automation-setup-draft-delete-confirm'`). `control-openhands browser click 'testid=automation-setup-draft-delete-confirm'` toasts `Draft deleted.`; drafts `total` is `0` and the materialized test automation answers `404`.",
          "raw": "**Saved drafts (`F21.saved-drafts`, `F21.search`).** Arrange one draft through the setup page (F22's **Open and save a draft**: name `QA_F22 Delta Draft`, prompt `Reply pong. Do not run any tools.`, custom cron `0 0 1 1 *`, Save draft), then `control-openhands browser goto /automations`. `control-openhands browser text 'role=main'` lists `Drafts 1` / `QA_F22 Delta Draft` above the groups, and `control-openhands browser testids 'role=main' --filter draft` lists `automation-setup-draft-<draft-id>`, `-resume-`, `-open-`, `-edit-` (`Edit`), `-test-` (`Test draft`) and `-delete-` (`Delete draft`). `control-openhands browser tooltip 'testid=automation-setup-draft-test-<draft-id>'` is `Test Run`; clicking it toasts `Test run started.` and the draft (`api GET /api/automation/v1/drafts/<draft-id>`) gains `materialized_automation_id` and `last_test_run_id`; that automation has `state` `DRAFT`, is not listed in any group, and its detail page shows the Draft badge (F23.header). Once its run is `COMPLETED` (poll `api GET '/api/automation/v1/<materialized-id>/runs?limit=10'`), `control-openhands browser reload` leaves the tiles at `Automations 0 / 0 active`, `Total runs 0` and `Average duration —` (`browser text 'testid=overview-tile-total-runs'`): the test automation is not counted. `testid=automation-setup-draft-resume-<draft-id>` and `testid=automation-setup-draft-edit-<draft-id>` each open `/automations/setup?draftId=<draft-id>` (`--expect-url '/automations/setup'`) with the saved values. Search narrows drafts too: filling `Delta` leaves 0 rows and 1 draft; `zzqa-nomatch` shows the filtered empty state with no draft. Delete: click `testid=automation-setup-draft-delete-<draft-id>`; `control-openhands browser snapshot 'role=dialog[name=\"Delete draft?\"]'` shows `button \"Close\"`, heading `Delete draft?`, `Delete “QA_F22 Delta Draft”? This removes the saved setup draft, but does not delete any final automations.`, `Cancel` and `Delete draft`, and `control-openhands browser eval \"document.activeElement.textContent\"` is `Cancel`; `browser press Tab` (repeated) cycles Delete draft, Close and Cancel without leaving the dialog. `control-openhands browser press Escape` makes `browser count 'role=dialog[name=\"Delete draft?\"]'` `0` with the draft kept and focus back on `automation-setup-draft-delete-<draft-id>`; reopening and closing with `'role=dialog[name=\"Delete draft?\"] >> role=button[name=\"Close\"]'`, `... role=button[name=\"Cancel\"]` and a backdrop click (`control-openhands browser mouse-click 1400 950`) each give `0` too. At phone width the confirm button is `insideViewport` (`browser bbox 'testid=automation-setup-draft-delete-confirm'`). `control-openhands browser click 'testid=automation-setup-draft-delete-confirm'` toasts `Draft deleted.`; drafts `total` is `0` and the materialized test automation answers `404`.",
          "source_line": 92,
          "expected": [
            "saved setup drafts (#17242) appear in a **Drafts** group above Active/Inactive, one row each with the name (Resume), a pencil (**Edit**), Play (**Test draft**, tooltip **Test Run**; disabled for event-triggered or non-dispatchable drafts) and a trash (**Delete draft**). Resume and Edit open `/automations/setup?draftId=<id>`; Test dispatches a test run (toast **Test run started.**) on a materialized DRAFT-state automation that the groups do not list; Delete asks **Delete draft?** (`Delete “<name>”? This removes the saved setup draft, but does not delete any final automations.`) with Cancel / **Delete draft** and toasts **Draft deleted.**, also removing the draft's materialized test automation. The confirmation is the named modal dialog **Delete draft?** (the automation delete dialog with draft copy): it opens with focus on Cancel, keeps Tab inside, closes on Escape, X, Cancel or a backdrop click and returns focus to the trash button.",
            "the search box narrows the visible automations client-side by name, prompt, repository or model, and the saved drafts by name, prompt, kind or trigger; the tiles do not change."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--arrange-two-automations-import-f22",
          "title": "Arrange two automations (import, F22)",
          "ids": [],
          "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**.",
          "raw": "**Arrange two automations (import, F22).** 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**.",
          "source_line": 93,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F21--grid-cards",
          "title": "Grid cards",
          "ids": [
            "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]`.",
          "raw": "**Grid cards (`F21.card-grid`).** 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]`.",
          "source_line": 94,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/automation#347](https://github.com/OpenHands/automation/issues/347) (`F21.card-grid`, `F21.overview-tiles`): per-automation run-summary reads lack an aggregate contract, and the report describes a 30 s shared-resource stall with HTTP 500 plus SQLite pool settings that do not apply. A failing recipe needs the original database/lock workload and redacted service-log/HAR timing evidence, which the report withholds. Successful summary reads under the Canvas request throttle do not establish those backend contracts."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F21--kebab-menu",
          "title": "Kebab menu",
          "ids": [
            "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.",
          "raw": "**Kebab menu (`F21.kebab-menu`).** 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.",
          "source_line": 95,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--turn-on",
          "title": "Turn on",
          "ids": [
            "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`).",
          "raw": "**Turn on (`F21.toggle`).** 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`).",
          "source_line": 96,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--run-now-from-play",
          "title": "Run now from Play",
          "ids": [
            "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`).",
          "raw": "**Run now from Play (`F21.run-now`).** 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`).",
          "source_line": 97,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--list-view",
          "title": "List view",
          "ids": [
            "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`).",
          "raw": "**List view (`F21.view-toggle`, `F21.list-row`).** 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`).",
          "source_line": 98,
          "expected": [
            "the view-mode menu switches Grid and List; the choice survives a reload.",
            "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>`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--hover-previews",
          "title": "Hover previews",
          "ids": [
            "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.",
          "raw": "**Hover previews (`F21.row-tooltip`, `F21.summary-hovercard`).** 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.",
          "source_line": 99,
          "expected": [
            "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**.",
            "hovering a grid card's truncated run summary shows the full summary in a hovercard."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--sparkline-deep-link",
          "title": "Sparkline deep link",
          "ids": [
            "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`.",
          "raw": "**Sparkline deep link (`F21.sparkline-deeplink`).** 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`.",
          "source_line": 100,
          "expected": [
            "a sparkline bar links to `/automations/<id>?run=<runId>`, which opens the detail page with that run highlighted."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--search",
          "title": "Search",
          "ids": [
            "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`.",
          "raw": "**Search (`F21.search`).** 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`.",
          "source_line": 101,
          "expected": [
            "the search box narrows the visible automations client-side by name, prompt, repository or model, and the saved drafts by name, prompt, kind or trigger; the tiles do not change."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--filtered-empty-and-clear-filters",
          "title": "Filtered empty and Clear filters",
          "ids": [
            "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`).",
          "raw": "**Filtered empty and Clear filters (`F21.filtered-empty`).** 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`).",
          "source_line": 102,
          "expected": [
            "when nothing matches, **No automations match these filters** and **Clear filters** appear; Clear filters resets search, status and trigger but keeps the sort."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--filters",
          "title": "Filters",
          "ids": [
            "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.",
          "raw": "**Filters (`F21.filters`).** 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.",
          "source_line": 103,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--turn-off",
          "title": "Turn off",
          "ids": [
            "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`.",
          "raw": "**Turn off (`F21.toggle`).** 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`.",
          "source_line": 104,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--edit-from-the-dashboard",
          "title": "Edit from the dashboard",
          "ids": [
            "F21.edit"
          ],
          "body": "Turn off (above) left both automations in the Inactive group, as Sort needs. Open Alpha's kebab and `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Edit\"]' --expect-url '/automations/setup'`: the URL is `/automations/setup?automationId=<id>`, `control-openhands browser value 'testid=automation-setup-name'` is `QA_F21 Alpha`, `automation-setup-prompt` holds the prompt, `automation-setup-custom-schedule` is `0 0 1 1 *` (Frequency Custom, timezone UTC), Timeout `600`, and `browser testids 'testid=automation-setup-toolbar'` lists `automation-setup-test`, `automation-setup-save` and `automation-setup-close`; `control-openhands conversation list` is unchanged. Run `control-openhands browser fill 'testid=automation-setup-name' 'QA_F21 Zulu'`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=automation-setup-save'` and `control-openhands browser toasts --history` (`Automation updated`); `control-openhands browser network --bodies --filter 'automation/v1'` shows one `PATCH /api/automation/v1/<id>` with `name`, `trigger`, `model: null` (Active profile kept), `agent_profile_id: null`, `timeout` and `prompt`, and `control-openhands api GET /api/automation/v1` shows `QA_F21 Zulu` with the schedule unchanged. The toast sits over the toolbar and stays while the pointer rests on it (see Gotchas): run `control-openhands browser hover 'testid=automation-setup-name'` and wait about 5 s, then `control-openhands browser click 'testid=automation-setup-close' --expect-url '/automations$'`. At phone width the same kebab Edit on QA_F21 Beta (event trigger) shows Event selected with Event source GitHub and no horizontal overflow (`browser bbox 'testid=automation-setup-form'`). Script automation: Known failure (reproduced 2026-10-13, #18341): for a tarball automation (create one with F22's Upload tarball bullet), kebab Edit shows `Custom Python` with generated starter code (`browser value 'testid=automation-setup-custom-code'` contains `def fire_callback` and an empty prompt) and `browser count 'testid=automation-setup-existing-bundle'` is `0`; expected the **Existing code bundle** summary, which a cold `browser goto '/automations/setup?automationId=<id>'` does show.",
          "raw": "**Edit from the dashboard (`F21.edit`).** Turn off (above) left both automations in the Inactive group, as Sort needs. Open Alpha's kebab and `control-openhands browser click 'role=list >> has-text=Export >> role=button[name=\"Edit\"]' --expect-url '/automations/setup'`: the URL is `/automations/setup?automationId=<id>`, `control-openhands browser value 'testid=automation-setup-name'` is `QA_F21 Alpha`, `automation-setup-prompt` holds the prompt, `automation-setup-custom-schedule` is `0 0 1 1 *` (Frequency Custom, timezone UTC), Timeout `600`, and `browser testids 'testid=automation-setup-toolbar'` lists `automation-setup-test`, `automation-setup-save` and `automation-setup-close`; `control-openhands conversation list` is unchanged. Run `control-openhands browser fill 'testid=automation-setup-name' 'QA_F21 Zulu'`, `control-openhands browser network --clear`, `control-openhands browser click 'testid=automation-setup-save'` and `control-openhands browser toasts --history` (`Automation updated`); `control-openhands browser network --bodies --filter 'automation/v1'` shows one `PATCH /api/automation/v1/<id>` with `name`, `trigger`, `model: null` (Active profile kept), `agent_profile_id: null`, `timeout` and `prompt`, and `control-openhands api GET /api/automation/v1` shows `QA_F21 Zulu` with the schedule unchanged. The toast sits over the toolbar and stays while the pointer rests on it (see Gotchas): run `control-openhands browser hover 'testid=automation-setup-name'` and wait about 5 s, then `control-openhands browser click 'testid=automation-setup-close' --expect-url '/automations$'`. At phone width the same kebab Edit on QA_F21 Beta (event trigger) shows Event selected with Event source GitHub and no horizontal overflow (`browser bbox 'testid=automation-setup-form'`). Script automation: Known failure (reproduced 2026-10-13, #18341): for a tarball automation (create one with F22's Upload tarball bullet), kebab Edit shows `Custom Python` with generated starter code (`browser value 'testid=automation-setup-custom-code'` contains `def fire_callback` and an empty prompt) and `browser count 'testid=automation-setup-existing-bundle'` is `0`; expected the **Existing code bundle** summary, which a cold `browser goto '/automations/setup?automationId=<id>'` does show.",
          "source_line": 105,
          "expected": [
            "kebab **Edit** opens the setup page in edit mode (`/automations/setup?automationId=<id>`) prefilled from the automation, with **Test**, **Save** and **Close** in its toolbar; Save sends one PATCH and toasts **Automation updated**, Close returns to `/automations` (field-level editing is F23). For a script (tarball) automation it should summarize the saved bundle (**Existing code bundle**)."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-13, #18341): for a tarball automation (create one with F22's Upload tarball bullet), kebab Edit shows `Custom Python` with generated starter code (`browser value 'testid=automation-setup-custom-code'` contains `def fire_callback` and an empty prompt) and `browser count 'testid=automation-setup-existing-bundle'` is `0`; expected the **Existing code bundle** summary, which a cold `browser goto '/automations/setup?automationId=<id>'` does show.",
            "Known failure (reproduced 2026-10-13, #18341): from the kebab it shows generated starter Python and a default setup script instead; a cold load of the same URL shows the bundle summary."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F21--sort",
          "title": "Sort",
          "ids": [
            "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`).",
          "raw": "**Sort (`F21.sort`).** 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`).",
          "source_line": 106,
          "expected": [
            "Sort (Latest run / Most runs / Name) orders automations inside each group and counts as a non-default choice."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--export",
          "title": "Export",
          "ids": [
            "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.",
          "raw": "**Export (`F21.export`).** 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.",
          "source_line": 107,
          "expected": [
            "kebab **Export** downloads `<name-slug>.automation.json` with `version`, `kind: \"automation\"` and the spec."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--open-the-detail-page",
          "title": "Open the detail page",
          "ids": [
            "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`.",
          "raw": "**Open the detail page (`F21.open-detail`).** 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`.",
          "source_line": 108,
          "expected": [
            "kebab **View**, the row link, a card click and Enter on a focused card all open `/automations/<id>`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--git-sync-button",
          "title": "Git Sync button",
          "ids": [
            "F21.git-sync-button"
          ],
          "body": "`control-openhands browser click 'testid=automations-git-sync'`, then `control-openhands browser url`: `/automations/git-sync` (F24).",
          "raw": "**Git Sync button (`F21.git-sync-button`).** `control-openhands browser click 'testid=automations-git-sync'`, then `control-openhands browser url`: `/automations/git-sync` (F24).",
          "source_line": 109,
          "expected": [
            "the header **Git Sync** button (manage permission; always on local) opens `/automations/git-sync` (see F24)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--needs-attention-kebab-run-now",
          "title": "Needs attention, kebab Run now",
          "ids": [
            "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.",
          "raw": "**Needs attention, kebab Run now (`F21.overview-tiles`, `F21.run-now`, `F21.filters`).** 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.",
          "source_line": 110,
          "expected": [
            "four tiles summarize the automations the groups list from the loaded org list (hidden DRAFT test automations from a draft Test are left out): 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.",
            "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.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/automation#347](https://github.com/OpenHands/automation/issues/347) (`F21.card-grid`, `F21.overview-tiles`): per-automation run-summary reads lack an aggregate contract, and the report describes a 30 s shared-resource stall with HTTP 500 plus SQLite pool settings that do not apply. A failing recipe needs the original database/lock workload and redacted service-log/HAR timing evidence, which the report withholds. Successful summary reads under the Canvas request throttle do not establish those backend contracts."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F21--run-now-error",
          "title": "Run now error",
          "ids": [
            "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](https://github.com/OpenHands/OpenHands/issues/18062), see Gotchas).",
          "raw": "**Run now error (`F21.run-now-error`).** 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](https://github.com/OpenHands/OpenHands/issues/18062), see Gotchas).",
          "source_line": 111,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure: the wait times out and the card stays until `control-openhands browser reload` ([#18062](https://github.com/OpenHands/OpenHands/issues/18062), see Gotchas)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F21--delete-with-confirmation",
          "title": "Delete with confirmation",
          "ids": [
            "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`) and `browser press Tab` / `Shift+Tab` cycle Cancel, Delete and Close inside the dialog. 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.",
          "raw": "**Delete with confirmation (`F21.delete`).** 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`) and `browser press Tab` / `Shift+Tab` cycle Cancel, Delete and Close inside the dialog. 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.",
          "source_line": 112,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--load-more",
          "title": "Load more",
          "ids": [
            "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.",
          "raw": "**Load more (`F21.load-more`).** 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.",
          "source_line": 113,
          "expected": [
            "past 50 automations a **Load more** button fetches the next page and disappears when everything is loaded."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F21.phone`, `F21.subpage-nav`).** 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`.",
          "source_line": 114,
          "expected": [
            "at 390 px the dashboard has no horizontal overflow, tiles wrap 2×2 and the kebab menu stays inside the viewport.",
            "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\"`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F21--errors",
          "title": "Errors",
          "ids": [],
          "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`.",
          "raw": "**Errors.** 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`.",
          "source_line": 115,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        }
      ]
    },
    {
      "id": "F22",
      "title": "Creating automations: templates, setup and import",
      "file": "F22-automation-creation.md",
      "sha256": "a897a801c54cb9c0b8b3d12186bb64c4bfe8b26dd2d354f8c8725363f204ed38",
      "overview": "A user adds an automation in three ways. The **Templates** sub-page\n(`/automations/templates`) lists the prebuilt catalog from\n`@openhands/extensions` (6 featured, 17 Beta) with search; picking a card\neither opens a manifest-driven setup dialog at `/automations/new/<id>`\n(prerequisites → form → review → **Confirm and create**), or first asks where a\nGitHub/Slack responder should run, or first asks the user to install missing\nMCP integrations, or seeds a conversation with the entry's launch prompt.\n**Add Automation → Create Automation** opens the agent-assisted setup page at\n`/automations/setup`, where the form opens before a conversation exists. Saving a server-backed\ndraft and testing its generated custom script need no LLM; sending a prompt\nstarts genuine model assistance through `automation_form_update`. The setup page's own fields,\ndraft validation, Test and Create each have their own IDs in F28. **Add Automation → Import automation** reads\nan exported JSON file, previews it and creates it disabled.",
      "entry": "- Sidebar **Automate** (`sidebar-automations-link`), then **Templates** in the Automate sub-page nav (`automations-navbar-desktop >> automations-navigation-templates`; at phone width `automations-navbar-mobile`). The command menu has no Templates entry (searching `template` gives **No commands found**).\n- Direct URLs `/automations/templates` and `/automations/new/<catalog-id>` (for example `/automations/new/custom-automation`, `/automations/new/news-digest`, `/automations/new/qa-changes`). A deep link opens the setup dialog over the page; it bypasses the responder and install-queue dialogs that a card click shows first.\n- Template cards elsewhere use the same launcher: the **Recommended automations** rail on home/New Chat (F03) and on the empty dashboard (F21.empty-rail), and the onboarding say-hello step (F01).\n- Dashboard header **Add Automation** (`automations-add-automation`) → **Create Automation** (`automations-add-automation-create`) or **Import automation** (`automations-import-automation`). The empty dashboard also shows the Create Automation instructions inline (`automations-empty >> automations-create-automation`).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` for model/profile-dependent checks. Agent assistance, prompt/plugin Test and Run now use genuine `deepseek-flash` within the run's authorized model budget. The **Open and save a draft**, **Credential-free script test** and **Resume at phone width** bullets may start on an unconfigured fresh stack without this preset.\n- No automation named `QA_F22 …` or `Daily news digest` (`control-openhands api GET '/api/automation/v1?limit=100'`), no `OPENHANDS_URL` secret (`control-openhands api GET /api/settings/secrets`) and no MCP server installed (`control-openhands api GET /api/settings`, `mcp_config` empty). An existing `OPENHANDS_URL` skips the secret creation in the responder recipe; an installed Slack/Linear/Tavily server skips its install dialog.\n- Import fixtures (arrange only; each command prints the path, `$OH_VERIFY_RUN/evidence/_fixtures/<name>`):\n  `control-openhands fixture file --name qa-f22-import.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F22 Imported\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":true,\"prompt\":\"Reply with the single word: pong. Do not run any tools.\",\"plugins\":[\"github:OpenHands/extensions/plugins/qa-none\"]}}'`,\n  `control-openhands fixture file --name qa-f22-view.automation.json --content '{\"version\":1,\"kind\":\"automation\",\"spec\":{\"name\":\"QA_F22 View\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\"},\"enabled\":true,\"prompt\":\"Reply with the single word: pong. Do not run any tools.\"}}'`,\n  `control-openhands fixture file --name qa-f22-broken.json --content '{\"version\": 1, \"kind\": '` and\n  `control-openhands fixture file --name qa-f22-schema.json --content '{\"version\":2,\"kind\":\"automation\",\"spec\":{\"name\":\"\",\"trigger\":{\"type\":\"event\"},\"enabled\":\"yes\",\"prompt\":\"x\"}}'`.\n- Every automation created here uses the yearly cron `0 0 1 1 *`, so none fires during the run.\n- Tarball fixture (arrange only): `control-openhands fixture tarball --name qa-f22-tarball` writes `$OH_VERIFY_RUN/evidence/_fixtures/qa-f22-tarball.tar.gz` (one `main.py` that prints `pong`).\n- Blocked here: completing `F22.template-mcp-install-queue` (and launching a card whose only missing integration is installable, such as `research-brief-writer`) needs a real Slack, Linear or Tavily credential: the install dialog tests the connection before saving.",
      "gotchas": "- Expected: a completed draft test updates the open Test runs row to Successful automatically. Known failure (reproduced again 2026-10-13 on a desktop prompt draft: run COMPLETED at 08:13:39, row still Pending after a further 45 s wait; verified 2026-10-10, fresh Local stacks, Agent Server 1.54.0/automation 1.19.3): callback-only script tests and genuine DeepSeek prompt/plugin tests report COMPLETED while the open row stays Pending. The desktop prompt stayed Pending through a 45-second wait; the phone plugin capture was more than 201 seconds after completion. Reload and View test runs recover Successful ([#18263](https://github.com/OpenHands/OpenHands/issues/18263)). Ordinary final-automation Run now polling passes; reload recovery does not waive the draft-Test failure.\n- The Save toast can cover the setup toolbar Test button; use the lower `automation-setup-draft-test` and verify the actual run outcome. The toast does not time out while the pointer rests on it, so move the pointer away (`browser hover` on a form field) before clicking the toolbar again.\n- The setup page's docked composer keeps no draft: it opens empty even when the Home composer has an unsent draft (`sessionStorage` `oh:home-prompt-draft`), a prompt sent from it never becomes the Home draft, and leaving it keeps an existing Home draft. Unsent text typed there is lost when the user leaves the page.\n- In the desktop split view (form beside the conversation) the Frequency strip scrolls horizontally inside the narrower form and the selected **Custom** button sits off-screen; scroll it into view before reading it.\n- Dashboard draft Delete asks in the named dialog `role=dialog[name=\"Delete draft?\"]` (F21.saved-drafts). Deleting a draft also removes its materialized test automation.\n- A card click and a deep link are different paths: from the grid, GitHub/Slack-only responders (`qa-changes`, `github-agents-md-maintainer`, `slack-channel-monitor`, …) first show the responder choice and then the GitHub/Slack install dialog, so their setup form is reachable locally only by URL (`/automations/new/<id>`).\n- A card's `N MCPs to connect before launch` pill counts optional integrations too: Custom automation shows `1 MCPs to connect before launch` for GitHub, yet opens its setup at once because GitHub is optional there ([#17959](https://github.com/OpenHands/OpenHands/issues/17959)).\n- The featured-section copy says picking a card opens \"a pre-filled conversation\"; most featured cards open a setup form instead.\n- The setup dialog's comboboxes (Action, Agent profile, Timezone, LLM profile) are autocomplete inputs: click the field and pick the option in the same breath, scoped as `role=listbox >> role=option[name=\"…\"]`. A `browser snapshot` between the two closes the listbox (reproduced twice), and the option click then times out after 30 s; click the field again.\n- Service preflight runs 400 ms after a blur, not on every keystroke; `browser press Tab` then `browser wait` for the `-error` element.\n- **Confirm and create** creates the automation enabled; only imports arrive disabled. Use a yearly cron (`0 0 1 1 *`) so nothing fires during the run.\n- The **Daily news digest** entry names its automation `Daily news digest` (no `QA_` prefix); remember it in cleanup.\n- The Agent profile combobox exists only while the chosen action creates through `/v1` (Upload tarball, bundle entries): the automation service rejects `agent_profile_id` on `/v1/preset/prompt` and `/v1/preset/plugin`, so the Prompt and Plugin actions hide it and switching to them clears a chosen profile. Leave it on `Default` except in the Upload tarball bullet. A cleared choice is stored as an empty id and shown as `Default` (#18111).\n- The automation detail page has no `main` landmark: read its title with `browser text 'role=heading[level=1]'`, not `'role=main >> …'`.\n- The import success toast is the only path to **View automation** and disappears after about 5 s.\n- **Continue with local setup** silently creates the `OPENHANDS_URL` secret (value: the app origin) the first time; F14's list then has an extra row.\n- The **Install** button in a template's install dialog verifies the MCP connection before saving, so dummy credentials cannot complete the queue; nothing is saved on failure.\n- Creating the first automation ticks **Schedule a task** in the sidebar Getting-started checklist (F02); a run that checks the checklist should do so before this file.\n- Known issue [#17959](https://github.com/OpenHands/OpenHands/issues/17959): a template search with no matches leaves a blank area with no message (`F22.templates-search`).\n- Known issue [#17436](https://github.com/OpenHands/OpenHands/issues/17436) (`F22.setup-review-confirm`): Prompt-preset automations created after the Canvas 1.17.0 upgrade fail on the first conversation events request while older tarballs work, so the UI shows Failed runs. Recheck with a real LLM key and the reported automation 1.11.1/Agent Server 1.46.0 deployment plus pre-upgrade and finish-tool-wired tarballs; retain this version-specific report separately from the plugin-preset Cloud failure in #16611.\n- Known issue [#15509](https://github.com/OpenHands/OpenHands/issues/15509) (`F22.setup-tarball-create`): A native Windows event-triggered automation fails while uploading its workspace tarball with `Path must be absolute`. Recheck a Windows local stack and real event delivery, then observe its failed run/upload; a POSIX tarball upload is not a Windows path-validation check. This run has neither Windows nor the webhook fixture.\n- Known issue [#16611](https://github.com/OpenHands/OpenHands/issues/16611) (`F22.setup-plugin-create`): a minimal plugin-preset Cloud automation fails on its in-sandbox conversation/events request with HTTP 500, while the identical prompt preset works. Live prerequisites: an authenticated Cloud account, a real model key, and a reviewed no-op plugin fixture for the paired cases. The reported cost-zero error is visible in run logs, but a local prompt-only check cannot establish the Cloud plugin-loading behavior.\n- Known issue [OpenHands/software-agent-sdk#5729](https://github.com/OpenHands/software-agent-sdk/issues/5729) (`F22.setup-repository-list`): prompt and plugin preset workers call `RemoteWorkspace.clone_repos`, which asks for lowercase provider-token secret names. A private repository can therefore fail to clone when only the documented uppercase `GITHUB_TOKEN` (or GitLab/Bitbucket equivalent) exists. Recheck an owned preset with a private repository and only its uppercase secret, using an authorized disposable repository or a reviewed token-gated local Git fixture; a public repository and the Canvas Agent Server's separate Git-provider service do not exercise that lookup.",
      "features": [
        {
          "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 (also after a switch through Prompt or Plugin and after picking `Default`, #18111), 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.agent-assisted-setup",
          "description": "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F22--templates-page",
          "title": "Templates page",
          "ids": [
            "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.",
          "raw": "**Templates page (`F22.templates-page`).** 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.",
          "source_line": 73,
          "expected": [
            "`/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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--cards-and-pills",
          "title": "Cards and pills",
          "ids": [
            "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`).",
          "raw": "**Cards and pills (`F22.template-card`).** 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`).",
          "source_line": 74,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--search",
          "title": "Search",
          "ids": [
            "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).",
          "raw": "**Search (`F22.templates-search`).** 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).",
          "source_line": 75,
          "expected": [
            "the search box narrows cards by name, category, description, launch prompt and integration names; a query that matches nothing should say so."
          ],
          "notes": [
            "Known issue [#17959](https://github.com/OpenHands/OpenHands/issues/17959): a template search with no matches leaves a blank area with no message (`F22.templates-search`)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F22--card-opens-setup-prerequisites",
          "title": "Card opens setup, prerequisites",
          "ids": [
            "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`).",
          "raw": "**Card opens setup, prerequisites (`F22.template-launch-setup`, `F22.setup-prerequisites`).** 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`).",
          "source_line": 76,
          "expected": [
            "a card whose entry ships a setup form (custom-automation, news-digest, github-pr-reviewer, qa-changes, …) navigates to `/automations/new/<id>`.",
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--validation",
          "title": "Validation",
          "ids": [
            "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.",
          "raw": "**Validation (`F22.setup-form-validation`).** 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.",
          "source_line": 77,
          "expected": [
            "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: …`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--setup-form-colours-f22-setup-form-validation-18330",
          "title": "Setup form colours (`F22.setup-form-validation`, #18330)",
          "ids": [
            "F22.setup-form-validation"
          ],
          "body": "In the custom-automation setup dialog, the required and service errors read `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(209, 52, 56)` (field errors) / `rgb(192, 40, 45)` (prompt error) on white under Light+ (`F16.color-theme`); read them with `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=setup-field-schedule-error]')).color\"`. The schedule preflight answers only once name and prompt are filled: with them empty `/validate` returns `200` and no schedule error appears.",
          "raw": "**Setup form colours (`F22.setup-form-validation`, #18330).** In the custom-automation setup dialog, the required and service errors read `rgb(231, 106, 94)` on OpenHands-Neutral and `rgb(209, 52, 56)` (field errors) / `rgb(192, 40, 45)` (prompt error) on white under Light+ (`F16.color-theme`); read them with `control-openhands browser eval \"getComputedStyle(document.querySelector('[data-testid=setup-field-schedule-error]')).color\"`. The schedule preflight answers only once name and prompt are filled: with them empty `/validate` returns `200` and no schedule error appears.",
          "source_line": 78,
          "expected": [
            "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: …`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--action-kinds",
          "title": "Action kinds",
          "ids": [
            "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.",
          "raw": "**Action kinds (`F22.setup-action-kind`).** 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.",
          "source_line": 79,
          "expected": [
            "the **Action** combobox (custom-automation: Prompt / Plugin / Upload tarball) swaps the action fields and keeps shared values."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--agent-profile",
          "title": "Agent profile",
          "ids": [
            "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'`: 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`). `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'`: `Default` (the choice was cleared, shown as on a fresh form; choosing `Default` from the list also reads `Default`). Choose `Prompt` again before continuing. Creating an automation with a profile is in the Upload tarball bullet below.",
          "raw": "**Agent profile (`F22.setup-agent-profile`).** 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'`: 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`). `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'`: `Default` (the choice was cleared, shown as on a fresh form; choosing `Default` from the list also reads `Default`). Choose `Prompt` again before continuing. Creating an automation with a profile is in the Upload tarball bullet below.",
          "source_line": 80,
          "expected": [
            "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 (also after a switch through Prompt or Plugin and after picking `Default`, #18111), 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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--review-and-create",
          "title": "Review and create",
          "ids": [
            "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).",
          "raw": "**Review and create (`F22.setup-review-confirm`).** 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).",
          "source_line": 81,
          "expected": [
            "**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."
          ],
          "notes": [
            "Known issue [#17436](https://github.com/OpenHands/OpenHands/issues/17436) (`F22.setup-review-confirm`): Prompt-preset automations created after the Canvas 1.17.0 upgrade fail on the first conversation events request while older tarballs work, so the UI shows Failed runs. Recheck with a real LLM key and the reported automation 1.11.1/Agent Server 1.46.0 deployment plus pre-upgrade and finish-tool-wired tarballs; retain this version-specific report separately from the plugin-preset Cloud failure in #16611."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F22--plugin-action-and-timezone",
          "title": "Plugin action and timezone",
          "ids": [
            "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}]}`.",
          "raw": "**Plugin action and timezone (`F22.setup-plugin-create`).** 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}]}`.",
          "source_line": 82,
          "expected": [
            "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`)."
          ],
          "notes": [
            "Known issue [#16611](https://github.com/OpenHands/OpenHands/issues/16611) (`F22.setup-plugin-create`): a minimal plugin-preset Cloud automation fails on its in-sandbox conversation/events request with HTTP 500, while the identical prompt preset works. Live prerequisites: an authenticated Cloud account, a real model key, and a reviewed no-op plugin fixture for the paired cases. The reported cost-zero error is visible in run logs, but a local prompt-only check cannot establish the Cloud plugin-loading behavior."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F22--upload-tarball-action-with-an-agent-profile",
          "title": "Upload tarball action, with an agent profile",
          "ids": [
            "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`.",
          "raw": "**Upload tarball action, with an agent profile (`F22.setup-tarball-create`, `F22.setup-agent-profile`).** 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`.",
          "source_line": 83,
          "expected": [
            "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.",
            "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 (also after a switch through Prompt or Plugin and after picking `Default`, #18111), 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`."
          ],
          "notes": [
            "Known issue [#15509](https://github.com/OpenHands/OpenHands/issues/15509) (`F22.setup-tarball-create`): A native Windows event-triggered automation fails while uploading its workspace tarball with `Path must be absolute`. Recheck a Windows local stack and real event delivery, then observe its failed run/upload; a POSIX tarball upload is not a Windows path-validation check. This run has neither Windows nor the webhook fixture."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F22--close",
          "title": "Close",
          "ids": [
            "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).",
          "raw": "**Close (`F22.setup-close`).** 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).",
          "source_line": 84,
          "expected": [
            "X or Escape goes back in history; on a cold deep link it replaces the location with `/automations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--bundle-entry-min-length",
          "title": "Bundle entry, min length",
          "ids": [
            "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`.",
          "raw": "**Bundle entry, min length (`F22.setup-bundle-create`, `F22.setup-form-validation`).** 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`.",
          "source_line": 85,
          "expected": [
            "a bundle entry (Daily news digest) uploads its script tarball and creates an automation with the bundle's entrypoint, without any account.",
            "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: …`)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--repository-list",
          "title": "Repository list",
          "ids": [
            "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.",
          "raw": "**Repository list (`F22.setup-repository-list`).** 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.",
          "source_line": 86,
          "expected": [
            "a repository list field has **Add** (disabled while the input is empty) and a **Remove** per row; an empty required list is refused."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5729](https://github.com/OpenHands/software-agent-sdk/issues/5729) (`F22.setup-repository-list`): prompt and plugin preset workers call `RemoteWorkspace.clone_repos`, which asks for lowercase provider-token secret names. A private repository can therefore fail to clone when only the documented uppercase `GITHUB_TOKEN` (or GitLab/Bitbucket equivalent) exists. Recheck an owned preset with a private repository and only its uppercase secret, using an authorized disposable repository or a reviewed token-gated local Git fixture; a public repository and the Canvas Agent Server's separate Git-provider service do not exercise that lookup."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F22--blocking-prerequisite",
          "title": "Blocking prerequisite",
          "ids": [
            "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.",
          "raw": "**Blocking prerequisite (`F22.setup-prerequisites`).** 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.",
          "source_line": 87,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--not-available-and-fallback",
          "title": "Not available and fallback",
          "ids": [
            "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).",
          "raw": "**Not available and fallback (`F22.setup-unsupported`, `F22.setup-fallback-conversation`).** 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).",
          "source_line": 88,
          "expected": [
            "an entry this deployment cannot run (qa-changes needs event triggers) shows **Not available**, the unmet requirements, **Close** and **Set up in a conversation**.",
            "**Set up in a conversation** creates a conversation seeded with the entry's setup message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--unknown-id",
          "title": "Unknown id",
          "ids": [
            "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`.",
          "raw": "**Unknown id (`F22.setup-unknown-id`).** `control-openhands browser goto /automations/new/qa-no-such-template` then `control-openhands browser snapshot`: `heading \"404\"` and `Not Found`.",
          "source_line": 89,
          "expected": [
            "`/automations/new/<id>` for an id no catalog entry claims is a 404."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--responder-choice",
          "title": "Responder choice",
          "ids": [
            "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'`.",
          "raw": "**Responder choice (`F22.responder-deployment-choice`).** 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'`.",
          "source_line": 90,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--responder-launch-with-slack-installed",
          "title": "Responder launch with Slack installed",
          "ids": [
            "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.",
          "raw": "**Responder launch with Slack installed (`F22.responder-local-launch`).** 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.",
          "source_line": 91,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--install-queue",
          "title": "Install queue",
          "ids": [
            "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.",
          "raw": "**Install queue (`F22.template-mcp-install-queue`).** 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.",
          "source_line": 92,
          "expected": [
            "a card with missing required integrations opens each one's install dialog in turn; Cancel abandons the launch without creating anything."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F22--prompt-launch",
          "title": "Prompt launch",
          "ids": [
            "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).",
          "raw": "**Prompt launch (`F22.template-launch-conversation`).** 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).",
          "source_line": 93,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F22--open-and-save-a-draft-f22-agent-assisted-setup-desktop-and-phone",
          "title": "Open and save a draft (`F22.agent-assisted-setup`, desktop and phone)",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "This scoped recipe needs a doctored baseline stack with automation draft support (1.19.3 here); its non-dispatch steps need no LLM key or profile. From `/automations` run `control-openhands browser click 'testid=automations-add-automation'`, then `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`. The form and docked composer are visible; `control-openhands conversation list` is unchanged. Run `control-openhands browser fill 'testid=automation-setup-name' 'QA_F22 Delta Draft'`, `control-openhands browser fill 'testid=automation-setup-prompt' 'Reply pong. Do not run any tools.'`, `control-openhands browser click 'testid=automation-setup-frequency-custom'`, `control-openhands browser fill 'testid=automation-setup-custom-schedule' '0 0 1 1 *'`, then `control-openhands browser click 'testid=automation-setup-save-draft'`. `control-openhands browser wait 'testid=automation-setup-draft-details' --timeout 10000` and `browser snapshot 'testid=automation-setup-draft-details'` show **Draft saved** and **Ready to test**. `control-openhands api GET /api/automation/v1/drafts` supplies `<draft-id>` and confirms the name, prompt and yearly cron; the URL carries `draftId`. After `browser reload`, read each field with `browser value 'testid=automation-setup-name'`, `browser value 'testid=automation-setup-prompt'` and `browser value 'testid=automation-setup-custom-schedule'`: the values match. Screenshot with `--feature F22.agent-assisted-setup --name saved-draft`. Saving a prompt draft does not prove model execution; use the configured flash profile and the **Genuine prompt/plugin draft Test** checks below for that path.",
          "raw": "**Open and save a draft (`F22.agent-assisted-setup`, desktop and phone).** This scoped recipe needs a doctored baseline stack with automation draft support (1.19.3 here); its non-dispatch steps need no LLM key or profile. From `/automations` run `control-openhands browser click 'testid=automations-add-automation'`, then `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`. The form and docked composer are visible; `control-openhands conversation list` is unchanged. Run `control-openhands browser fill 'testid=automation-setup-name' 'QA_F22 Delta Draft'`, `control-openhands browser fill 'testid=automation-setup-prompt' 'Reply pong. Do not run any tools.'`, `control-openhands browser click 'testid=automation-setup-frequency-custom'`, `control-openhands browser fill 'testid=automation-setup-custom-schedule' '0 0 1 1 *'`, then `control-openhands browser click 'testid=automation-setup-save-draft'`. `control-openhands browser wait 'testid=automation-setup-draft-details' --timeout 10000` and `browser snapshot 'testid=automation-setup-draft-details'` show **Draft saved** and **Ready to test**. `control-openhands api GET /api/automation/v1/drafts` supplies `<draft-id>` and confirms the name, prompt and yearly cron; the URL carries `draftId`. After `browser reload`, read each field with `browser value 'testid=automation-setup-name'`, `browser value 'testid=automation-setup-prompt'` and `browser value 'testid=automation-setup-custom-schedule'`: the values match. Screenshot with `--feature F22.agent-assisted-setup --name saved-draft`. Saving a prompt draft does not prove model execution; use the configured flash profile and the **Genuine prompt/plugin draft Test** checks below for that path.",
          "source_line": 94,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--credential-free-script-test",
          "title": "Credential-free script test",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Run `control-openhands browser click 'testid=automation-setup-kind-custom'`, then inspect `browser value 'testid=automation-setup-custom-code'`: the generated Python prints its prompt and calls the real automation callback, without an agent/LLM. Keep its code, setup and entrypoint unchanged; click Save draft. Run `browser record start --feature F22.agent-assisted-setup --name custom-script-test --max-seconds 180`, then `browser click 'testid=automation-setup-draft-test'` and `browser wait 'testid=automation-setup-draft-runs-page' --timeout 10000`. Its snapshot initially shows Pending. Immediately run `api GET /api/automation/v1/drafts/<draft-id>` for `materialized_automation_id` and `last_test_run_id`, then `api GET '/api/automation/v1/<materialized-id>/runs?limit=10'`. Repeat the quoted runs GET after short `sleep 5` intervals until that run has terminal backend status (bounded to 90 seconds; no single wait over 45 seconds). Once it is COMPLETED, `browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000` tests automatic UI completion. Expected: Successful without a reload. Record the actual row and a second backend receipt before any recovery. As a separate readback, run `browser reload`, click `testid=automation-setup-view-test-runs`, screenshot Successful, then `browser click 'testid=automation-setup-draft-runs-back'`: the draft details read **Latest test passed**. End with `browser record stop`. A saved draft, Pending row or successful reload does not prove automatic completion updates.",
          "raw": "**Credential-free script test (`F22.agent-assisted-setup`).** Run `control-openhands browser click 'testid=automation-setup-kind-custom'`, then inspect `browser value 'testid=automation-setup-custom-code'`: the generated Python prints its prompt and calls the real automation callback, without an agent/LLM. Keep its code, setup and entrypoint unchanged; click Save draft. Run `browser record start --feature F22.agent-assisted-setup --name custom-script-test --max-seconds 180`, then `browser click 'testid=automation-setup-draft-test'` and `browser wait 'testid=automation-setup-draft-runs-page' --timeout 10000`. Its snapshot initially shows Pending. Immediately run `api GET /api/automation/v1/drafts/<draft-id>` for `materialized_automation_id` and `last_test_run_id`, then `api GET '/api/automation/v1/<materialized-id>/runs?limit=10'`. Repeat the quoted runs GET after short `sleep 5` intervals until that run has terminal backend status (bounded to 90 seconds; no single wait over 45 seconds). Once it is COMPLETED, `browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000` tests automatic UI completion. Expected: Successful without a reload. Record the actual row and a second backend receipt before any recovery. As a separate readback, run `browser reload`, click `testid=automation-setup-view-test-runs`, screenshot Successful, then `browser click 'testid=automation-setup-draft-runs-back'`: the draft details read **Latest test passed**. End with `browser record stop`. A saved draft, Pending row or successful reload does not prove automatic completion updates.",
          "source_line": 95,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--resume-at-phone-width",
          "title": "Resume at phone width",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Run `browser viewport phone`, `browser goto /automations`, then click `testid=automation-setup-draft-resume-<draft-id>` with `--expect-url '/automations/setup'`. Read Name, rename to `QA_F22 Phone Draft`, Save draft and reload: `browser value 'testid=automation-setup-name'` and `api GET /api/automation/v1/drafts/<draft-id>` both have the new name. `browser bbox 'testid=automation-setup-form'` has `pageHorizontalOverflow: false`; this long form scrolls vertically and need not fit wholly inside the viewport. Screenshot with `--feature F22.agent-assisted-setup --name resumed-phone`. Repeat initial open/save/reload on a fresh phone draft when checking that entry point; desktop creation is not phone creation. Restore desktop afterward.",
          "raw": "**Resume at phone width (`F22.agent-assisted-setup`).** Run `browser viewport phone`, `browser goto /automations`, then click `testid=automation-setup-draft-resume-<draft-id>` with `--expect-url '/automations/setup'`. Read Name, rename to `QA_F22 Phone Draft`, Save draft and reload: `browser value 'testid=automation-setup-name'` and `api GET /api/automation/v1/drafts/<draft-id>` both have the new name. `browser bbox 'testid=automation-setup-form'` has `pageHorizontalOverflow: false`; this long form scrolls vertically and need not fit wholly inside the viewport. Screenshot with `--feature F22.agent-assisted-setup --name resumed-phone`. Repeat initial open/save/reload on a fresh phone draft when checking that entry point; desktop creation is not phone creation. Restore desktop afterward.",
          "source_line": 96,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--agent-fills-a-prompt-form-f22-agent-assisted-setup-desktop",
          "title": "Agent fills a prompt form (`F22.agent-assisted-setup`, desktop)",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Start a fresh form with `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'` and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`. Run `control-openhands conversation start --stay --prompt 'Call automation_form_update exactly once with fields {\"name\":\"QA_F22 DeepSeek Desktop\",\"kind\":\"prompt\",\"prompt\":\"Reply pong. Do not run any tools.\",\"frequency\":\"custom\",\"customSchedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\",\"model\":\"deepseek-flash\"}. Do not call any other tools, run terminal commands, inspect files, or read environment variables. Briefly confirm the filled fields and stop.'`. `--stay` sends through the docked composer; omitting it would navigate away to New Chat. The returned `<setup-conversation-id>` opens `/conversations/<id>` with the form beside it. Run `control-openhands conversation wait <setup-conversation-id> --until finished,idle --timeout 60`, then `control-openhands conversation events <setup-conversation-id> --kinds ActionEvent --full`: a genuine `automation_form_update` supplied the exact fields. Read `control-openhands browser value 'testid=automation-setup-name'`, `control-openhands browser value 'testid=automation-setup-prompt'`, `control-openhands browser value 'testid=automation-setup-custom-schedule'` and `control-openhands browser text 'testid=automation-setup-model'`; they match the requested values. `control-openhands browser snapshot 'testid=automation-setup-form'` also shows **Filled by OpenHands** markers. Capture `control-openhands browser screenshot --feature F22.agent-assisted-setup --name desktop-genuine-tool-filled`; the UI field readbacks and real action establish the update, not the agent's confirmation alone. The sent prompt is not kept as a draft: `control-openhands browser eval \"sessionStorage.getItem('oh:home-prompt-draft')\"` is `null` (with no earlier Home draft), and the New Chat composer (`browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` after clicking `text=New Chat`) and a later `/automations/setup` docked composer are empty; return to `/conversations/<setup-conversation-id>` for the next bullet.",
          "raw": "**Agent fills a prompt form (`F22.agent-assisted-setup`, desktop).** Start a fresh form with `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'` and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup'`. Run `control-openhands conversation start --stay --prompt 'Call automation_form_update exactly once with fields {\"name\":\"QA_F22 DeepSeek Desktop\",\"kind\":\"prompt\",\"prompt\":\"Reply pong. Do not run any tools.\",\"frequency\":\"custom\",\"customSchedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\",\"model\":\"deepseek-flash\"}. Do not call any other tools, run terminal commands, inspect files, or read environment variables. Briefly confirm the filled fields and stop.'`. `--stay` sends through the docked composer; omitting it would navigate away to New Chat. The returned `<setup-conversation-id>` opens `/conversations/<id>` with the form beside it. Run `control-openhands conversation wait <setup-conversation-id> --until finished,idle --timeout 60`, then `control-openhands conversation events <setup-conversation-id> --kinds ActionEvent --full`: a genuine `automation_form_update` supplied the exact fields. Read `control-openhands browser value 'testid=automation-setup-name'`, `control-openhands browser value 'testid=automation-setup-prompt'`, `control-openhands browser value 'testid=automation-setup-custom-schedule'` and `control-openhands browser text 'testid=automation-setup-model'`; they match the requested values. `control-openhands browser snapshot 'testid=automation-setup-form'` also shows **Filled by OpenHands** markers. Capture `control-openhands browser screenshot --feature F22.agent-assisted-setup --name desktop-genuine-tool-filled`; the UI field readbacks and real action establish the update, not the agent's confirmation alone. The sent prompt is not kept as a draft: `control-openhands browser eval \"sessionStorage.getItem('oh:home-prompt-draft')\"` is `null` (with no earlier Home draft), and the New Chat composer (`browser eval \"document.querySelector('[data-testid=chat-input]').innerText\"` after clicking `text=New Chat`) and a later `/automations/setup` docked composer are empty; return to `/conversations/<setup-conversation-id>` for the next bullet.",
          "source_line": 97,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--desktop-hide-show-and-phone-views",
          "title": "Desktop Hide/Show and phone views",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "On that associated conversation, `control-openhands browser attr 'testid=automation-setup-agent-toggle' aria-label` reads `Hide Agent`. Click the toggle: the label becomes `Show Agent`, and `control-openhands browser bbox 'testid=conversation-chat-panel'` has width `0`; form values remain. Click again: `Hide Agent` and a visible conversation panel return. Save with `control-openhands browser click 'testid=automation-setup-save-draft'`, `control-openhands browser wait 'testid=automation-setup-draft-details' --timeout 10000` and `control-openhands api GET /api/automation/v1/drafts` to obtain `<assisted-draft-id>` and the stored form. Run `control-openhands browser viewport phone`, `control-openhands browser click 'testid=automation-setup-mobile-view-form'`, read Name, then `control-openhands browser click 'testid=automation-setup-mobile-view-conversation'`; the selected view fits without horizontal overflow. From `control-openhands browser goto /automations`, click `testid=automation-setup-draft-resume-<assisted-draft-id>` with `--expect-url '/automations/setup'`: dashboard Resume opens `/automations/setup?draftId=<id>`, not the original conversation URL. Reload and read Name/prompt/schedule again. Separately `control-openhands browser goto /conversations/<setup-conversation-id>` and `control-openhands browser click 'testid=automation-setup-mobile-view-form'` restore the same draft inside its associated conversation. Capture each entry separately. `control-openhands browser click 'testid=automation-setup-create' --expect-url '/automations/[0-9a-f-]{8,}'` creates the final prompt automation; `control-openhands api GET /api/automation/v1/<id>` shows the requested fields and `enabled: false`.",
          "raw": "**Desktop Hide/Show and phone views (`F22.agent-assisted-setup`).** On that associated conversation, `control-openhands browser attr 'testid=automation-setup-agent-toggle' aria-label` reads `Hide Agent`. Click the toggle: the label becomes `Show Agent`, and `control-openhands browser bbox 'testid=conversation-chat-panel'` has width `0`; form values remain. Click again: `Hide Agent` and a visible conversation panel return. Save with `control-openhands browser click 'testid=automation-setup-save-draft'`, `control-openhands browser wait 'testid=automation-setup-draft-details' --timeout 10000` and `control-openhands api GET /api/automation/v1/drafts` to obtain `<assisted-draft-id>` and the stored form. Run `control-openhands browser viewport phone`, `control-openhands browser click 'testid=automation-setup-mobile-view-form'`, read Name, then `control-openhands browser click 'testid=automation-setup-mobile-view-conversation'`; the selected view fits without horizontal overflow. From `control-openhands browser goto /automations`, click `testid=automation-setup-draft-resume-<assisted-draft-id>` with `--expect-url '/automations/setup'`: dashboard Resume opens `/automations/setup?draftId=<id>`, not the original conversation URL. Reload and read Name/prompt/schedule again. Separately `control-openhands browser goto /conversations/<setup-conversation-id>` and `control-openhands browser click 'testid=automation-setup-mobile-view-form'` restore the same draft inside its associated conversation. Capture each entry separately. `control-openhands browser click 'testid=automation-setup-create' --expect-url '/automations/[0-9a-f-]{8,}'` creates the final prompt automation; `control-openhands api GET /api/automation/v1/<id>` shows the requested fields and `enabled: false`.",
          "source_line": 98,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--fresh-phone-prompt-and-genuine-run-now",
          "title": "Fresh phone prompt and genuine Run now",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Stay at phone width, open a new form through Dashboard Add → Create, and send the exact one-tool prompt above with name `QA_F22 DeepSeek Phone`. Read the genuine action and fields, click `testid=automation-setup-mobile-view-form`, Save draft, reload and read back the saved values before clicking Create. On the resulting detail page run `control-openhands browser click 'role=switch[name=\"Turn on\"]'`, `control-openhands browser click 'role=button[name=\"Run now\"]'`, then `control-openhands browser wait 'testid=automation-activity-log >> testid=run-status-icon-completed' --timeout 60000` without reloading. `control-openhands api GET /api/automation/v1/<id>/runs` independently reports COMPLETED with a conversation id; `control-openhands conversation events <run-conversation-id> --kinds ActionEvent --full` contains a real `finish`, and MessageEvent output is `pong`. The ordinary detail row becomes Successful automatically. Desktop assisted-prompt creation and fresh phone prompt execution are separate scopes. Return to desktop before the next bullet.",
          "raw": "**Fresh phone prompt and genuine Run now (`F22.agent-assisted-setup`).** Stay at phone width, open a new form through Dashboard Add → Create, and send the exact one-tool prompt above with name `QA_F22 DeepSeek Phone`. Read the genuine action and fields, click `testid=automation-setup-mobile-view-form`, Save draft, reload and read back the saved values before clicking Create. On the resulting detail page run `control-openhands browser click 'role=switch[name=\"Turn on\"]'`, `control-openhands browser click 'role=button[name=\"Run now\"]'`, then `control-openhands browser wait 'testid=automation-activity-log >> testid=run-status-icon-completed' --timeout 60000` without reloading. `control-openhands api GET /api/automation/v1/<id>/runs` independently reports COMPLETED with a conversation id; `control-openhands conversation events <run-conversation-id> --kinds ActionEvent --full` contains a real `finish`, and MessageEvent output is `pong`. The ordinary detail row becomes Successful automatically. Desktop assisted-prompt creation and fresh phone prompt execution are separate scopes. Return to desktop before the next bullet.",
          "source_line": 99,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--agent-created-plugin-f22-agent-assisted-setup-desktop-and-fresh-phone",
          "title": "Agent-created plugin (`F22.agent-assisted-setup`, desktop and fresh phone)",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Arrange a public plugin fixture with `mkdir -p \"$OH_VERIFY_RUN/workspace\"` and `cp -R node_modules/@openhands/extensions/plugins/magic-test \"$OH_VERIFY_RUN/workspace/qa-f22-real-plugin\"`; `<plugin-path>` below is that absolute run-owned path. Open another fresh form and send the one-tool docked prompt with fields `{\"name\":\"QA_F22 DeepSeek Real Plugin\",\"kind\":\"plugin\",\"pluginSource\":\"<plugin-path>\",\"prompt\":\"Reply pong. Do not run any tools.\",\"frequency\":\"custom\",\"customSchedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\",\"model\":\"deepseek-flash\"}` (replace the path before sending). Read the real `automation_form_update`, the form and its saved values. Create, Turn on and Run now as above; the detail row becomes Successful without reload and its actual conversation finishes `pong`. `control-openhands conversation events <run-conversation-id> --grep magic-word --from-start` confirms that the public plugin's skill registered. This checks plugin loading/execution; `pong` does not exercise that skill's trigger, hooks, MCP or scripts. Repeat from a fresh phone form with name `QA_F22 DeepSeek Plugin Phone Draft`, using the Form view and the next draft-Test bullet before final Create/Turn on/Run now; desktop creation alone does not prove that phone path.",
          "raw": "**Agent-created plugin (`F22.agent-assisted-setup`, desktop and fresh phone).** Arrange a public plugin fixture with `mkdir -p \"$OH_VERIFY_RUN/workspace\"` and `cp -R node_modules/@openhands/extensions/plugins/magic-test \"$OH_VERIFY_RUN/workspace/qa-f22-real-plugin\"`; `<plugin-path>` below is that absolute run-owned path. Open another fresh form and send the one-tool docked prompt with fields `{\"name\":\"QA_F22 DeepSeek Real Plugin\",\"kind\":\"plugin\",\"pluginSource\":\"<plugin-path>\",\"prompt\":\"Reply pong. Do not run any tools.\",\"frequency\":\"custom\",\"customSchedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\",\"model\":\"deepseek-flash\"}` (replace the path before sending). Read the real `automation_form_update`, the form and its saved values. Create, Turn on and Run now as above; the detail row becomes Successful without reload and its actual conversation finishes `pong`. `control-openhands conversation events <run-conversation-id> --grep magic-word --from-start` confirms that the public plugin's skill registered. This checks plugin loading/execution; `pong` does not exercise that skill's trigger, hooks, MCP or scripts. Repeat from a fresh phone form with name `QA_F22 DeepSeek Plugin Phone Draft`, using the Form view and the next draft-Test bullet before final Create/Turn on/Run now; desktop creation alone does not prove that phone path.",
          "source_line": 100,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--genuine-prompt-plugin-draft-test",
          "title": "Genuine prompt/plugin draft Test",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "On the fresh phone plugin form, Save draft and read its `<model-draft-id>` through `control-openhands api GET /api/automation/v1/drafts`, then `control-openhands browser click 'testid=automation-setup-draft-test'` and `control-openhands browser wait 'testid=automation-setup-draft-runs-page' --timeout 10000`. Use `control-openhands api GET /api/automation/v1/drafts/<model-draft-id>` for `materialized_automation_id` and `last_test_run_id`, then poll `control-openhands api GET '/api/automation/v1/<materialized-id>/runs?limit=10'` until the exact run is terminal. Inspect its real conversation's finish event and `pong`. As in the script-test recipe, test the open row with `control-openhands browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000` before recovery. Expected: automatic Successful. Known failure #18263: the genuine phone plugin run was COMPLETED while the page remained Pending more than 201 seconds later; a separate desktop prompt draft Test reproduced the same failure through a 45-second wait. Record fail, backend receipts and the visible row before `control-openhands browser reload` and `control-openhands browser click 'testid=automation-setup-view-test-runs'`. Reload shows Successful; `control-openhands browser click 'testid=automation-setup-draft-runs-back'` shows **Latest test passed**. Then Create the phone plugin and verify its ordinary Run now completion as above. The standalone desktop prompt variant uses the name/prompt/yearly-cron fields from **Open and save a draft**, with kind left Prompt and the configured flash model, before the same Test/status/reload checks.",
          "raw": "**Genuine prompt/plugin draft Test (`F22.agent-assisted-setup`).** On the fresh phone plugin form, Save draft and read its `<model-draft-id>` through `control-openhands api GET /api/automation/v1/drafts`, then `control-openhands browser click 'testid=automation-setup-draft-test'` and `control-openhands browser wait 'testid=automation-setup-draft-runs-page' --timeout 10000`. Use `control-openhands api GET /api/automation/v1/drafts/<model-draft-id>` for `materialized_automation_id` and `last_test_run_id`, then poll `control-openhands api GET '/api/automation/v1/<materialized-id>/runs?limit=10'` until the exact run is terminal. Inspect its real conversation's finish event and `pong`. As in the script-test recipe, test the open row with `control-openhands browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000` before recovery. Expected: automatic Successful. Known failure #18263: the genuine phone plugin run was COMPLETED while the page remained Pending more than 201 seconds later; a separate desktop prompt draft Test reproduced the same failure through a 45-second wait. Record fail, backend receipts and the visible row before `control-openhands browser reload` and `control-openhands browser click 'testid=automation-setup-view-test-runs'`. Reload shows Successful; `control-openhands browser click 'testid=automation-setup-draft-runs-back'` shows **Latest test passed**. Then Create the phone plugin and verify its ordinary Run now completion as above. The standalone desktop prompt variant uses the name/prompt/yearly-cron fields from **Open and save a draft**, with kind left Prompt and the configured flash model, before the same Test/status/reload checks.",
          "source_line": 101,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [
            "Known failure #18263: the genuine phone plugin run was COMPLETED while the page remained Pending more than 201 seconds later; a separate desktop prompt draft Test reproduced the same failure through a 45-second wait. Record fail, backend receipts and the visible row before `control-openhands browser reload` and `control-openhands browser click 'testid=automation-setup-view-test-runs'`. Reload shows Successful; `control-openhands browser click 'testid=automation-setup-draft-runs-back'` shows **Latest test passed**. Then Create the phone plugin and verify its ordinary Run now completion as above. The standalone desktop prompt variant uses the name/prompt/yearly-cron fields from **Open and save a draft**, with kind left Prompt and the configured flash model, before the same Test/status/reload checks."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F22--setup-fixture-cleanup",
          "title": "Setup fixture cleanup",
          "ids": [
            "F22.agent-assisted-setup"
          ],
          "body": "Read the owned draft and final automation ids before deleting. Run `control-openhands api DELETE /api/automation/v1/drafts/<draft-id> --write` for each remaining draft, including the credential-free draft; confirm that its materialized test automation is absent. Delete final `QA_F22 DeepSeek …` automations with `control-openhands api DELETE /api/automation/v1/<id> --write` and confirm both lists contain no owned fixtures. Finish or pause the setup/execution conversations. This cleanup checks fixture removal; dashboard Delete is a distinct UI path.",
          "raw": "**Setup fixture cleanup (`F22.agent-assisted-setup`).** Read the owned draft and final automation ids before deleting. Run `control-openhands api DELETE /api/automation/v1/drafts/<draft-id> --write` for each remaining draft, including the credential-free draft; confirm that its materialized test automation is absent. Delete final `QA_F22 DeepSeek …` automations with `control-openhands api DELETE /api/automation/v1/<id> --write` and confirm both lists contain no owned fixtures. Finish or pause the setup/execution conversations. This cleanup checks fixture removal; dashboard Delete is a distinct UI path.",
          "source_line": 102,
          "expected": [
            "**Add Automation → Create Automation** opens `/automations/setup`, whose setup panel collects name, action, trigger, model/profile and custom code, can ask the agent for help, saves server-backed drafts, opens test runs behind a Back button and dispatches the finished automation."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--import-picker",
          "title": "Import picker",
          "ids": [
            "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`.",
          "raw": "**Import picker (`F22.import-picker`).** 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`.",
          "source_line": 103,
          "expected": [
            "**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**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--bad-files",
          "title": "Bad files",
          "ids": [
            "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`.",
          "raw": "**Bad files (`F22.import-validation`).** 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`.",
          "source_line": 104,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--preview-and-cancel",
          "title": "Preview and cancel",
          "ids": [
            "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.",
          "raw": "**Preview and cancel (`F22.import-preview`).** 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.",
          "source_line": 105,
          "expected": [
            "a valid file switches the dialog to a preview (Name, Trigger, Prompt, Plugins) with the disabled notice, **Cancel** and **Import automation**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--import",
          "title": "Import",
          "ids": [
            "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`.",
          "raw": "**Import (`F22.import-confirm`).** 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`.",
          "source_line": 106,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--choose-file",
          "title": "Choose file",
          "ids": [
            "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`).",
          "raw": "**Choose file (`F22.import-choose-file`).** 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`).",
          "source_line": 107,
          "expected": [
            "**Choose file**, and a click anywhere on the dropzone, open the browser's file chooser; the chosen file is previewed."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--drop-a-file",
          "title": "Drop a file",
          "ids": [
            "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`.",
          "raw": "**Drop a file (`F22.import-dropzone`).** 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`.",
          "source_line": 108,
          "expected": [
            "dragging a file over the dropzone highlights it (`data-active=\"true\"`); dropping a `.json` file previews it like **Choose file**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--phone",
          "title": "Phone",
          "ids": [
            "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`.",
          "raw": "**Phone (`F22.phone`).** 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`.",
          "source_line": 109,
          "expected": [
            "the Templates grid, the setup dialog and the import dialog fit a 390 px viewport without horizontal overflow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F22--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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 setup 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 '/automations/setup'` opens the agent-assisted setup page.",
          "raw": "**Cleanup.** 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 setup 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 '/automations/setup'` opens the agent-assisted setup page.",
          "source_line": 110,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F22--malformed-catalog-entry",
          "title": "Malformed catalog entry",
          "ids": [
            "F22.template-card",
            "F22.templates-page"
          ],
          "body": "Archive the frozen target into an isolated private checkout, link unchanged dependencies except a private copy of the pinned `@openhands/extensions`, and remove only `requires` from `automations/catalog/custom-automation/manifest.json`. Leave Canvas source and its lockfile unchanged; run `npm run build:app` only in that copy. Serve its flat `build/` directory against an owned healthy backend, then open `/automations/templates` through `browser goto <fixture-origin>/automations/templates --allow-external`. Expected: the malformed entry degrades without crashing the valid catalog. Known failure, reproduced 2026-10-12 on `fa1f724`: the whole page renders **Application Error**, `TypeError: Cannot read properties of undefined (reading 'integrations')` ([#16277](https://github.com/OpenHands/OpenHands/issues/16277)). Record this as catalog-data fault injection, then stop/remove only the private fixture frontend/build; never modify the shared package or build.",
          "raw": "**Malformed catalog entry (`F22.template-card`, `F22.templates-page`).** Archive the frozen target into an isolated private checkout, link unchanged dependencies except a private copy of the pinned `@openhands/extensions`, and remove only `requires` from `automations/catalog/custom-automation/manifest.json`. Leave Canvas source and its lockfile unchanged; run `npm run build:app` only in that copy. Serve its flat `build/` directory against an owned healthy backend, then open `/automations/templates` through `browser goto <fixture-origin>/automations/templates --allow-external`. Expected: the malformed entry degrades without crashing the valid catalog. Known failure, reproduced 2026-10-12 on `fa1f724`: the whole page renders **Application Error**, `TypeError: Cannot read properties of undefined (reading 'integrations')` ([#16277](https://github.com/OpenHands/OpenHands/issues/16277)). Record this as catalog-data fault injection, then stop/remove only the private fixture frontend/build; never modify the shared package or build.",
          "source_line": 112,
          "expected": [
            "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.",
            "`/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)."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 on `fa1f724`: the whole page renders **Application Error**, `TypeError: Cannot read properties of undefined (reading 'integrations')` ([#16277](https://github.com/OpenHands/OpenHands/issues/16277)). Record this as catalog-data fault injection, then stop/remove only the private fixture frontend/build; never modify the shared package or build."
          ],
          "states": [
            "failure"
          ]
        }
      ]
    },
    {
      "id": "F23",
      "title": "Automation detail, runs and editing",
      "file": "F23-automation-detail.md",
      "sha256": "859dcf37973b1c752ecacbbfac8f990b1d07a42ddba5d1a1fe3950c15f62527b",
      "overview": "The detail page (`/automations/<id>`) shows one automation: a **Back to\nAutomations** link, a header with the name, an **Active**/**Inactive** badge,\n**Run now**, an on/off switch and a kebab (Export, Tarball, Edit, Turn on/off,\nDelete); a **Why this is paused** banner when it was turned off; the Prompt (or,\nfor a script automation, the Script bundle); Configuration (repositories,\ntrigger, schedule or event source/type/filter, LLM profile); a **Plugins** card\nwhen it has plugins; an Activity card (Created, Last run);\nand the **Activity Log** of runs with status, summary, cost, a logs dialog, a\nlink to each run's conversation and JSON/CSV export. Kebab **Edit** opens the\nsetup page in edit mode (`/automations/setup?automationId=<id>`, since #17688),\nwhich changes name, prompt, agent and LLM profile, timeout and the trigger. A\nDRAFT-state test automation (materialized by a draft Test, F21.saved-drafts)\nshows a **Draft** badge and no on/off switch.",
      "entry": "- Dashboard (`/automations`, F21): click a card's heading, a list row link, or the kebab **Automation actions** → **View**; a sparkline bar opens `/automations/<id>?run=<runId>` (F21.sparkline-deeplink).\n- Direct URL `/automations/<id>` and `/automations/<id>?run=<runId>`. Get `<id>` from `control-openhands browser url` after opening a card, or from `control-openhands api GET /api/automation/v1`; get `<runId>` from `control-openhands api GET /api/automation/v1/<id>/runs`.\n- Edit: the detail kebab **Edit** (exercised here) and the dashboard kebab **Edit** (F21.edit) both open `/automations/setup?automationId=<id>`; a cold load of that URL opens it too.\n- Browser Back from a run's conversation returns to the detail page.\n- The detail page has no command-menu entry and no keyboard shortcut.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and an active LLM profile for the runs: `control-openhands llm preset deepseek` (`--api-key-file <file>` when the key is in a file); the edit recipes also expect `deepseek-pro` in the LLM profile list.\n- No `QA_F23` automations and no **Daily news digest** exist (`control-openhands api GET /api/automation/v1`).\n- Arrange three fixtures through the API (arrange only; F22 owns creating automations in the UI). Each prints the new automation in `body` (`body.id` is `<id>`):\n  `control-openhands api POST /api/automation/v1/preset/prompt --write --data '{\"name\":\"QA_F23 Pong\",\"prompt\":\"Reply with the single word: pong. Do not run any tools.\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 9 * * *\",\"timezone\":\"UTC\"},\"enabled\":false}'`,\n  `control-openhands api POST /api/automation/v1/preset/prompt --write --data '{\"name\":\"QA_F23 Fail\",\"prompt\":\"Reply with the single word: pong.\",\"trigger\":{\"type\":\"cron\",\"schedule\":\"0 0 1 1 *\",\"timezone\":\"UTC\"},\"enabled\":true}'`,\n  `control-openhands api POST /api/automation/v1/preset/plugin --write --data '{\"name\":\"QA_F23 Event\",\"prompt\":\"QA line 1: reply pong.\\nQA line 2: reply pong.\\nQA line 3: reply pong.\\nQA line 4: reply pong.\\nQA line 5: reply pong.\\nQA line 6: reply pong.\\nQA line 7: reply pong.\\nQA line 8: reply pong.\\nQA line 9: reply pong.\\nQA line 10: reply pong.\\nQA line 11: reply pong.\\nQA line 12: reply pong.\\nQA line 13: reply pong.\\nQA line 14: reply pong.\\nQA line 15: reply pong.\",\"plugins\":[{\"source\":\"github:qa-example/qa-plugin\"}],\"repos\":[{\"url\":\"https://github.com/qa-example/qa-repo\",\"ref\":\"main\"}],\"trigger\":{\"type\":\"event\",\"source\":\"github\",\"on\":\"pull_request.opened\",\"filter\":\"pull_request.draft == `false` && pull_request.base.ref == repository.default_branch\"},\"enabled\":false}'`.\n  Pong is a daily preset (`0 9 * * *`) created off; Fail is a yearly custom cron (`0 0 1 1 *`) created on, so it never fires by itself; Event never fires (no GitHub webhooks).\n- Desktop viewport. Open a fixture's page the way a user does: `control-openhands browser goto /automations`, then `control-openhands browser click '[data-testid^=\"automation-card-\"] >> has-text=<name> >> role=heading' --expect-url '/automations/[0-9a-f-]+'`. Below this is called \"open <name>\".",
      "gotchas": "- The automations QA checklist (F28) expects edit-mode **Test** to run the current form state; the panel calls `dispatchAutomation(<id>)`, which runs the saved automation. `F23.edit-test` records current behavior; whether it should change is a product decision, not a map correction.\n- The Logs dialog's panel keeps long content reachable (`F23.run-logs-modal`, #18175 / #18173). Re-verified 2026-10-13 (revision `2bdc0e9d`, SDK 1.54.0, automation 1.19.3) with a 30-line `outcome_summary` at 1440×1000, 390×844 and 390×500: the panel is `testid=run-logs-body` (`max-h-[80vh]`, `overflow-y: auto`) with a sticky header (Logs + X stay visible); the Output box no longer scrolls on its own, the whole panel does, and its end (`=== RESULT === ALL_OK`) is reachable at the bottom. Verified 2026-10-10 on an independent fresh Linux Local stack at product revision `5a8c3832154b14d2dad651801c0ab3f74c96f02e`, SDK 1.54.0 and automation 1.19.3: a genuine DeepSeek finish produced a 4,596-character `outcome_summary` and 1,079-character metadata `message`; summary/metadata endings, Output/Error tabs and the true stdout end were reachable at 1440×1000, 390×844, 1440×500 and 390×500. The header/X remained visible, closing did not navigate, and phone had no horizontal overflow. Preserve those long-content and short-height checks when retesting. The verified scope is pinned Linux Local; macOS, Cloud and legacy/custom finish schemas need their own checks. `browser bbox 'role=dialog'` measures the full-screen wrapper, not the panel: measure the panel with `browser eval`.\n- The detail page has no `main` landmark: `browser snapshot 'role=main'` times out. Snapshot the page, or scope by section headings (`role=heading[name=\"Configuration\"] >> xpath=ancestor::div[2]`).\n- The kebab menu has no test ids; `role=list >> has-text=Tarball` scopes the detail kebab (the dashboard kebab has no Tarball, so F21 uses `has-text=Export`).\n- Kebab **Edit** (detail and dashboard) opens `/automations/setup?automationId=<id>`. The edit page's Save and Test report API failures with the API's message (see `F23.edit-validation`, `F23.edit-save-error`), and its `Automation updated` toast covers Test/Save/Close until the pointer moves away.\n- Known defect: clearing **At** on a preset schedule and saving stores midnight with no message ([#17948](https://github.com/OpenHands/OpenHands/issues/17948) item; with the old modal it was silently ignored).\n- On a local backend the Logs dialog reads every run's output from the server-level `/api/bash/bash_events/search`, with or without a conversation. It caches what it fetched: reload before checking its requests with `browser network`.\n- Known defect: prompt runs also have a `bash_command_id`, so every row shows the logs icon, and a prompt run that ended without a conversation is labelled as a script run (`F23.run-no-conversation`) ([#17948](https://github.com/OpenHands/OpenHands/issues/17948)).\n- The automation service (1.19.0 here) keeps repos and plugins only under `preset_metadata`; the UI reads them from there. Repositories show `owner/repo` without the host (a GitLab URL reads like a GitHub one), a repo without a ref gets no badge, and Export keeps only the first repository (all plugins). The **Notification** field reads a top-level `notification` that the service has no field for, so it never renders on a local stack (#16644 tracks removing or mapping it).\n- The Schedule field shows the raw cron (`0 9 * * *`) and no timezone: the service returns no `schedule_human` and no top-level `timezone`, so the Edit dialog also omits its `Timezone:` hint.\n- `run-phase` never appeared in the log here (`phase_code` stays `null` on the local service), so the phase chip is not reachable on this stack.\n- An LLM profile that does not exist (`model: \"qa-no-such-profile\"`) does not fail the run: it ran and succeeded with the active profile. Use timeout `1` to get a failed run; it takes several minutes to fail.\n- `Load more` in the log (`F23.activity-log-more`) needs more than 20 runs of one automation; at about 35 s and one model call each it is left out of a normal pass (not-run).\n- The script section needs a tarball automation. The account-free way to get one is F22's **Daily news digest** bundle entry (it uploads its bundle and creates the automation); the custom-automation **Upload tarball** action takes `control-openhands fixture tarball`. Its kebab Edit shows generated starter Python instead of the saved bundle (#18341, see F21.edit); a cold load of `/automations/setup?automationId=<id>` shows the **Existing code bundle** summary.\n- **Starting…** on Run now lasts only for the dispatch request (under 50 ms locally); `--observe` does not catch it.\n- The run row is a plain `<a href>`, so opening a run's conversation reloads the whole app (a `document` request in `browser network`), unlike sidebar navigation.\n- A 404 detail request is retried three times before **Automation not found** shows (about 7 s of skeleton) ([#17948](https://github.com/OpenHands/OpenHands/issues/17948)); wait with `wait-text`, not `count`.\n- `browser fill` on the Timeout spinbutton with letters fails in Playwright and, after its 30 s retry, the dialog was found closed; use numeric values (`1.5`, `0`, `1801`) to reach the timeout errors.\n- The Tarball download is named `<name>.tar` (spaces become `_`) but is gzip-compressed ([#17948](https://github.com/OpenHands/OpenHands/issues/17948)); `browser downloads --inspect` does not list tar entries, so inspect it with `tar -tzf`.\n- **Debug with OpenHands** starts a real agent with the `openhands-automation` skill and the automation API key. It acts on live automations: here it looked up `QA_F23 Fail` by name (a newer fixture with the same name), PATCHed its timeout back to 600 and dispatched it, which adds runs and conversations. Stop it once it has started: on its page `control-openhands browser click 'testid=stop-button'`, then `control-openhands conversation wait <id> --until paused,finished,idle --timeout 45` (`paused`).\n- A successful run leaves a conversation `<name> — <UTC timestamp>` that stays after the automation is deleted (conversations family owns deleting it).\n- Known issue [#15465](https://github.com/OpenHands/OpenHands/issues/15465) (`F23.configuration-event` `F23.activity-log`): An automation configured for Agent Canvas release events never runs automatically, although Run now works. Recheck a real signed GitHub/Forgejo release webhook through an externally reachable responder into a disposable automation and inspect its activity log; local manual dispatch cannot verify event delivery. No external repository webhook credentials or controlled release-event fixture are available.\n- Known issue [OpenHands/software-agent-sdk#5279](https://github.com/OpenHands/software-agent-sdk/issues/5279) (`F23.run-linked-conversation`): An automation that attaches to an existing remote conversation can complete with no conversation link in its run, because the workspace callback does not register the attached id. Recheck a Cloud automation script that calls `RemoteConversation.attach` on an owned stable conversation and exits through the real workspace completion callback, then inspect the Activity Log link. Blocked without that authenticated Cloud workspace/callback context; a plain local GET is not callback proof.\n- Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.\n- Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.\n- Known issue [OpenHands/automation#286](https://github.com/OpenHands/automation/issues/286) (`F23.run-status-polling`): Multi-worker/replica automation deployments can run duplicate watchdogs that query the same sandbox and hit rate limits, falsely failing runs. Recheck the real deployment's worker/replica configuration with an owned long-running Cloud run and sandbox verification traffic; this single-worker local stack cannot prove distributed scheduler/watchdog ownership.\n- Known issue [OpenHands/automation#219](https://github.com/OpenHands/automation/issues/219) (`F23.run-status-polling`): Starting a Cloud automation at the user's sandbox cap can pause an existing interactive session instead of reaching the graceful Skipped–Limit Reached path. Recheck only on an owned disposable Cloud account at its recorded cap, with a live interactive fixture and a new automation run, then observe both run and conversation states. This run has no Cloud account/cap fixture; do not evict a production session as a test.\n- Known issue [OpenHands/automation#113](https://github.com/OpenHands/automation/issues/113) (`F23.run-linked-conversation`): Scheduled plugin jobs can start conversations visible in the archived list while the automation run UI says no conversation started. Recheck the affected automation/run ids, archived conversation ids and deployment version, using the real callback relationship; these identifiers and version are not provided. This is a reported missing-link state, not proof that no agent ran.\n- Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.\n- Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.\n- Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass.\n- Known issue [OpenHands/software-agent-sdk#5938](https://github.com/OpenHands/software-agent-sdk/issues/5938) (`F23.run-status-polling`): prompt/plugin preset workers register an event callback that forwards `ActionEvent.summary` as the live phase. After an events-socket reconnect, REST-reconciled events reach SDK state but are not replayed to that callback, so summaries emitted in the gap can be missed. Recheck an owned real preset worker through a bounded reconnect window while a genuine action is recorded; compare callback-driven phase with stored events. Current Canvas phase rendering has its separate [#17121](https://github.com/OpenHands/OpenHands/issues/17121) gap, and the worker's completion metadata is not claimed lost merely because this callback missed an event.",
      "features": [
        {
          "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. A DRAFT-state test automation shows a **Draft** badge (`active-status-badge-draft`), no switch, no paused banner and a kebab without Turn on/off."
        },
        {
          "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 the setup page `/automations/setup?automationId=<id>` (#17688; the **Edit automation** modal is gone) prefilled with Name, Prompt, agent profile and LLM profile pickers, the trigger (Schedule with Frequency, or Event), timezone and Timeout seconds, with **Test**, **Save** and **Close** in the toolbar and a docked composer; no conversation exists until a prompt is sent."
        },
        {
          "id": "F23.edit-close",
          "description": "**Close** leaves without saving and lands on `/automations` (the dashboard, not the detail page); Escape does nothing on the page."
        },
        {
          "id": "F23.edit-validation",
          "description": "an empty prompt is refused with the toast **Add a prompt before continuing.** (no request); a bad cron, a cron that never runs or a timeout above 1800 is sent to the API, refused with `422`, and its message is the single error toast (`Value error, Invalid cron expression: not a cron`, `Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *`, `Value error, timeout must not exceed 1800 seconds (30 minutes)`); nothing is saved."
        },
        {
          "id": "F23.edit-save",
          "description": "Save sends one PATCH (also when nothing changed), shows **Saving…** then **Saved just now**, the toast **Automation updated**, and the detail page and API reflect it; an Active profile model stays `null`."
        },
        {
          "id": "F23.edit-test",
          "description": "on the edit page **Test** dispatches the automation as last saved (`POST /api/automation/v1/<id>/dispatch`, no PATCH first) and shows `Test run started.` on the form; unsaved edits are not part of that run. Whether it should test the current form is a product decision (Gotchas)."
        },
        {
          "id": "F23.edit-dirty-state",
          "description": "the edit page shows no save state for unsaved edits (no **Unsaved changes**, which create mode shows, F28); the label appears only while saving (**Saving…**) and after (**Saved just now** or **Save failed**). **Close** discards the edits."
        },
        {
          "id": "F23.edit-schedule",
          "description": "Frequency offers Once, Hourly, Daily, Weekdays (Mon–Fri), Weekly and Custom; Weekly adds **Day of week** and **At**; they are saved as a cron expression and read back after a reload."
        },
        {
          "id": "F23.edit-time-cleared",
          "description": "clearing **At** on a preset schedule and saving should be refused with a message instead of being dropped or replaced. Known failure (reproduced 2026-10-13, [#17948](https://github.com/OpenHands/OpenHands/issues/17948) item, new symptom since #17688): Save silently stores midnight (`0 0 * * 3`) and toasts **Automation updated**; Save draft in the create flow stores `0 0 * * *` the same way. Open PR #18260 (head `0f812ac68902d2c71896568311bffb8aa6f216a6`, checked 2026-10-15) targets the live setup panel: it rejects an invalid or cleared time before Save and Save draft, with an edit-mode regression test. It is not merged; the known failure above remains the observed behavior on this map’s baseline."
        },
        {
          "id": "F23.edit-cron",
          "description": "a custom cron schedule shows Frequency **Custom** with an editable **Custom** cron field and a timezone select."
        },
        {
          "id": "F23.edit-event-readonly",
          "description": "for an event automation the edit page shows Event selected with Event source, event key and filter controls (editable now, no longer 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 (`Automation not found` for an automation deleted meanwhile), the save state reads **Save failed** and the edits stay on the page; the toolbar **Test** on that page also toasts `Automation not found`."
        },
        {
          "id": "F23.phone",
          "description": "at 390×844 the detail page has no horizontal overflow, and the Edit page fits with its title, Test, Save and Close reachable."
        }
      ],
      "scenarios": [
        {
          "name": "F23--open-from-the-dashboard",
          "title": "Open from the dashboard",
          "ids": [
            "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`).",
          "raw": "**Open from the dashboard (`F23.open-detail`, `F23.header`).** 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`).",
          "source_line": 84,
          "expected": [
            "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.",
            "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. A DRAFT-state test automation shows a **Draft** badge (`active-status-badge-draft`), no switch, no paused banner and a kebab without Turn on/off."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--other-ways-in",
          "title": "Other ways in",
          "ids": [
            "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.",
          "raw": "**Other ways in (`F23.open-detail`, `F23.back-link`).** `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.",
          "source_line": 85,
          "expected": [
            "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.",
            "**Back to Automations** (on the page and on the not-found state) returns to `/automations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--states-loading-and-not-found",
          "title": "States: loading and not found",
          "ids": [
            "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-...`.",
          "raw": "**States: loading and not found (`F23.loading`, `F23.not-found`).** `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-...`.",
          "source_line": 86,
          "expected": [
            "`detail-skeleton` stands in while the automation loads.",
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--backend-unavailable",
          "title": "Backend unavailable",
          "ids": [
            "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.",
          "raw": "**Backend unavailable (`F23.backend-unavailable`).** 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.",
          "source_line": 87,
          "expected": [
            "with the automation service down, the page shows **Automations Unavailable** and **Retry**; Retry recovers once the service is back."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--load-error",
          "title": "Load error",
          "ids": [
            "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.",
          "raw": "**Load error (`F23.load-error`).** `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.",
          "source_line": 88,
          "expected": [
            "when the health check is still good but the automation request fails, the page shows **Failed to load automations** and **Retry**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--turn-on-with-the-switch",
          "title": "Turn on with the switch",
          "ids": [
            "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`.",
          "raw": "**Turn on with the switch (`F23.toggle`).** 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`.",
          "source_line": 89,
          "expected": [
            "the header switch flips the enabled flag at once (no confirmation); the badge, switch and Run now follow and survive a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--run-now-and-live-status",
          "title": "Run now and live status",
          "ids": [
            "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`.",
          "raw": "**Run now and live status (`F23.run-now`, `F23.run-status-polling`, `F23.activity-log`).** 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`.",
          "source_line": 90,
          "expected": [
            "**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).",
            "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.",
            "**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>`)."
          ],
          "notes": [
            "Known issue [#15465](https://github.com/OpenHands/OpenHands/issues/15465) (`F23.configuration-event` `F23.activity-log`): An automation configured for Agent Canvas release events never runs automatically, although Run now works. Recheck a real signed GitHub/Forgejo release webhook through an externally reachable responder into a disposable automation and inspect its activity log; local manual dispatch cannot verify event delivery. No external repository webhook credentials or controlled release-event fixture are available.",
            "Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.",
            "Known issue [OpenHands/automation#286](https://github.com/OpenHands/automation/issues/286) (`F23.run-status-polling`): Multi-worker/replica automation deployments can run duplicate watchdogs that query the same sandbox and hit rate limits, falsely failing runs. Recheck the real deployment's worker/replica configuration with an owned long-running Cloud run and sandbox verification traffic; this single-worker local stack cannot prove distributed scheduler/watchdog ownership.",
            "Known issue [OpenHands/automation#219](https://github.com/OpenHands/automation/issues/219) (`F23.run-status-polling`): Starting a Cloud automation at the user's sandbox cap can pause an existing interactive session instead of reaching the graceful Skipped–Limit Reached path. Recheck only on an owned disposable Cloud account at its recorded cap, with a live interactive fixture and a new automation run, then observe both run and conversation states. This run has no Cloud account/cap fixture; do not evict a production session as a test.",
            "Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.",
            "Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.",
            "Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass.",
            "Known issue [OpenHands/software-agent-sdk#5938](https://github.com/OpenHands/software-agent-sdk/issues/5938) (`F23.run-status-polling`): prompt/plugin preset workers register an event callback that forwards `ActionEvent.summary` as the live phase. After an events-socket reconnect, REST-reconciled events reach SDK state but are not replayed to that callback, so summaries emitted in the gap can be missed. Recheck an owned real preset worker through a bounded reconnect window while a genuine action is recorded; compare callback-driven phase with stored events. Current Canvas phase rendering has its separate [#17121](https://github.com/OpenHands/OpenHands/issues/17121) gap, and the worker's completion metadata is not claimed lost merely because this callback missed an event."
          ],
          "annotations": [],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F23--linked-conversation",
          "title": "Linked conversation",
          "ids": [
            "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'`).",
          "raw": "**Linked conversation (`F23.run-linked-conversation`).** 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'`).",
          "source_line": 91,
          "expected": [
            "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>`."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5279](https://github.com/OpenHands/software-agent-sdk/issues/5279) (`F23.run-linked-conversation`): An automation that attaches to an existing remote conversation can complete with no conversation link in its run, because the workspace callback does not register the attached id. Recheck a Cloud automation script that calls `RemoteConversation.attach` on an owned stable conversation and exits through the real workspace completion callback, then inspect the Activity Log link. Blocked without that authenticated Cloud workspace/callback context; a plain local GET is not callback proof.",
            "Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.",
            "Known issue [OpenHands/automation#113](https://github.com/OpenHands/automation/issues/113) (`F23.run-linked-conversation`): Scheduled plugin jobs can start conversations visible in the archived list while the automation run UI says no conversation started. Recheck the affected automation/run ids, archived conversation ids and deployment version, using the real callback relationship; these identifiers and version are not provided. This is a reported missing-link state, not proof that no agent ran."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F23--run-deep-link",
          "title": "Run deep link",
          "ids": [
            "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.",
          "raw": "**Run deep link (`F23.run-deeplink`).** 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.",
          "source_line": 92,
          "expected": [
            "`?run=<runId>` highlights that run (`automation-run-highlight-<runId>`, class `bg-focus/10`) and scrolls to it; an unknown run id highlights nothing."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--logs-dialog",
          "title": "Logs dialog",
          "ids": [
            "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.",
          "raw": "**Logs dialog (`F23.run-logs-modal`).** 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.",
          "source_line": 93,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.",
            "Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.",
            "Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.",
            "Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.",
            "Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass."
          ],
          "annotations": [],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F23--export-the-log",
          "title": "Export the log",
          "ids": [
            "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`.",
          "raw": "**Export the log (`F23.activity-log-export`).** `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`.",
          "source_line": 94,
          "expected": [
            "**Export JSON** / **Export CSV** download `<name-slug>.activity-log.json|csv` with every run, including `conversation_url`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--export-and-tarball",
          "title": "Export and Tarball",
          "ids": [
            "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`.",
          "raw": "**Export and Tarball (`F23.export`, `F23.tarball`).** `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`.",
          "source_line": 95,
          "expected": [
            "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`.",
            "kebab **Tarball** downloads the automation's code bundle."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--turn-off-and-the-paused-banner",
          "title": "Turn off and the paused banner",
          "ids": [
            "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`.",
          "raw": "**Turn off and the paused banner (`F23.disabled-reason`, `F23.toggle`).** 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`.",
          "source_line": 96,
          "expected": [
            "after **Turn off**, the banner `automation-disabled-reason-banner` reads **Why this is paused** / **Turned off manually** / **Paused <time>**; it is gone while active.",
            "the header switch flips the enabled flag at once (no confirmation); the badge, switch and Run now follow and survive a reload."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--edit-fields-and-closing",
          "title": "Edit fields and closing",
          "ids": [
            "F23.edit-open",
            "F23.edit-close"
          ],
          "body": "On Pong run `control-openhands browser click 'role=button[name=\"Automation actions\"]'` and `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Edit\"]' --expect-url '/automations/setup'`: the URL is `/automations/setup?automationId=<id>`, `browser count 'testid=edit-automation-save'` is `0` (no modal), and `browser value` reads `automation-setup-name` (`QA_F23 Pong`), `automation-setup-prompt`, the stored schedule (a custom cron shows Frequency Custom with `automation-setup-custom-schedule`) and `automation-setup-timeout` (`600`); `browser text 'testid=automation-setup-agent-profile'` is `default` and `browser text 'testid=automation-setup-model'` names the active profile (the stored `model` stays `null`). `browser testids 'testid=automation-setup-toolbar'` lists `automation-setup-test`, `automation-setup-save` and `automation-setup-close`; `control-openhands conversation list` is unchanged. `control-openhands browser press Escape` leaves the URL unchanged; `control-openhands browser click 'testid=automation-setup-close' --expect-url '/automations$'` lands on the dashboard without a PATCH (the dashboard's own GETs follow). A cold `control-openhands browser goto '/automations/setup?automationId=<id>'` opens the same form.",
          "raw": "**Edit fields and closing (`F23.edit-open`, `F23.edit-close`).** On Pong run `control-openhands browser click 'role=button[name=\"Automation actions\"]'` and `control-openhands browser click 'role=list >> has-text=Tarball >> role=button[name=\"Edit\"]' --expect-url '/automations/setup'`: the URL is `/automations/setup?automationId=<id>`, `browser count 'testid=edit-automation-save'` is `0` (no modal), and `browser value` reads `automation-setup-name` (`QA_F23 Pong`), `automation-setup-prompt`, the stored schedule (a custom cron shows Frequency Custom with `automation-setup-custom-schedule`) and `automation-setup-timeout` (`600`); `browser text 'testid=automation-setup-agent-profile'` is `default` and `browser text 'testid=automation-setup-model'` names the active profile (the stored `model` stays `null`). `browser testids 'testid=automation-setup-toolbar'` lists `automation-setup-test`, `automation-setup-save` and `automation-setup-close`; `control-openhands conversation list` is unchanged. `control-openhands browser press Escape` leaves the URL unchanged; `control-openhands browser click 'testid=automation-setup-close' --expect-url '/automations$'` lands on the dashboard without a PATCH (the dashboard's own GETs follow). A cold `control-openhands browser goto '/automations/setup?automationId=<id>'` opens the same form.",
          "source_line": 97,
          "expected": [
            "kebab **Edit** opens the setup page `/automations/setup?automationId=<id>` (#17688; the **Edit automation** modal is gone) prefilled with Name, Prompt, agent profile and LLM profile pickers, the trigger (Schedule with Frequency, or Event), timezone and Timeout seconds, with **Test**, **Save** and **Close** in the toolbar and a docked composer; no conversation exists until a prompt is sent.",
            "**Close** leaves without saving and lands on `/automations` (the dashboard, not the detail page); Escape does nothing on the page."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--validation",
          "title": "Validation",
          "ids": [
            "F23.edit-validation"
          ],
          "body": "On the edit page run `control-openhands browser fill 'testid=automation-setup-prompt' ''`, `control-openhands browser network --clear` and `control-openhands browser click 'testid=automation-setup-save'`: `control-openhands browser toasts --history` includes the toast `Add a prompt before continuing.` and `browser network --filter 'automation/v1'` lists no request. Refill the prompt. On a Custom-cron automation (QA_F23 Fail) fill `testid=automation-setup-custom-schedule` with `not a cron` (then `0 0 31 2 *`, then a valid cron with `testid=automation-setup-timeout` `1801`), click Save and read `control-openhands browser toasts --history` after `browser toasts --history --clear`. Each sends a `PATCH` that returns `422` (`browser network --filter 'automation/v1'`), and the history holds exactly one toast with the API message: `Value error, Invalid cron expression: not a cron`, `Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *` and `Value error, timeout must not exceed 1800 seconds (30 minutes)`; `api GET /api/automation/v1/<Fail id>` keeps `0 0 1 1 *` and timeout `600`. Before each new click move the pointer off the toast (`browser hover 'testid=automation-setup-name'`, about 5 s; see Gotchas).",
          "raw": "**Validation (`F23.edit-validation`).** On the edit page run `control-openhands browser fill 'testid=automation-setup-prompt' ''`, `control-openhands browser network --clear` and `control-openhands browser click 'testid=automation-setup-save'`: `control-openhands browser toasts --history` includes the toast `Add a prompt before continuing.` and `browser network --filter 'automation/v1'` lists no request. Refill the prompt. On a Custom-cron automation (QA_F23 Fail) fill `testid=automation-setup-custom-schedule` with `not a cron` (then `0 0 31 2 *`, then a valid cron with `testid=automation-setup-timeout` `1801`), click Save and read `control-openhands browser toasts --history` after `browser toasts --history --clear`. Each sends a `PATCH` that returns `422` (`browser network --filter 'automation/v1'`), and the history holds exactly one toast with the API message: `Value error, Invalid cron expression: not a cron`, `Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *` and `Value error, timeout must not exceed 1800 seconds (30 minutes)`; `api GET /api/automation/v1/<Fail id>` keeps `0 0 1 1 *` and timeout `600`. Before each new click move the pointer off the toast (`browser hover 'testid=automation-setup-name'`, about 5 s; see Gotchas).",
          "source_line": 98,
          "expected": [
            "an empty prompt is refused with the toast **Add a prompt before continuing.** (no request); a bad cron, a cron that never runs or a timeout above 1800 is sent to the API, refused with `422`, and its message is the single error toast (`Value error, Invalid cron expression: not a cron`, `Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *`, `Value error, timeout must not exceed 1800 seconds (30 minutes)`); nothing is saved."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--no-change-save",
          "title": "No-change Save",
          "ids": [
            "F23.edit-save"
          ],
          "body": "Open Edit, `control-openhands browser network --clear`, click `testid=automation-setup-save`, then `control-openhands browser network --filter 'automation/v1'`: one `PATCH` with the unchanged values and the toast `Automation updated` (the old modal sent nothing).",
          "raw": "**No-change Save (`F23.edit-save`).** Open Edit, `control-openhands browser network --clear`, click `testid=automation-setup-save`, then `control-openhands browser network --filter 'automation/v1'`: one `PATCH` with the unchanged values and the toast `Automation updated` (the old modal sent nothing).",
          "source_line": 99,
          "expected": [
            "Save sends one PATCH (also when nothing changed), shows **Saving…** then **Saved just now**, the toast **Automation updated**, and the detail page and API reflect it; an Active profile model stays `null`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--weekly-schedule-model-and-timeout",
          "title": "Weekly schedule, model and timeout",
          "ids": [
            "F23.edit-schedule",
            "F23.edit-save"
          ],
          "body": "On the edit page run `control-openhands browser click 'testid=automation-setup-frequency-weekly'`, `control-openhands browser select 'testid=automation-setup-weekday' 3` (Wednesday; options `0:Sunday` … `6:Saturday`), `control-openhands browser fill 'testid=automation-setup-time' 10:30`, `control-openhands browser click 'testid=automation-setup-model'`, `control-openhands browser click 'testid=automation-setup-model-option-deepseek-pro'`, `control-openhands browser fill 'testid=automation-setup-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=automation-setup-save' --observe 'testid=automation-setup-save-state' --observe-ms 2000` (`Saving…` → `Saved just now`) and `control-openhands browser toasts --history` (`Automation updated`). `browser network --bodies --filter 'automation/v1'` shows one `PATCH /api/automation/v1/<id>` with `schedule` `30 10 * * 3`, `model` `deepseek-pro` and `timeout` `300`. After `control-openhands browser reload` the weekday is `3`, At `10:30`, the model `deepseek-pro` and Timeout `300`; the detail page Configuration reads `Schedule` `30 10 * * 3` and `LLM profile` `deepseek-pro`.",
          "raw": "**Weekly schedule, model and timeout (`F23.edit-schedule`, `F23.edit-save`).** On the edit page run `control-openhands browser click 'testid=automation-setup-frequency-weekly'`, `control-openhands browser select 'testid=automation-setup-weekday' 3` (Wednesday; options `0:Sunday` … `6:Saturday`), `control-openhands browser fill 'testid=automation-setup-time' 10:30`, `control-openhands browser click 'testid=automation-setup-model'`, `control-openhands browser click 'testid=automation-setup-model-option-deepseek-pro'`, `control-openhands browser fill 'testid=automation-setup-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=automation-setup-save' --observe 'testid=automation-setup-save-state' --observe-ms 2000` (`Saving…` → `Saved just now`) and `control-openhands browser toasts --history` (`Automation updated`). `browser network --bodies --filter 'automation/v1'` shows one `PATCH /api/automation/v1/<id>` with `schedule` `30 10 * * 3`, `model` `deepseek-pro` and `timeout` `300`. After `control-openhands browser reload` the weekday is `3`, At `10:30`, the model `deepseek-pro` and Timeout `300`; the detail page Configuration reads `Schedule` `30 10 * * 3` and `LLM profile` `deepseek-pro`.",
          "source_line": 100,
          "expected": [
            "Frequency offers Once, Hourly, Daily, Weekdays (Mon–Fri), Weekly and Custom; Weekly adds **Day of week** and **At**; they are saved as a cron expression and read back after a reload.",
            "Save sends one PATCH (also when nothing changed), shows **Saving…** then **Saved just now**, the toast **Automation updated**, and the detail page and API reflect it; an Active profile model stays `null`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--cleared-time",
          "title": "Cleared time",
          "ids": [
            "F23.edit-time-cleared"
          ],
          "body": "On the weekly edit page `control-openhands browser fill 'testid=automation-setup-time' ''`, `control-openhands browser network --clear`, click Save. Expected: an inline message and nothing saved. Known failure (reproduced 2026-10-13, [#17948](https://github.com/OpenHands/OpenHands/issues/17948) item, new symptom since #17688): `browser network --bodies --filter 'automation/v1'` shows a `PATCH` with `schedule` `0 0 * * 3`, the toast reads `Automation updated` and the automation now runs at midnight. Save draft in the create flow (`/automations/setup`) stores `0 0 * * *` the same way; open PR #18260 targets both paths in `automation-setup-panel.tsx` at head `0f812ac68902d2c71896568311bffb8aa6f216a6` (checked 2026-10-15), but is not merged.",
          "raw": "**Cleared time (`F23.edit-time-cleared`).** On the weekly edit page `control-openhands browser fill 'testid=automation-setup-time' ''`, `control-openhands browser network --clear`, click Save. Expected: an inline message and nothing saved. Known failure (reproduced 2026-10-13, [#17948](https://github.com/OpenHands/OpenHands/issues/17948) item, new symptom since #17688): `browser network --bodies --filter 'automation/v1'` shows a `PATCH` with `schedule` `0 0 * * 3`, the toast reads `Automation updated` and the automation now runs at midnight. Save draft in the create flow (`/automations/setup`) stores `0 0 * * *` the same way; open PR #18260 targets both paths in `automation-setup-panel.tsx` at head `0f812ac68902d2c71896568311bffb8aa6f216a6` (checked 2026-10-15), but is not merged.",
          "source_line": 101,
          "expected": [
            "clearing **At** on a preset schedule and saving should be refused with a message instead of being dropped or replaced."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-13, [#17948](https://github.com/OpenHands/OpenHands/issues/17948) item, new symptom since #17688): `browser network --bodies --filter 'automation/v1'` shows a `PATCH` with `schedule` `0 0 * * 3`, the toast reads `Automation updated` and the automation now runs at midnight. Save draft in the create flow (`/automations/setup`) stores `0 0 * * *` the same way; open PR #18260 targets both paths in `automation-setup-panel.tsx` at head `0f812ac68902d2c71896568311bffb8aa6f216a6` (checked 2026-10-15), but is not merged.",
            "Known failure (reproduced 2026-10-13, [#17948](https://github.com/OpenHands/OpenHands/issues/17948) item, new symptom since #17688): Save silently stores midnight (`0 0 * * 3`) and toasts **Automation updated**; Save draft in the create flow stores `0 0 * * *` the same way. Open PR #18260 (head `0f812ac68902d2c71896568311bffb8aa6f216a6`, checked 2026-10-15) targets the live setup panel: it rejects an invalid or cleared time before Save and Save draft, with an edit-mode regression test. It is not merged; the known failure above remains the observed behavior on this map’s baseline."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F23--custom-cron",
          "title": "Custom cron",
          "ids": [
            "F23.edit-cron"
          ],
          "body": "Open QA_F23 Fail and Edit: Frequency **Custom** is selected and `control-openhands browser value 'testid=automation-setup-custom-schedule'` is `0 0 1 1 *` beside the timezone select (`UTC`). Fill `0 0 2 1 *` and `testid=automation-setup-timeout` with `1` (this makes the next run fail), Save and `browser toasts --history` (`Automation updated`); after a reload of the detail page the Configuration shows `0 0 2 1 *`. Invalid crons: see Validation.",
          "raw": "**Custom cron (`F23.edit-cron`).** Open QA_F23 Fail and Edit: Frequency **Custom** is selected and `control-openhands browser value 'testid=automation-setup-custom-schedule'` is `0 0 1 1 *` beside the timezone select (`UTC`). Fill `0 0 2 1 *` and `testid=automation-setup-timeout` with `1` (this makes the next run fail), Save and `browser toasts --history` (`Automation updated`); after a reload of the detail page the Configuration shows `0 0 2 1 *`. Invalid crons: see Validation.",
          "source_line": 102,
          "expected": [
            "a custom cron schedule shows Frequency **Custom** with an editable **Custom** cron field and a timezone select."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--failed-run-its-row-and-debug-with-openhands",
          "title": "Failed run, its row and Debug with OpenHands",
          "ids": [
            "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.",
          "raw": "**Failed run, its row and Debug with OpenHands (`F23.run-status-polling`, `F23.run-no-conversation`, `F23.run-logs-modal`, `F23.debug-with-openhands`).** 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.",
          "source_line": 103,
          "expected": [
            "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.",
            "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.\"",
            "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.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.",
            "Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.",
            "Known issue [OpenHands/automation#286](https://github.com/OpenHands/automation/issues/286) (`F23.run-status-polling`): Multi-worker/replica automation deployments can run duplicate watchdogs that query the same sandbox and hit rate limits, falsely failing runs. Recheck the real deployment's worker/replica configuration with an owned long-running Cloud run and sandbox verification traffic; this single-worker local stack cannot prove distributed scheduler/watchdog ownership.",
            "Known issue [OpenHands/automation#219](https://github.com/OpenHands/automation/issues/219) (`F23.run-status-polling`): Starting a Cloud automation at the user's sandbox cap can pause an existing interactive session instead of reaching the graceful Skipped–Limit Reached path. Recheck only on an owned disposable Cloud account at its recorded cap, with a live interactive fixture and a new automation run, then observe both run and conversation states. This run has no Cloud account/cap fixture; do not evict a production session as a test.",
            "Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.",
            "Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.",
            "Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass.",
            "Known issue [OpenHands/software-agent-sdk#5938](https://github.com/OpenHands/software-agent-sdk/issues/5938) (`F23.run-status-polling`): prompt/plugin preset workers register an event callback that forwards `ActionEvent.summary` as the live phase. After an events-socket reconnect, REST-reconciled events reach SDK state but are not replayed to that callback, so summaries emitted in the gap can be missed. Recheck an owned real preset worker through a bounded reconnect window while a genuine action is recorded; compare callback-driven phase with stored events. Current Canvas phase rendering has its separate [#17121](https://github.com/OpenHands/OpenHands/issues/17121) gap, and the worker's completion metadata is not claimed lost merely because this callback missed an event."
          ],
          "annotations": [],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F23--task-outcome-badge",
          "title": "Task outcome badge",
          "ids": [
            "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).",
          "raw": "**Task outcome badge (`F23.run-task-outcome`).** 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).",
          "source_line": 104,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--event-automation",
          "title": "Event automation",
          "ids": [
            "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).",
          "raw": "**Event automation (`F23.prompt-collapse`, `F23.configuration-event`, `F23.plugins-repo`, `F23.export`).** 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).",
          "source_line": 105,
          "expected": [
            "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.",
            "an event automation shows Trigger **Event**, **Event Source**, **Event Type** and **Filter**, truncated at 60 characters with **Show more**/**Show less**.",
            "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.",
            "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`."
          ],
          "notes": [
            "Known issue [#15465](https://github.com/OpenHands/OpenHands/issues/15465) (`F23.configuration-event` `F23.activity-log`): An automation configured for Agent Canvas release events never runs automatically, although Run now works. Recheck a real signed GitHub/Forgejo release webhook through an externally reachable responder into a disposable automation and inspect its activity log; local manual dispatch cannot verify event delivery. No external repository webhook credentials or controlled release-event fixture are available."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F23--event-trigger-in-edit",
          "title": "Event trigger in Edit",
          "ids": [
            "F23.edit-event-readonly"
          ],
          "body": "On QA_F23 Event open Edit: Event is selected, `browser testids 'testid=automation-setup-form' --filter event` lists `automation-setup-event-source` (GitHub), `automation-setup-event-source-toggle`, `automation-setup-event-public-url-notice`, `automation-setup-event-key`, `automation-setup-event-filter` and `automation-setup-event-filter-remove` (the fixture has a filter), and there are no Frequency controls for a schedule. The trigger is editable here (it was read-only text in the old modal). Screenshot with `--feature F23.edit-event-readonly --name event`, then Close.",
          "raw": "**Event trigger in Edit (`F23.edit-event-readonly`).** On QA_F23 Event open Edit: Event is selected, `browser testids 'testid=automation-setup-form' --filter event` lists `automation-setup-event-source` (GitHub), `automation-setup-event-source-toggle`, `automation-setup-event-public-url-notice`, `automation-setup-event-key`, `automation-setup-event-filter` and `automation-setup-event-filter-remove` (the fixture has a filter), and there are no Frequency controls for a schedule. The trigger is editable here (it was read-only text in the old modal). Screenshot with `--feature F23.edit-event-readonly --name event`, then Close.",
          "source_line": 106,
          "expected": [
            "for an event automation the edit page shows Event selected with Event source, event key and filter controls (editable now, no longer read-only text) and no schedule fields."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--phone",
          "title": "Phone",
          "ids": [
            "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 (kebab, `--expect-url '/automations/setup'`), then `control-openhands browser bbox 'testid=automation-setup-save'` and `... 'testid=automation-setup-close'` (both `insideViewport` `true`, Save at `y` about 65) and `control-openhands browser bbox 'testid=automation-setup-form'` (`pageHorizontalOverflow` `false`); screenshot with `--feature F23.phone --name edit-setup-page`. Close and `control-openhands browser viewport desktop`.",
          "raw": "**Phone (`F23.phone`).** 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 (kebab, `--expect-url '/automations/setup'`), then `control-openhands browser bbox 'testid=automation-setup-save'` and `... 'testid=automation-setup-close'` (both `insideViewport` `true`, Save at `y` about 65) and `control-openhands browser bbox 'testid=automation-setup-form'` (`pageHorizontalOverflow` `false`); screenshot with `--feature F23.phone --name edit-setup-page`. Close and `control-openhands browser viewport desktop`.",
          "source_line": 107,
          "expected": [
            "at 390×844 the detail page has no horizontal overflow, and the Edit page fits with its title, Test, Save and Close reachable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--run-now-and-save-errors",
          "title": "Run now and save errors",
          "ids": [
            "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](https://github.com/OpenHands/OpenHands/issues/18062)). The save error does not reuse that page (it is gone once [#18062](https://github.com/OpenHands/OpenHands/issues/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>`), open QA_F23 Stale, its kebab and `role=list >> has-text=Tarball >> role=button[name=\"Edit\"]` (`--expect-url '/automations/setup'`). With the edit page open, `control-openhands api DELETE /api/automation/v1/<stale-id> --write` and `control-openhands browser toasts --history --clear`. Fill `testid=automation-setup-name` with `QA_F23 Stale renamed` and click `testid=automation-setup-save`: the page keeps the edit (`browser value 'testid=automation-setup-name'` still `QA_F23 Stale renamed`) and `browser toasts --history` holds exactly one toast, `Automation not found`. Screenshot with `--feature F23.edit-save-error --name toast`. Move the pointer off the toast (`browser hover 'testid=automation-setup-name'`, about 5 s), `browser toasts --history --clear` and click `testid=automation-setup-test`: again exactly one toast, `Automation not found`. Close. `browser errors --app-only` lists the induced `POST /api/automation/v1/<Fail id>/dispatch`, `PATCH /api/automation/v1/<stale-id>` and `POST /api/automation/v1/<stale-id>/dispatch` 404s; both fixtures are already deleted.",
          "raw": "**Run now and save errors (`F23.run-now-error`, `F23.edit-save-error`).** 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](https://github.com/OpenHands/OpenHands/issues/18062)). The save error does not reuse that page (it is gone once [#18062](https://github.com/OpenHands/OpenHands/issues/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>`), open QA_F23 Stale, its kebab and `role=list >> has-text=Tarball >> role=button[name=\"Edit\"]` (`--expect-url '/automations/setup'`). With the edit page open, `control-openhands api DELETE /api/automation/v1/<stale-id> --write` and `control-openhands browser toasts --history --clear`. Fill `testid=automation-setup-name` with `QA_F23 Stale renamed` and click `testid=automation-setup-save`: the page keeps the edit (`browser value 'testid=automation-setup-name'` still `QA_F23 Stale renamed`) and `browser toasts --history` holds exactly one toast, `Automation not found`. Screenshot with `--feature F23.edit-save-error --name toast`. Move the pointer off the toast (`browser hover 'testid=automation-setup-name'`, about 5 s), `browser toasts --history --clear` and click `testid=automation-setup-test`: again exactly one toast, `Automation not found`. Close. `browser errors --app-only` lists the induced `POST /api/automation/v1/<Fail id>/dispatch`, `PATCH /api/automation/v1/<stale-id>` and `POST /api/automation/v1/<stale-id>/dispatch` 404s; both fixtures are already deleted.",
          "source_line": 108,
          "expected": [
            "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.",
            "a failed save shows exactly one error toast with the API message (`Automation not found` for an automation deleted meanwhile), the save state reads **Save failed** and the edits stay on the page; the toolbar **Test** on that page also toasts `Automation not found`."
          ],
          "notes": [],
          "annotations": [
            "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](https://github.com/OpenHands/OpenHands/issues/18062)). The save error does not reuse that page (it is gone once [#18062](https://github.com/OpenHands/OpenHands/issues/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>`), open QA_F23 Stale, its kebab and `role=list >> has-text=Tarball >> role=button[name=\"Edit\"]` (`--expect-url '/automations/setup'`). With the edit page open, `control-openhands api DELETE /api/automation/v1/<stale-id> --write` and `control-openhands browser toasts --history --clear`. Fill `testid=automation-setup-name` with `QA_F23 Stale renamed` and click `testid=automation-setup-save`: the page keeps the edit (`browser value 'testid=automation-setup-name'` still `QA_F23 Stale renamed`) and `browser toasts --history` holds exactly one toast, `Automation not found`. Screenshot with `--feature F23.edit-save-error --name toast`. Move the pointer off the toast (`browser hover 'testid=automation-setup-name'`, about 5 s), `browser toasts --history --clear` and click `testid=automation-setup-test`: again exactly one toast, `Automation not found`. Close. `browser errors --app-only` lists the induced `POST /api/automation/v1/<Fail id>/dispatch`, `PATCH /api/automation/v1/<stale-id>` and `POST /api/automation/v1/<stale-id>/dispatch` 404s; both fixtures are already deleted."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F23--load-more-runs",
          "title": "Load more runs",
          "ids": [
            "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.",
          "raw": "**Load more runs (`F23.activity-log-more`).** 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.",
          "source_line": 109,
          "expected": [
            "with more than 20 runs a **Load more** button grows the page by 20."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "not-run"
          ]
        },
        {
          "name": "F23--script-automation",
          "title": "Script automation",
          "ids": [
            "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.",
          "raw": "**Script automation (`F23.script-section`).** 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.",
          "source_line": 110,
          "expected": [
            "a script (tarball) automation shows **Script** with the entrypoint and the bundle's files in place of the Prompt."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--edit-mode-test-and-unsaved-edits",
          "title": "Edit-mode Test and unsaved edits",
          "ids": [
            "F23.edit-test",
            "F23.edit-dirty-state"
          ],
          "body": "Open QA_F23 Event and its kebab **Edit** (`--expect-url 'automationId='`). Fill `testid=automation-setup-name` with `QA_F23 Unsaved`: `control-openhands browser count 'testid=automation-setup-save-state'` is `0`. `control-openhands browser network --clear`, click `testid=automation-setup-test`; `control-openhands browser network --filter 'automation/v1'` shows `POST …/<id>/dispatch 201` and no `PATCH`, and the form shows `Test run started.` Move the pointer off the toast (`control-openhands browser hover 'testid=automation-setup-prompt'`) and click `testid=automation-setup-close` (`--expect-url '/automations$'`): the card still reads `QA_F23 Event`. The dispatched run is not checked here; Delete below removes it with the automation.",
          "raw": "**Edit-mode Test and unsaved edits (`F23.edit-test`, `F23.edit-dirty-state`).** Open QA_F23 Event and its kebab **Edit** (`--expect-url 'automationId='`). Fill `testid=automation-setup-name` with `QA_F23 Unsaved`: `control-openhands browser count 'testid=automation-setup-save-state'` is `0`. `control-openhands browser network --clear`, click `testid=automation-setup-test`; `control-openhands browser network --filter 'automation/v1'` shows `POST …/<id>/dispatch 201` and no `PATCH`, and the form shows `Test run started.` Move the pointer off the toast (`control-openhands browser hover 'testid=automation-setup-prompt'`) and click `testid=automation-setup-close` (`--expect-url '/automations$'`): the card still reads `QA_F23 Event`. The dispatched run is not checked here; Delete below removes it with the automation.",
          "source_line": 111,
          "expected": [
            "on the edit page **Test** dispatches the automation as last saved (`POST /api/automation/v1/<id>/dispatch`, no PATCH first) and shows `Test run started.` on the form; unsaved edits are not part of that run. Whether it should test the current form is a product decision (Gotchas).",
            "the edit page shows no save state for unsaved edits (no **Unsaved changes**, which create mode shows, F28); the label appears only while saving (**Saving…**) and after (**Saved just now** or **Save failed**). **Close** discards the edits."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--delete",
          "title": "Delete",
          "ids": [
            "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`.",
          "raw": "**Delete (`F23.delete`).** 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`.",
          "source_line": 112,
          "expected": [
            "kebab **Delete** asks `Delete \"<name>\"? This cannot be undone.`; Cancel keeps it, Delete removes it and returns to `/automations`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F23--errors",
          "title": "Errors",
          "ids": [],
          "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).",
          "raw": "**Errors.** 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).",
          "source_line": 113,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F23--utf-8-script-member",
          "title": "UTF-8 script member",
          "ids": [
            "F23.script-section"
          ],
          "body": "Arrange an inactive annual-cron script automation through the real upload/create APIs with a two-file UTF-8 USTAR bundle (`tarfile.USTAR_FORMAT`, names `café.py` and `main.py`) and entrypoint `python3 café.py`; do not use a PAX-only member name. Open `/automations/<id>` and inspect `automation-script-entrypoint`, both `automation-script-file` headings, and the screenshot. Expected: `café.py` keeps its name, receives the **Entrypoint** badge and sorts first. Known failure, reproduced 2026-10-12 on `fa1f724`: the command is correct, but the file heading is `cafÃ©.py` without the badge; the ASCII control reads normally ([#17704](https://github.com/OpenHands/OpenHands/issues/17704)). No run or model is needed.",
          "raw": "**UTF-8 script member (`F23.script-section`).** Arrange an inactive annual-cron script automation through the real upload/create APIs with a two-file UTF-8 USTAR bundle (`tarfile.USTAR_FORMAT`, names `café.py` and `main.py`) and entrypoint `python3 café.py`; do not use a PAX-only member name. Open `/automations/<id>` and inspect `automation-script-entrypoint`, both `automation-script-file` headings, and the screenshot. Expected: `café.py` keeps its name, receives the **Entrypoint** badge and sorts first. Known failure, reproduced 2026-10-12 on `fa1f724`: the command is correct, but the file heading is `cafÃ©.py` without the badge; the ASCII control reads normally ([#17704](https://github.com/OpenHands/OpenHands/issues/17704)). No run or model is needed.",
          "source_line": 115,
          "expected": [
            "a script (tarball) automation shows **Script** with the entrypoint and the bundle's files in place of the Prompt."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 on `fa1f724`: the command is correct, but the file heading is `cafÃ©.py` without the badge; the ASCII control reads normally ([#17704](https://github.com/OpenHands/OpenHands/issues/17704)). No run or model is needed."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F23--live-free-form-phase",
          "title": "Live free-form phase",
          "ids": [
            "F23.run-status-polling",
            "F23.activity-log"
          ],
          "body": "Upload an owned bounded script, create it active with the inert yearly cron, open its detail page, and click **Run now**. While genuinely running, compare `api GET /api/automation/v1/<id>/runs` with `browser snapshot 'testid=automation-activity-log'`. Expected: a run's `current_phase` renders as its live/last phase when structured fields are absent. Known failure, reproduced 2026-10-12 against automation 1.19.3: the API says `Starting automation` with no `phase_code`/`phase_label`, but Activity Log shows only **Running** ([#17121](https://github.com/OpenHands/OpenHands/issues/17121)). Keep the worker bounded and clean up only its owned command; the script needs no model.",
          "raw": "**Live free-form phase (`F23.run-status-polling`, `F23.activity-log`).** Upload an owned bounded script, create it active with the inert yearly cron, open its detail page, and click **Run now**. While genuinely running, compare `api GET /api/automation/v1/<id>/runs` with `browser snapshot 'testid=automation-activity-log'`. Expected: a run's `current_phase` renders as its live/last phase when structured fields are absent. Known failure, reproduced 2026-10-12 against automation 1.19.3: the API says `Starting automation` with no `phase_code`/`phase_label`, but Activity Log shows only **Running** ([#17121](https://github.com/OpenHands/OpenHands/issues/17121)). Keep the worker bounded and clean up only its owned command; the script needs no model.",
          "source_line": 116,
          "expected": [
            "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.",
            "**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>`)."
          ],
          "notes": [
            "Known issue [#15465](https://github.com/OpenHands/OpenHands/issues/15465) (`F23.configuration-event` `F23.activity-log`): An automation configured for Agent Canvas release events never runs automatically, although Run now works. Recheck a real signed GitHub/Forgejo release webhook through an externally reachable responder into a disposable automation and inspect its activity log; local manual dispatch cannot verify event delivery. No external repository webhook credentials or controlled release-event fixture are available.",
            "Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.",
            "Known issue [OpenHands/automation#286](https://github.com/OpenHands/automation/issues/286) (`F23.run-status-polling`): Multi-worker/replica automation deployments can run duplicate watchdogs that query the same sandbox and hit rate limits, falsely failing runs. Recheck the real deployment's worker/replica configuration with an owned long-running Cloud run and sandbox verification traffic; this single-worker local stack cannot prove distributed scheduler/watchdog ownership.",
            "Known issue [OpenHands/automation#219](https://github.com/OpenHands/automation/issues/219) (`F23.run-status-polling`): Starting a Cloud automation at the user's sandbox cap can pause an existing interactive session instead of reaching the graceful Skipped–Limit Reached path. Recheck only on an owned disposable Cloud account at its recorded cap, with a live interactive fixture and a new automation run, then observe both run and conversation states. This run has no Cloud account/cap fixture; do not evict a production session as a test.",
            "Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.",
            "Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.",
            "Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass.",
            "Known issue [OpenHands/software-agent-sdk#5938](https://github.com/OpenHands/software-agent-sdk/issues/5938) (`F23.run-status-polling`): prompt/plugin preset workers register an event callback that forwards `ActionEvent.summary` as the live phase. After an events-socket reconnect, REST-reconciled events reach SDK state but are not replayed to that callback, so summaries emitted in the gap can be missed. Recheck an owned real preset worker through a bounded reconnect window while a genuine action is recorded; compare callback-driven phase with stored events. Current Canvas phase rendering has its separate [#17121](https://github.com/OpenHands/OpenHands/issues/17121) gap, and the worker's completion metadata is not claimed lost merely because this callback missed an event."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 against automation 1.19.3: the API says `Starting automation` with no `phase_code`/`phase_label`, but Activity Log shows only **Running** ([#17121](https://github.com/OpenHands/OpenHands/issues/17121)). Keep the worker bounded and clean up only its owned command; the script needs no model."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F23--custom-nested-setup-f23-script-section-f23-run-logs-modal-creation-in-f22",
          "title": "Custom nested setup (`F23.script-section`, `F23.run-logs-modal`; creation in F22)",
          "ids": [
            "F23.script-section",
            "F23.run-logs-modal"
          ],
          "body": "Arrange a reviewed tarball containing `scripts/setup.sh` that writes `.qa-setup-marker` and `main.py` that asserts that marker exists. Upload with `api POST /api/automation/v1/uploads?name=qa-nested-setup --write --data-file <tarball> --content-type application/gzip`; create with `entrypoint: python3 main.py`, `setup_script_path: scripts/setup.sh`, active state and yearly cron. Open the detail page and click **Run now**; correlate its `bash_command_id` with `api GET '/api/bash/bash_events/search?command_id__eq=<id>&limit=100'`. Expected: the configured nested setup runs before the entrypoint. Known failure, reproduced 2026-10-12 against automation 1.19.3: the real BashOutput exits 1 with the marker assertion, proving the setup step was skipped ([OpenHands/automation#343](https://github.com/OpenHands/automation/issues/343)). Only an owned marker is written; no external service or model is used.",
          "raw": "**Custom nested setup (`F23.script-section`, `F23.run-logs-modal`; creation in F22).** Arrange a reviewed tarball containing `scripts/setup.sh` that writes `.qa-setup-marker` and `main.py` that asserts that marker exists. Upload with `api POST /api/automation/v1/uploads?name=qa-nested-setup --write --data-file <tarball> --content-type application/gzip`; create with `entrypoint: python3 main.py`, `setup_script_path: scripts/setup.sh`, active state and yearly cron. Open the detail page and click **Run now**; correlate its `bash_command_id` with `api GET '/api/bash/bash_events/search?command_id__eq=<id>&limit=100'`. Expected: the configured nested setup runs before the entrypoint. Known failure, reproduced 2026-10-12 against automation 1.19.3: the real BashOutput exits 1 with the marker assertion, proving the setup step was skipped ([OpenHands/automation#343](https://github.com/OpenHands/automation/issues/343)). Only an owned marker is written; no external service or model is used.",
          "source_line": 117,
          "expected": [
            "a script (tarball) automation shows **Script** with the entrypoint and the bundle's files in place of the Prompt.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#4705](https://github.com/OpenHands/software-agent-sdk/issues/4705) (`F23.run-status-polling`, `F23.run-logs-modal`): GitHub MCP tool events can omit `inputSchema`, causing an automation's RemoteConversation decoder/run log to report failure while the agent continues, so task and automation statuses disagree. Recheck a real GitHub-MCP-enabled run using the affected client/server versions and its retained tool-definition event; the issue does not give those versions. A generic MCP fixture does not establish the reported GitHub/version branch, and genuine run comparison needs a model key.",
            "Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.",
            "Known failure [OpenHands/automation#514](https://github.com/OpenHands/automation/issues/514) spans Home cancellation (`F03.cancel-in-flight-run`) and `F23.activity-log`/`F23.run-logs-modal`: on 2026-10-12 a genuine Home **Cancel run** changed the owned bounded local worker's row to **Cancelled**, while its Python entrypoint remained alive in the owned launcher ancestry. The F03 driving bullet owns upload/create/cancel steps and retained evidence. The worker later reached its 180 s bound and exited; a cancelled status alone does not prove process cleanup.",
            "Known issue [OpenHands/software-agent-sdk#4388](https://github.com/OpenHands/software-agent-sdk/issues/4388) (`F23.run-status-polling`, `F23.run-logs-modal`): incremental SDK Bash output polling can return empty pages before newer chunks when `order__gt` is applied after the file-page boundary. Canvas's frontend log reader does not send that filter, but automation launch preparation and prompt/plugin preset repository clones use `(Async)RemoteWorkspace.execute_command`, whose output poller does. Recheck a bounded genuine preset/setup or repository-clone command that emits enough chunks to cross the 100-event boundary, correlating its final BashOutput and real run result; a frontend Logs read or a one-output command does not cover the incremental branch.",
            "Known issue [OpenHands/software-agent-sdk#5937](https://github.com/OpenHands/software-agent-sdk/issues/5937) (`F23.run-status-polling`, `F23.run-logs-modal`): prompt/plugin preset workers attach `AUTOMATION_CONVERSATION_ID` for continued conversations and then call `RemoteConversation.run()`. If that same conversation is already running a tool for more than 30 s, the join request can time out and fail the automation worker while the agent continues. Recheck an owned continued-conversation automation with a genuine already-running long tool and correlate worker error, agent status and final run result; a fresh conversation or one normal first `run()` does not exercise the join. No model execution is authorized in this pass."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 against automation 1.19.3: the real BashOutput exits 1 with the marker assertion, proving the setup step was skipped ([OpenHands/automation#343](https://github.com/OpenHands/automation/issues/343)). Only an owned marker is written; no external service or model is used."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F23--selected-profile-on-a-prompt-preset",
          "title": "Selected profile on a prompt preset",
          "ids": [
            "F23.configuration",
            "F23.run-linked-conversation"
          ],
          "body": "Arrange a bare saved Agent Profile (`tools: []`, unique persona) referencing an offline LLM profile whose base URL is the closed loopback `http://127.0.0.1:9`; set zero retries and disable title generation. Create an owned active annual-cron prompt preset, PATCH its `agent_profile_id` to the bare profile, then click **Run now** in Canvas. Inspect the linked conversation's `launched_agent_profile`, tool names and persona without revealing secrets. Expected: the chosen profile governs that conversation. Known failure, reproduced 2026-10-12 against automation 1.19.3/Agent Server 1.54.0: provenance is null, the selected persona is absent, and default terminal/file-editor/task-tracker/finish tools replace the bare selection ([OpenHands/automation#557](https://github.com/OpenHands/automation/issues/557)). The deliberate loopback connection failure is incidental to this metadata check; no provider is reachable.",
          "raw": "**Selected profile on a prompt preset (`F23.configuration`, `F23.run-linked-conversation`).** Arrange a bare saved Agent Profile (`tools: []`, unique persona) referencing an offline LLM profile whose base URL is the closed loopback `http://127.0.0.1:9`; set zero retries and disable title generation. Create an owned active annual-cron prompt preset, PATCH its `agent_profile_id` to the bare profile, then click **Run now** in Canvas. Inspect the linked conversation's `launched_agent_profile`, tool names and persona without revealing secrets. Expected: the chosen profile governs that conversation. Known failure, reproduced 2026-10-12 against automation 1.19.3/Agent Server 1.54.0: provenance is null, the selected persona is absent, and default terminal/file-editor/task-tracker/finish tools replace the bare selection ([OpenHands/automation#557](https://github.com/OpenHands/automation/issues/557)). The deliberate loopback connection failure is incidental to this metadata check; no provider is reachable.",
          "source_line": 118,
          "expected": [
            "a cron automation shows Trigger **Schedule**, the Schedule and **LLM profile** (`Active profile` or the profile name).",
            "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>`."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5279](https://github.com/OpenHands/software-agent-sdk/issues/5279) (`F23.run-linked-conversation`): An automation that attaches to an existing remote conversation can complete with no conversation link in its run, because the workspace callback does not register the attached id. Recheck a Cloud automation script that calls `RemoteConversation.attach` on an owned stable conversation and exits through the real workspace completion callback, then inspect the Activity Log link. Blocked without that authenticated Cloud workspace/callback context; a plain local GET is not callback proof.",
            "Known issue [OpenHands/software-agent-sdk#4260](https://github.com/OpenHands/software-agent-sdk/issues/4260) (`F23.run-linked-conversation`, `F23.run-logs-modal`): Cloud PR-review automation can remain at review in progress and time out without posting a review; its event webhook also fails to serialize SecretStr, breaking the UI stream even on successful reviews. Keep the stream-serialization failure distinct from the empty-response/600-second run-budget failure. Recheck with an owned Cloud reviewer/webhook deployment, authorized disposable review target and real model credentials; a local script run proves neither production path.",
            "Known issue [OpenHands/automation#113](https://github.com/OpenHands/automation/issues/113) (`F23.run-linked-conversation`): Scheduled plugin jobs can start conversations visible in the archived list while the automation run UI says no conversation started. Recheck the affected automation/run ids, archived conversation ids and deployment version, using the real callback relationship; these identifiers and version are not provided. This is a reported missing-link state, not proof that no agent ran."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 against automation 1.19.3/Agent Server 1.54.0: provenance is null, the selected persona is absent, and default terminal/file-editor/task-tracker/finish tools replace the bare selection ([OpenHands/automation#557](https://github.com/OpenHands/automation/issues/557)). The deliberate loopback connection failure is incidental to this metadata check; no provider is reachable."
          ],
          "states": [
            "failure",
            "issue",
            "blocked"
          ]
        }
      ]
    },
    {
      "id": "F24",
      "title": "Automation Git Sync",
      "file": "F24-git-sync.md",
      "sha256": "bc1f041fda3305574b2c81587236f5b1adde72f17bc200f977ccb089261315eb",
      "overview": "Git Sync keeps an organization's automation definitions in a git repository.\nFrom the Automate dashboard an admin opens a Git Sync page with a **Sync Status**\ncard (enabled and encryption pills, repository, branch, path, last synced commit\nand time, interval, pending changes, **Sync now** with a live activity row) and a\n**Configuration** form (enable switch, interval, repository URL, branch, path,\ncommit author, access token, encryption key). Saving first checks that the\nrepository is reachable; **Save and sync now** saves and then runs a cycle that\ncommits every changed automation under `<path>/<slug>/` and pushes it.",
      "entry": "- Sidebar **Automate** (`sidebar-automations-link`), then the **Git Sync** button in the dashboard header (`automations-git-sync`). Only users who can manage automations see it (always, locally).\n- Direct URL `/automations/git-sync`.\n- No command-menu entry, keyboard shortcut or Automate sub-navigation tab leads here; the page shows no Dashboard/Templates tabs (see Gotchas).\n- Leave with **Back to Automations** (a link, not a button) at the top of the page.",
      "given": "Preconditions:\n\n- Baseline state (fresh `launch --new`, doctored, `onboard --skip`). No LLM profile is needed: sync cycles run git only.\n- Git Sync is unconfigured: `control-openhands api GET /api/automation/v1/git-sync/status` shows `\"repo_url\": \"\"`, `\"enabled\": false` and `\"last_synced_commit\": null`.\n- A local repository to push to: `control-openhands fixture git-repo --name qa-sync-remote`. Its `path` output is `<repo-path>` below (`$OH_VERIFY_RUN/workspace/qa-sync-remote`). Sync to branch `qa-sync`, not `main`: the fixture is a non-bare repo with `main` checked out, and git refuses pushes to a checked-out branch. Remote state is read with plain `git -C <repo-path> ...` (read-only second view).\n- An inert automation for the pending-changes bullets, created right before **Sync a changed automation**: `control-openhands api POST /api/automation/v1/preset/prompt --write --data '{\"name\":\"QA_F24 Sync\",\"prompt\":\"QA git sync fixture; never runs.\",\"trigger\":{\"type\":\"event\",\"source\":\"qa-f24\",\"on\":\"qa.f24.never\"}}'`. Its `id` is `<automation-id>`; later bullets mark it changed with `control-openhands api PATCH /api/automation/v1/<automation-id> --write --data '{\"enabled\":false}'` (or `true`), which is arrange, not proof. **Sync a deletion** deletes it again.",
      "gotchas": "- Configuring a repository is what turns sync on: the backend's pause switch defaults to on, so the first save of a URL flips the pill to `Enabled` even though the switch was never touched, and clearing the URL turns it off again. The help text under the switch says so (`Sync is on as soon as a repository is configured. ...`). A pause is the exception: it survives clearing and re-entering the URL, so the pill stays `Disabled` until the switch is turned back on.\n- A local path (or `file://` URL) is a valid remote, which is what makes this family drivable without credentials. Use a branch other than the fixture's checked-out `main`: git's default `receive.denyCurrentBranch` refuses pushes to a non-bare repo's checked-out branch (not driven here; every recipe uses `qa-sync`).\n- The reachability check runs only when repository URL, branch or token changed (`git ls-remote`, 20 s timeout). Interval, path, author, encryption key and the enable switch save without it. A check that cannot run at all (old backend, network error) never blocks a save.\n- The second Save after a failed check saves the same values without checking again; editing any checked field re-arms the check.\n- The activity row (`git-sync-activity-row`, `data-state` `running`/`succeeded`/`failed`) is page state: it disappears on reload and does not appear for a background cycle that starts and ends between two idle polls (15 s). Assert background cycles through the commit hash, not the row.\n- Feedback colours are semantic tokens (#18346): the check-failure block, the **Last sync error** banner and a `Sync failed` row use the danger surface and feedback-error text (`rgb(231, 106, 94)` on OpenHands-Neutral, `rgb(192, 40, 45)` on white under Light+), and `Sync complete` uses success (`rgb(165, 231, 94)` Neutral, `rgb(15, 115, 77)` Light+); verified 2026-10-13 on desktop. Read them with `browser eval` of `getComputedStyle(...).color` on the text elements inside `git-sync-check-failure`, `git-sync-error-banner` and `git-sync-activity-row` after switching themes on `/settings/app` (`F16.color-theme`).\n- Toasts (`Git Sync settings saved.`, `Enable Git Sync before triggering a sync.`) last a few seconds: read them with `browser wait-text` immediately after the click, never after a long `--observe` window.\n- Save's label cycles `Save Changes` → `Checking repository...` (only when a checked field changed) → `Saving...` → `Save Changes` in about 100 ms against a local repo; `--observe 'testid=git-sync-save-button'` records it.\n- Setting, rotating or clearing the encryption key re-exports the automation even when its definition is unchanged. Each key-only save sets `dirty_count` to `1`; the next sync creates a commit with ciphertext after set, different ciphertext after rotation and plaintext after clear. Read the dirty count and exported file before any automation PATCH; a separate dirty export tests a different contract.\n- Nothing on the page says whether a token is stored: the token placeholder is always `Leave blank to keep the current token`, and the author fields always reload blank. Prove author changes from the commit (`git log --format='%an <%ae>'`).\n- The card's Repository value drops embedded credentials, as link text and as plain text: an http(s) URL loses its whole `user:token@` (a token alone in the user slot too), other schemes keep the user and lose only the password (`ssh://git@host:2222/...`), and the rest stays as configured. The Repository URL input and `GET /api/automation/v1/git-sync/status` still carry the credentials, so a screenshot of the form shows them. Never type a real token into the URL.\n- The page has no Automate sub-navigation (Dashboard / Templates) and no command-menu entry; leave through **Back to Automations** (`role=link`, not a button).\n- Deleted automations stay in Pending changes until a cycle pushes the removal; after cleanup with sync off the card can read `Pending changes 2`. **Sync a deletion** pushes it while the repository is still configured.\n- An untouched **Enable Git Sync** switch follows the server: after another admin pauses sync, the next idle poll flips both the pill and the switch off, so turning it back on is one label click plus Save.\n- `F24.unsupported` uses `--automation-ref` for the old Git tag; without access to that source it stays blocked. The current CLI also supports `--automation-version V` for a PyPI release (verified with 1.19.3 on 2026-10-10). Backend overrides come from these explicit launch flags; shell `OH_AUTOMATION_VERSION` is not forwarded.\n- Arrange with `api ... --write` only to mark automations changed or to simulate another admin; the save, check and sync steps must go through the form.\n- Known issue [OpenHands/automation#586](https://github.com/OpenHands/automation/issues/586) (`F24.sync-now`): a YAML-only repository edit can replace an unchanged uploaded script bundle because canonical gzip bytes retain their creation second. Recheck a disposable Git Sync repository and script automation, change only YAML, arrange the canonical rebuilds across different seconds, and compare the real saved upload reference before and after sync. Equal decompressed contents or a serializer-only probe do not prove that Canvas reuses its upload.",
      "features": [
        {
          "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**."
        }
      ],
      "scenarios": [
        {
          "name": "F24--open-from-the-dashboard",
          "title": "Open from the dashboard",
          "ids": [
            "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`.",
          "raw": "**Open from the dashboard (`F24.open`, `F24.loading`).** 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`.",
          "source_line": 59,
          "expected": [
            "the **Git Sync** button on the Automate dashboard and the direct URL open the page (h1 `Git Sync`); **Back to Automations** returns to `/automations`.",
            "a skeleton (`git-sync-skeleton`) shows while health, permissions and status load."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--unconfigured-card",
          "title": "Unconfigured card",
          "ids": [
            "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`.",
          "raw": "**Unconfigured card (`F24.overview-unconfigured`).** 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`.",
          "source_line": 60,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--dirty-tracking",
          "title": "Dirty tracking",
          "ids": [
            "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`.",
          "raw": "**Dirty tracking (`F24.form-dirty`).** `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`.",
          "source_line": 61,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--unreachable-repository",
          "title": "Unreachable repository",
          "ids": [
            "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`).",
          "raw": "**Unreachable repository (`F24.check-failure`).** 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`).",
          "source_line": 62,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--failing-cycle",
          "title": "Failing cycle",
          "ids": [
            "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).",
          "raw": "**Failing cycle (`F24.sync-failure`).** 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).",
          "source_line": 63,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--configure-and-save-and-sync",
          "title": "Configure and Save and sync",
          "ids": [
            "F24.save-and-sync"
          ],
          "body": "**Save and sync now** stays disabled until **Enable Git Sync** is on (`F24.check-failure` turns it on; on a fresh stack turn it on first). 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).",
          "raw": "**Configure and Save and sync (`F24.save-and-sync`).** **Save and sync now** stays disabled until **Enable Git Sync** is on (`F24.check-failure` turns it on; on a fresh stack turn it on first). 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).",
          "source_line": 64,
          "expected": [
            "**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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--sync-a-changed-automation",
          "title": "Sync a changed automation",
          "ids": [
            "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/...`.",
          "raw": "**Sync a changed automation (`F24.sync-now`).** 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/...`.",
          "source_line": 65,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/automation#586](https://github.com/OpenHands/automation/issues/586) (`F24.sync-now`): a YAML-only repository edit can replace an unchanged uploaded script bundle because canonical gzip bytes retain their creation second. Recheck a disposable Git Sync repository and script automation, change only YAML, arrange the canonical rebuilds across different seconds, and compare the real saved upload reference before and after sync. Equal decompressed contents or a serializer-only probe do not prove that Canvas reuses its upload."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F24--commit-author",
          "title": "Commit author",
          "ids": [
            "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>`.",
          "raw": "**Commit author (`F24.author`).** 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>`.",
          "source_line": 66,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--encryption-key",
          "title": "Encryption key",
          "ids": [
            "F24.encryption"
          ],
          "body": "Start with plaintext exported by the preceding sync bullets. Run `control-openhands browser attr 'testid=git-sync-encryption-key-input' placeholder` (`No encryption key set`) and record `git -C <repo-path> rev-parse qa-sync`. Fill the field with `qa-f24-dummy-key`, click `testid=git-sync-save-button`, immediately `control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000`, then `control-openhands browser reload`. The pill is `Encrypted`, the placeholder is `An encryption key is currently set`, and the password value is empty. `control-openhands api GET /api/automation/v1/git-sync/status` shows `dirty_count` `1`. Without changing the automation, click `testid=git-sync-now-button`, wait with `control-openhands browser wait '[data-testid=git-sync-activity-row]:not([data-state=running])' --timeout 30000`, then read the head and `git -C <repo-path> show qa-sync:qa-automations/qa-f24-sync/automation.yaml | head -c 20`: the head is new and the file begins `gAAAAA`. Separately verify dirty export: PATCH the fixture enabled state, reload and sync as above; the file remains ciphertext. This PATCH does not prove the key-only contract. Record that encrypted head and ciphertext, fill the key with `qa-f24-different-dummy-key`, Save, wait for the toast and reload. The status has `dirty_count` `1`; Sync now without an automation PATCH produces another head and different ciphertext. Record the encrypted head, fill the key with any dummy text and click `testid=git-sync-clear-encryption-key-switch >> xpath=ancestor::label`: the field becomes disabled and empty. Save, wait for the toast and reload: `Not encrypted`, placeholder `No encryption key set`, and the clear switch is unchecked. Status again has `dirty_count` `1`; Sync now without an automation change produces a new head and plaintext. Repeat key-only set, rotate and clear at `control-openhands browser viewport phone`, starting from the plaintext left by desktop clear, and record both viewport entries with selected backend `Local`. Return with `control-openhands browser viewport desktop`.",
          "raw": "**Encryption key (`F24.encryption`).** Start with plaintext exported by the preceding sync bullets. Run `control-openhands browser attr 'testid=git-sync-encryption-key-input' placeholder` (`No encryption key set`) and record `git -C <repo-path> rev-parse qa-sync`. Fill the field with `qa-f24-dummy-key`, click `testid=git-sync-save-button`, immediately `control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000`, then `control-openhands browser reload`. The pill is `Encrypted`, the placeholder is `An encryption key is currently set`, and the password value is empty. `control-openhands api GET /api/automation/v1/git-sync/status` shows `dirty_count` `1`. Without changing the automation, click `testid=git-sync-now-button`, wait with `control-openhands browser wait '[data-testid=git-sync-activity-row]:not([data-state=running])' --timeout 30000`, then read the head and `git -C <repo-path> show qa-sync:qa-automations/qa-f24-sync/automation.yaml | head -c 20`: the head is new and the file begins `gAAAAA`. Separately verify dirty export: PATCH the fixture enabled state, reload and sync as above; the file remains ciphertext. This PATCH does not prove the key-only contract. Record that encrypted head and ciphertext, fill the key with `qa-f24-different-dummy-key`, Save, wait for the toast and reload. The status has `dirty_count` `1`; Sync now without an automation PATCH produces another head and different ciphertext. Record the encrypted head, fill the key with any dummy text and click `testid=git-sync-clear-encryption-key-switch >> xpath=ancestor::label`: the field becomes disabled and empty. Save, wait for the toast and reload: `Not encrypted`, placeholder `No encryption key set`, and the clear switch is unchecked. Status again has `dirty_count` `1`; Sync now without an automation change produces a new head and plaintext. Repeat key-only set, rotate and clear at `control-openhands browser viewport phone`, starting from the plaintext left by desktop clear, and record both viewport entries with selected backend `Local`. Return with `control-openhands browser viewport desktop`.",
          "source_line": 67,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--access-token",
          "title": "Access token",
          "ids": [
            "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`.",
          "raw": "**Access token (`F24.token`).** `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`.",
          "source_line": 68,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--interval",
          "title": "Interval",
          "ids": [
            "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.",
          "raw": "**Interval (`F24.interval`).** 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.",
          "source_line": 69,
          "expected": [
            "**Sync every (seconds)** saves `N` as `Every Ns`, blank or `0` as `Manual only`, and refuses negative numbers."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--pause-and-resume",
          "title": "Pause and resume",
          "ids": [
            "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`.",
          "raw": "**Pause and resume (`F24.pause`).** 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`.",
          "source_line": 70,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--trigger-while-disabled-elsewhere",
          "title": "Trigger while disabled elsewhere",
          "ids": [
            "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).",
          "raw": "**Trigger while disabled elsewhere (`F24.sync-disabled-error`).** 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).",
          "source_line": 71,
          "expected": [
            "**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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--repository-link",
          "title": "Repository link",
          "ids": [
            "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).",
          "raw": "**Repository link (`F24.repo-link`).** 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).",
          "source_line": 72,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--interval-cycle",
          "title": "Interval cycle",
          "ids": [
            "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.",
          "raw": "**Interval cycle (`F24.background-sync`).** 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.",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--sync-a-deletion",
          "title": "Sync a deletion",
          "ids": [
            "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.",
          "raw": "**Sync a deletion (`F24.sync-delete`).** 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.",
          "source_line": 74,
          "expected": [
            "deleting a synced automation raises Pending changes; the next cycle commits the removal of its `<path>/<slug>/` folder."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--empty-branch-and-path",
          "title": "Empty branch and path",
          "ids": [
            "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.",
          "raw": "**Empty branch and path (`F24.field-defaults`).** 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.",
          "source_line": 75,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--ssh-remote-link",
          "title": "ssh remote link",
          "ids": [
            "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`.",
          "raw": "**ssh remote link (`F24.repo-link-ssh`).** 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`.",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--clear-the-repository",
          "title": "Clear the repository",
          "ids": [
            "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`.",
          "raw": "**Clear the repository (`F24.clear-repo`).** 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`.",
          "source_line": 77,
          "expected": [
            "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`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--phone-layout",
          "title": "Phone layout",
          "ids": [
            "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`.",
          "raw": "**Phone layout (`F24.phone`).** 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`.",
          "source_line": 78,
          "expected": [
            "the page fits a 390 px viewport without horizontal overflow; the card's fields stay in two columns."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--automation-service-down",
          "title": "Automation service down",
          "ids": [
            "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`.",
          "raw": "**Automation service down (`F24.backend-down`).** 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`.",
          "source_line": 79,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--old-backend",
          "title": "Old backend",
          "ids": [
            "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.",
          "raw": "**Old backend (`F24.unsupported`).** 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.",
          "source_line": 80,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--no-permission",
          "title": "No permission",
          "ids": [
            "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`.",
          "raw": "**No permission (`F24.no-access`).** 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`.",
          "source_line": 81,
          "expected": [
            "a user without `manage_automations` (Cloud member) sees `Git Sync is managed by organization admins`, and the dashboard hides the **Git Sync** button."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F24--repository-taken-by-another-org",
          "title": "Repository taken by another org",
          "ids": [
            "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.",
          "raw": "**Repository taken by another org (`F24.conflict`).** 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.",
          "source_line": 82,
          "expected": [
            "saving a repository, branch and path that another organization already syncs is refused (409) with a toast naming the conflict."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F24--status-error",
          "title": "Status error",
          "ids": [
            "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**.",
          "raw": "**Status error (`F24.error-state`).** 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**.",
          "source_line": 83,
          "expected": [
            "any other status failure shows the generic automation error panel with **Retry**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F24--clean-up",
          "title": "Clean up",
          "ids": [],
          "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.",
          "raw": "**Clean up.** 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.",
          "source_line": 84,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        }
      ]
    },
    {
      "id": "F25",
      "title": "Backends, Cloud and sharing",
      "file": "F25-backends-and-cloud.md",
      "sha256": "00c00fb4eb0ad58f7966cc0b618d7242a39f3946d7975bbbd174a5fb4a0ca014",
      "overview": "One Canvas can drive several agent servers. The backend selector at the bottom\nof the sidebar lists every registered backend with a live status dot and\nswitches between them; its footer opens **Add Backend** (OpenHands Cloud login\nthrough an OAuth device flow, or a local/remote agent server by host and API\nkey) and **Manage Backends** (select, edit, remove, and Cloud \"Log back in\").\nWhen the active backend is unreachable the whole app is replaced by Manage\nbackends in recovery mode. Cloud backends add organization rows, a Cloud\nsettings link and public sharing of conversations (read-only at\n`/shared/conversations/:id`); `/oauth/device/verify` is the device-approval page.\nWhen the page is served with the DigitalOcean Managed Agents bridge (the\ndesktop app's preload, or the web server's own bridge, which the launcher's\ningress mounts by default behind the session key), Add Backend gains a\n**Providers** tab whose DigitalOcean entry signs in with a personal access\ntoken and turns a Managed Agents session into a backend.\nEverything except the Cloud and DigitalOcean accounts themselves can be driven\nlocally with a second `control-openhands` stack as the extra backend.",
      "entry": "- Sidebar, bottom: the backend selector (`backend-selector`; click or hover) → options, **Add Backend** (`add-backend-menu-item`), **Manage Backends** (`manage-backends-menu-item`).\n- Collapsed rail: hover the backend icon (`collapsed-backend-selector-link`) → the same footer items.\n- Phone: hamburger (`sidebar-mobile-menu-toggle`) → drawer → backend selector.\n- Manage backends → **Add Backend** (`manage-backends-add`) opens the same Add form.\n- Direct URLs: any in-app URL with `?backend=<id>`; `/oauth/device/verify[?user_code=CODE]`; `/shared/conversations/<id>`.\n- Conversation header name → \"...\" (`conversation-name >> ellipsis-button`) for Public Share (Cloud only).\n- Reloading with an unreachable active backend opens the recovery gate.\n- Onboarding's backend step and the public-mode API-key screen also add backends; they belong to F01. The ChatGPT subscription card is F10 (`F10.subscription`) and the provider balance card is F27 (`F27.usage-provider-balance`).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` on this run.\n- One conversation on this run: `control-openhands conversation start --prompt \"Reply with exactly: QA_F25_PONG\" --wait --timeout 180`; note its `<id>`. The conversation only has to exist: F25 never reads the reply, so a run that ends in `error` (for example a key without balance, saved with `llm preset deepseek --no-validate`) is enough.\n- A second stack to add as a backend, started without a browser and never driven directly: `export OH_VERIFY_RUN_2=$(OH_VERIFY_RUN= control-openhands launch --new --build never --no-browser --print-run)`, then `control-openhands status --run \"$OH_VERIFY_RUN_2\"`; its `baseUrl` is `<second-url>` below and its key is `$OH_VERIFY_RUN_2/private/session-key`. It has no conversations.\n- No backend named `QA_Second` or `QA_Renamed` exists (the selector lists only `Local`).\n- Bullets run in order: Add creates `QA_Second`, Edit renames it to `QA_Renamed`, Remove deletes it.\n- The Managed Agents rows need the bridge, which every `control-openhands launch` run has: `control-openhands browser eval \"!!window.marsBridge\"` is `true` and `curl -s <baseUrl>/mars/health` answers `{\"ok\":true,\"authRequired\":true}`. `F25.digitalocean-agents` and `F25.digitalocean-backend-removal` stay blocked without a DigitalOcean account.\n- Cloud account: where this machine's proxy injects OpenHands Cloud credentials (`curl -s -o /dev/null -w '%{http_code}' https://app.all-hands.dev/api/organizations` is `200` without a token), the **Cloud backend** bullet below can approve the device flow in its popup; otherwise it is blocked. Blocked here even with such an account: `F25.cloud-log-back-in` (needs a logged-out Cloud row), org switching in `F25.cloud-org-rows` (needs a second organization), the archived and error states of `F25.cloud-sandbox-states`, `F25.cloud-org-suspended` (also needs a suspended OpenHands Enterprise organization or membership), `F25.share-publicly` (toggle), and `F25.locked-cloud`, which also needs a launcher flag (see Gotchas).",
      "gotchas": "- Export the second run as `OH_VERIFY_RUN_2` and pass it with `--run`; never export it as `OH_VERIFY_RUN`, or the browser commands look for a browser that run does not have. `OH_VERIFY_RUN= control-openhands launch --new ...` keeps the launch from reusing this run.\n- The second backend asks its own telemetry consent the first time it becomes active, over whatever page you are on; `onboard --skip` answers it. Consent is stored per backend, so a later `restart` of the second run does not ask again.\n- `browser fill <sel> --value-file FILE` takes no positional value; it keeps the key out of the shell history and the evidence. Edit forms show the stored key masked, but `browser snapshot` of the Edit form prints it in clear: do not save that snapshot as evidence with a real key.\n- Manage-row test ids use the backend name (`manage-backends-row-QA_Second`), so they change after a rename and are not unique when two backends share a name; the Cloud **Log back in** id uses the backend id instead (`manage-backends-login-<id>-login-button`).\n- Selector options are named `<status> <name>` (`Connected Local`, `Disconnected QA_Renamed`): select them with `role=option[name=\"Connected <name>\"]`.\n- After a navigating option click, `--expect-url` must allow `?backend=...`: end regexes with `(\\?|$)`.\n- `browser wait-tab <regex>` matches any open page, including the current one, unless `--new` leaves out the tab the daemon is on: for a second tab at the same URL (`/conversations` from **New Chat**) use `wait-tab '/conversations$' --new`, or read `browser tabs` and switch with `browser tab <i>`. `browser tab new /conversations` opens a plain second tab (no opener, empty sessionStorage), the state a user reaches by opening the app again. Close extra tabs highest index first, or the indexes shift under you.\n- `browser tab new` switches the daemon to the tab it opened, so a `wait-tab <regex> --new` run right after it reports the tab you came from (`index` `0` with two tabs); `browser tab 0` first when you want the new tab's index from `wait-tab`. A `tab new` and a `click --modifiers Control` on **New Chat** reach the same state (selector from the localStorage fallback, sessionStorage item `null`).\n- A tab opened with `click --modifiers Control` starts with an empty sessionStorage, so it boots from the localStorage fallback (the backend last chosen in any tab) unless its URL carries `?backend=`. Opening a `?backend=` link writes that backend to localStorage too: a per-tab check that relies on \"last choice\" must run before opening pinned links.\n- The Add Backend Agent-server form has no inline \"required\" messages; it only disables **Connect**. The messages exist in the Edit form.\n- Footer clicks from the collapsed popover or the phone drawer open the modals a moment later: `browser wait` for them instead of `count`.\n- The device-flow popup survives errors: every failed attempt leaves an `about:blank` tab ([#17953](https://github.com/OpenHands/OpenHands/issues/17953)). Cancel closes it; after an error close it with `browser close-tab`.\n- With the default host the device flow talks to the real `app.all-hands.dev` (`POST /oauth/device/authorize`, then token polling). Starting and cancelling is harmless; approve a code only in the **Cloud backend** bullet, with an account you may use.\n- While the second stack's agent server is stopped or restarting, its ingress answers without CORS headers, so the browser logs `Access to fetch at '<second-url>/api/settings' ... blocked by CORS policy` (also `/server_info`, `/api/conversations/search`, `/api/llm/models/verified`). That is the dead upstream, not a CORS bug; a healthy second stack sends `access-control-allow-origin` for this origin.\n- Expected: Escape never clears the backend selector. Known failure (reproduced 2026-10-09, 2026-10-12, 2026-10-13 and 2026-10-14, [#18233](https://github.com/OpenHands/OpenHands/issues/18233)): Escape on the focused backend selector while its menu is closed (a second Escape after the one that closes the menu, and sometimes the Escape that closes Manage Backends opened from it) clears the selection: the closed selector shows the active backend's name as an italic placeholder (`browser value 'testid=backend-selector >> role=combobox'` is `\"\"`) and its status dot is gone, so `browser wait 'testid=backend-selector >> role=status[name=\"Disconnected\"]'` times out although the backend is down; a reload restores it. Reload before a closed-selector status check, as the Edit bullet does. Repro: `browser click 'testid=backend-selector'`, `browser press Escape` twice ([#18233](https://github.com/OpenHands/OpenHands/issues/18233)). On 2026-10-12 `bug proof` verified fix PR #18238 at 77de0ad24 against main f07aebdf5 (two `xfail`, two `xpass`, desktop). `control-openhands bug run F25.selector-dropdown` replays it (desktop, after a reload with the mouse off the selector: a reload under a mouse left on it opens its menu and empties its field, which would look like the bug) and reports `xfail` while the bug lasts:\n\n  ```sh known-bug F25.selector-dropdown escape-closed\n  control-openhands browser viewport desktop\n  control-openhands browser goto /\n  control-openhands browser hover 'testid=root-layout'\n  control-openhands browser reload\n  control-openhands browser wait 'testid=backend-selector >> role=status[name=\"Connected\"]' --timeout 15000\n  control-openhands assert --field value --equals Local -- browser value 'testid=backend-selector >> role=combobox'\n  control-openhands browser click 'testid=backend-selector'\n  control-openhands browser press Escape\n  control-openhands browser press Escape\n  control-openhands browser screenshot 'testid=backend-selector' --feature F25.selector-dropdown --name escape-closed\n  control-openhands assert --field value --equals Local --bug-shows '' -- browser value 'testid=backend-selector >> role=combobox'  # bug\n  ```\n- Local and remote backends are probed every 30 s (settings + server_info), Cloud backends every 5 min (#18128), so allow up to 40 s for a local dot to change. `browser network --clear`, wait about 70 s, then `browser network --filter server_info` shows two probes per local origin 30 s apart. While the active backend is down, any reload lands on the recovery gate: drive the disconnected rows on the page that was already loaded.\n- `/oauth/device/verify` is a Cloud page that also ships in Canvas: on a local stack its POST target does not exist, so Authorize always ends in `Error`. The \"Authentication Required\" state is unreachable (`useIsAuthed` always resolves `true`).\n- `/shared/conversations/<id>` on a local backend also raises a raw toast `HTTP request failed (404 Not Found): {\"detail\":\"Not Found\"}` next to `Conversation not found` ([#17953](https://github.com/OpenHands/OpenHands/issues/17953)).\n- Known failure (repro candidate, not an exemption): `F25.edit-escape` closes the Manage modal underneath and drops the Edit draft (Cancel and X close only Edit, `F25.edit-cancel`) ([#17953](https://github.com/OpenHands/OpenHands/issues/17953)).\n- The add bullet's `network --external --filter 'ids='` check guards an earlier race: [#18390](https://github.com/OpenHands/OpenHands/issues/18390) tracks the same first-activation symptom; on a backend's first activation from `/conversations/<id>`, about 1 attempt in 5 asked the new backend for the old conversation id. It did not happen in 15 first activations at 4eff80237: each time `QA_Second` was removed, its consent unset with `control-openhands api PATCH /api/settings --run \"$OH_VERIFY_RUN_2\" --write --data '{\"misc_settings_diff\":{\"app_preferences\":{\"user_consents_to_analytics\":null}}}'` (arrange only) and the backend added again from `/conversations/<id>`. A backend that lacks an id answers `[null]`, which now reads as \"not found\" (only the `This conversation does not exist…` toast), not as incompatible data. No merged change was shown to fix the race itself (#18009 only changed how a `[null]` answer reads; #18024 only moved the redirect into a shared hook, still called after `addBackend`), so a hit (an `ids=` request to `<second-url>`) is a bug to file. At 95115e84f6dd it hit on 1 of 2 first activations from `/conversations/<id>` (one `GET <second-url>/api/conversations?ids=<id>` answered `200`, with no `This conversation does not exist…` toast after it). The hit followed a failed Connect in the same modal (an empty key, `Invalid API key`) before the successful one; the clean retry, which connected once, had none, so a repro should try both sequences. On 2026-10-08 at d2c89252d it did not reproduce in 17 first activations (8 clean, 9 failed-then-clean, some under CPU load and some human-paced): every `ids=` capture was empty, and no merged change touched the activation logic since the hit, so it stays a watch item rather than a filed defect. Record the Add bullet as `fail` when the request appears, even though the redirect and the selector look right. Known failure (reproduced 2026-10-13, #18390): two independent first activations hit it that day, each the run's first activation of `QA_Second` after a clean Connect. On 2026-10-13 at 2bdc0e9d6 it hit on 1 of 6 first activations from `/conversations/<id>`: the first one of the run (`GET <second-url>/api/conversations?ids=<id>` `200`, no toast), a clean Connect in a modal opened after earlier failed Connects in other Add Backend openings; the next 5 (2 failed-then-clean, 3 clean, plus one Manage → Add) had none.\n- Add Backend and Manage Backends take and keep keyboard focus, and Edit backend takes it on open (`ModalBackdrop`; at phone width open the selector with `'testid=sidebar-mobile-drawer >> testid=backend-selector'`, since the bare test id matches two selectors). At desktop width focus returns to the selector input when they close (at phone width Add Backend's X closes the drawer too and focus falls to the page body), so an Escape pressed after closing lands on the selector and can trigger [#18233](https://github.com/OpenHands/OpenHands/issues/18233) below.\n- Toasts are not page or HTTP errors, so `browser errors --app-only` misses them: read `browser toasts --history` after every add or switch, before anything reloads the page (the history starts with each page load).\n- `F25.locked-cloud` needs `scripts/static-server.mjs --lock-to-cloud <url>`, which neither `bin/agent-canvas.mjs` nor `control-openhands launch` can pass today; even with it, reconnecting needs a Cloud account.\n- The Cloud tab title truncates to `OpenHan…` at 390 px when the Agent-server tab is selected; the layout still fits. With the Providers tab present all three titles are cut to three letters.\n- A token DigitalOcean rejects surfaces as the bare `401 Unauthorized`, with no hint that the token is wrong or lacks scopes.\n- Known issue [#18029](https://github.com/OpenHands/OpenHands/issues/18029) (`F25.locked-cloud`): A returning user in a locked Cloud deployment can reach the no-backend placeholder and fail to start conversations despite a healthy configured backend. The report proposes a stale document or alternate origin but does not capture which occurred. Recheck against the affected locked deployment with a Cloud session, its exact origin, cache response headers and injected host state; the normal local launcher does not reproduce this deployment condition.\n- Known issue [#17390](https://github.com/OpenHands/OpenHands/issues/17390) (`F25.cloud-sandbox-states`): Every sent Cloud message can fail with `token_not_found_in_db` from the model proxy. Recheck with the affected Cloud account/session and a minimal no-tools prompt; capture the error type with provider token text and hashes redacted. The report supplies no deployment version or deterministic token-provisioning sequence, and this run has no Cloud account.\n- Known issue [#17163](https://github.com/OpenHands/OpenHands/issues/17163) (`F25.cloud-sandbox-states`): A Free Cloud account can lose access to previously usable models with HTTP 403 and a team-restriction message despite no team being created. Recheck with the affected account and its model entitlement/org history; local credentials cannot prove Cloud eligibility and this run has no such account.\n- Known issue [#16874](https://github.com/OpenHands/OpenHands/issues/16874) (`F25.cloud-sandbox-states`): A failed resume of a paused Cloud sandbox was reported to consume a one-shot guard and leave chat disconnected until remount. Current source uses a retrying resume hook, so recheck the original failure and a second pause in one route mount before concluding resolution. Blocked without a real paused Cloud sandbox and a controlled one-request backend/network failure; source changes alone are not a live pass.\n- Known issue [#15676](https://github.com/OpenHands/OpenHands/issues/15676) (`F25.cloud-sandbox-states`): Refreshing an approximately 24-hour-old Cloud conversation on Vercel can fail to resume/reconnect. Recheck an authenticated affected Cloud deployment with an aged sandbox/session and exact console/network error; the report omits its commit and failure text, and no aged Cloud fixture is available here.\n- Known issue [#15822](https://github.com/OpenHands/OpenHands/issues/15822) (`F25.switch-backend` `F25.health-status` `F25.cloud-org-rows`): Cloud-proxy traffic was reported to continue after switching from Cloud to Local. Current source probes registered inactive Cloud backends on a five-minute cadence; recheck the reported repeated OPTIONS behavior with a real registered Cloud account/org and capture only credential-redacted request metadata after switching. This run has no authenticated Cloud backend.\n- Known issue [OpenHands/automation#565](https://github.com/OpenHands/automation/issues/565) (`F25.cloud-org-rows`, `F25.cloud-org-suspended`): An org-scoped Cloud automation request with an unauthorized `X-Org-Id` can turn the identity service's 403 into a misleading 502/outage page. Recheck with an authenticated Cloud org-bound key and a disposable unauthorized organization id, comparing automation list and the identity response while preserving credential redaction. Local mode bypasses this identity service and cannot reproduce it.\n- Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch.",
      "features": [
        {
          "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 the tabs **OpenHands Cloud** (selected) and **Agent-server** (plus **Providers** with the bridge, see the Providers row). It takes keyboard focus on open (the close button), Tab and Shift+Tab wrap inside it, and at desktop width closing returns focus to the backend selector (at phone width the drawer closes with the modal and focus falls back to the page body). 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; it takes keyboard focus on open and keeps Tab inside; Escape, a backdrop click, X and **Done** close it and return focus to the selector."
        },
        {
          "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 Add and Manage inside the viewport without horizontal overflow. Edit backend fits at 320 and 390 px; on short viewports its form scrolls while the title and close control stay visible, and Cancel/Save remain reachable."
        },
        {
          "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.providers-tab",
          "description": "only when the page has the Managed Agents bridge (`window.marsBridge`), Add Backend shows a third tab **Providers** (\"Connect through DigitalOcean and more.\") with a **DigitalOcean** card (\"Your team's OpenHands agents\") and `More providers coming soon.`. Every launcher-served web page has it (the ingress answers `/mars/health`); without it (`MARS_WEB=0`, or a non-loopback bind with no session key at all) there are only the Cloud and Agent-server tabs and no Managed Agents UI anywhere. A non-loopback bind that has a key (F26 `--host 0.0.0.0` with `LOCAL_BACKEND_API_KEY`) mounts the bridge behind that key and offers the tab too, although the page itself carries no key (see the sign-in bullet's Known failure)."
        },
        {
          "id": "F25.digitalocean-sign-in",
          "description": "the DigitalOcean card opens \"Connect your DigitalOcean team\" with a Back button, a masked **Personal access token** field, a **Create a token** link to `https://cloud.digitalocean.com/account/api/tokens` and **Connect with token**, disabled until a token is typed (the **Sign in with DigitalOcean** OAuth button exists only in the desktop app). A token of the wrong shape reads `That does not look like a DigitalOcean personal access token.`; a well-formed token DigitalOcean rejects shows its HTTP error (`401 Unauthorized`). Back returns to the Providers tab."
        },
        {
          "id": "F25.digitalocean-agents",
          "description": "signed in, the panel lists the team's OpenHands agents and their sessions, **New Agent** (name and sandbox size), refresh and sign-out; connecting a session (or **Resume and connect** a paused one) adds it as the active backend, the sidebar's conversation list becomes that agent's session panel (`mars-session-panel`), and Manage Backends' Managed Agents view offers Pause, Stop, Disconnect and Open per session (blocked: needs a DigitalOcean account with Managed Agents)."
        },
        {
          "id": "F25.digitalocean-backend-removal",
          "description": "removing a DigitalOcean backend in Manage Backends explains that it only disconnects and offers **Disconnect only** or **Stop session** (blocked: needs a connected DigitalOcean session)."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F25--open-the-selector",
          "title": "Open the selector",
          "ids": [
            "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.",
          "raw": "**Open the selector (`F25.selector-dropdown`).** 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.",
          "source_line": 81,
          "expected": [
            "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**."
          ],
          "notes": [
            "Expected: Escape never clears the backend selector. Known failure (reproduced 2026-10-09, 2026-10-12, 2026-10-13 and 2026-10-14, [#18233](https://github.com/OpenHands/OpenHands/issues/18233)): Escape on the focused backend selector while its menu is closed (a second Escape after the one that closes the menu, and sometimes the Escape that closes Manage Backends opened from it) clears the selection: the closed selector shows the active backend's name as an italic placeholder (`browser value 'testid=backend-selector >> role=combobox'` is `\"\"`) and its status dot is gone, so `browser wait 'testid=backend-selector >> role=status[name=\"Disconnected\"]'` times out although the backend is down; a reload restores it. Reload before a closed-selector status check, as the Edit bullet does. Repro: `browser click 'testid=backend-selector'`, `browser press Escape` twice ([#18233](https://github.com/OpenHands/OpenHands/issues/18233)). On 2026-10-12 `bug proof` verified fix PR #18238 at 77de0ad24 against main f07aebdf5 (two `xfail`, two `xpass`, desktop). `control-openhands bug run F25.selector-dropdown` replays it (desktop, after a reload with the mouse off the selector: a reload under a mouse left on it opens its menu and empties its field, which would look like the bug) and reports `xfail` while the bug lasts:\n\n  ```sh known-bug F25.selector-dropdown escape-closed\n  control-openhands browser viewport desktop\n  control-openhands browser goto /\n  control-openhands browser hover 'testid=root-layout'\n  control-openhands browser reload\n  control-openhands browser wait 'testid=backend-selector >> role=status[name=\"Connected\"]' --timeout 15000\n  control-openhands assert --field value --equals Local -- browser value 'testid=backend-selector >> role=combobox'\n  control-openhands browser click 'testid=backend-selector'\n  control-openhands browser press Escape\n  control-openhands browser press Escape\n  control-openhands browser screenshot 'testid=backend-selector' --feature F25.selector-dropdown --name escape-closed\n  control-openhands assert --field value --equals Local --bug-shows '' -- browser value 'testid=backend-selector >> role=combobox'  # bug\n  ```"
          ],
          "annotations": [],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--add-backend-modal",
          "title": "Add Backend modal",
          "ids": [
            "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 ...`. Keyboard focus: `control-openhands browser eval \"(()=>{const a=document.activeElement;const d=document.querySelector('[data-testid=add-backend-modal]');return {tid:a.getAttribute('data-testid'),inDialog:!!(d&&d.contains(a))}})()\"` is `{tid: \"add-backend-close\", inDialog: true}` right after opening; after eight `control-openhands browser press Tab` and three `browser press Shift+Tab` (reading the same eval after each) `inDialog` stays `true` (Tab past **Advanced** wraps to the close button), and `browser press q` then `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').textContent\"` is `\"\"` (nothing reaches the Home composer behind). `browser screenshot --feature F25.add-backend-modal --name focus-trap-open`. `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`, and `control-openhands browser eval \"!!document.activeElement.closest('[data-testid=backend-selector]')\"` is `true` (focus is back on the selector). Keyboard focus in a light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, reopen the modal, run `control-openhands browser focus 'testid=add-backend-option-cloud'` and `browser press ArrowRight`, then `control-openhands browser eval \"getComputedStyle(document.activeElement).outlineColor\"`. Expected: the theme's focus colour (`--oh-focus`, `#007ACC` in Light+). Connection tabs, the guidance toggle and the DigitalOcean provider choice use the theme focus role and preserve host `--oh-focus` overrides; default focus contrast is at least 3:1 against base, modal, raised and hovered neutral surfaces in all five themes. Solarized Light uses `#227FBE`. Check the settled outline after its transition, preserving the 2 px width and existing offsets. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"`.",
          "raw": "**Add Backend modal (`F25.add-backend-modal`).** 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 ...`. Keyboard focus: `control-openhands browser eval \"(()=>{const a=document.activeElement;const d=document.querySelector('[data-testid=add-backend-modal]');return {tid:a.getAttribute('data-testid'),inDialog:!!(d&&d.contains(a))}})()\"` is `{tid: \"add-backend-close\", inDialog: true}` right after opening; after eight `control-openhands browser press Tab` and three `browser press Shift+Tab` (reading the same eval after each) `inDialog` stays `true` (Tab past **Advanced** wraps to the close button), and `browser press q` then `control-openhands browser eval \"document.querySelector('[data-testid=chat-input]').textContent\"` is `\"\"` (nothing reaches the Home composer behind). `browser screenshot --feature F25.add-backend-modal --name focus-trap-open`. `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`, and `control-openhands browser eval \"!!document.activeElement.closest('[data-testid=backend-selector]')\"` is `true` (focus is back on the selector). Keyboard focus in a light theme: arrange `control-openhands browser eval \"localStorage.setItem('openhands-color-theme','light-plus')\"` and `browser reload`, reopen the modal, run `control-openhands browser focus 'testid=add-backend-option-cloud'` and `browser press ArrowRight`, then `control-openhands browser eval \"getComputedStyle(document.activeElement).outlineColor\"`. Expected: the theme's focus colour (`--oh-focus`, `#007ACC` in Light+). Connection tabs, the guidance toggle and the DigitalOcean provider choice use the theme focus role and preserve host `--oh-focus` overrides; default focus contrast is at least 3:1 against base, modal, raised and hovered neutral surfaces in all five themes. Solarized Light uses `#227FBE`. Check the settled outline after its transition, preserving the 2 px width and existing offsets. Restore with `browser eval \"localStorage.removeItem('openhands-color-theme')\"`.",
          "source_line": 82,
          "expected": [
            "**Add Backend** opens \"Choose how you want to connect\" with a description, a **Learn more** link to the deployment docs and the tabs **OpenHands Cloud** (selected) and **Agent-server** (plus **Providers** with the bridge, see the Providers row). It takes keyboard focus on open (the close button), Tab and Shift+Tab wrap inside it, and at desktop width closing returns focus to the backend selector (at phone width the drawer closes with the modal and focus falls back to the page body). Escape does not close it; the X does."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--advanced-host",
          "title": "Advanced host",
          "ids": [
            "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`).",
          "raw": "**Advanced host (`F25.cloud-advanced-host`).** 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`).",
          "source_line": 83,
          "expected": [
            "on the Cloud tab, **Advanced** reveals a **Host** field (placeholder `https://app.all-hands.dev`) for a self-hosted Cloud."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--device-flow-error",
          "title": "Device flow error",
          "ids": [
            "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`.",
          "raw": "**Device flow error (`F25.cloud-device-flow`).** 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`.",
          "source_line": 84,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--device-flow-awaiting-and-cancel",
          "title": "Device flow awaiting and Cancel",
          "ids": [
            "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. Do not approve the code here (the **Cloud backend** bullet does, when it can). If Cloud is unreachable you get the error state of the previous bullet instead.",
          "raw": "**Device flow awaiting and Cancel (`F25.cloud-device-flow`).** 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. Do not approve the code here (the **Cloud backend** bullet does, when it can). If Cloud is unreachable you get the error state of the previous bullet instead.",
          "source_line": 85,
          "expected": [
            "**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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--agent-server-guidance",
          "title": "Agent-server guidance",
          "ids": [
            "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`.",
          "raw": "**Agent-server guidance (`F25.agent-server-guidance`).** 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`.",
          "source_line": 86,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--connect-rules-and-errors",
          "title": "Connect rules and errors",
          "ids": [
            "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`.",
          "raw": "**Connect rules and errors (`F25.add-agent-server`).** 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`.",
          "source_line": 87,
          "expected": [
            "**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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F25--add-the-second-backend",
          "title": "Add the second backend",
          "ids": [
            "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. Known failure (reproduced 2026-10-13, #18390): on the run's first activation of `QA_Second` the list sometimes holds `GET <second-url>/api/conversations?ids=<id>` (`200`); record the bullet as `fail` then (see Gotchas). (`--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`.",
          "raw": "**Add the second backend (`F25.add-agent-server`).** 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. Known failure (reproduced 2026-10-13, #18390): on the run's first activation of `QA_Second` the list sometimes holds `GET <second-url>/api/conversations?ids=<id>` (`200`); record the bullet as `fail` then (see Gotchas). (`--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`.",
          "source_line": 88,
          "expected": [
            "**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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch."
          ],
          "annotations": [
            "Known failure (reproduced 2026-10-13, #18390): on the run's first activation of `QA_Second` the list sometimes holds `GET <second-url>/api/conversations?ids=<id>` (`200`); record the bullet as `fail` then (see Gotchas). (`--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`."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F25--switch-with-the-overlay",
          "title": "Switch with the overlay",
          "ids": [
            "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`.",
          "raw": "**Switch with the overlay (`F25.switch-backend`).** 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`.",
          "source_line": 89,
          "expected": [
            "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)."
          ],
          "notes": [
            "Known issue [#15822](https://github.com/OpenHands/OpenHands/issues/15822) (`F25.switch-backend` `F25.health-status` `F25.cloud-org-rows`): Cloud-proxy traffic was reported to continue after switching from Cloud to Local. Current source probes registered inactive Cloud backends on a five-minute cadence; recheck the reported repeated OPTIONS behavior with a real registered Cloud account/org and capture only credential-redacted request metadata after switching. This run has no authenticated Cloud backend."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F25--pinned-url",
          "title": "Pinned URL",
          "ids": [
            "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`).",
          "raw": "**Pinned URL (`F25.backend-pinned-url`).** 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`).",
          "source_line": 90,
          "expected": [
            "in-app links carry `?backend=<id>`; opening such a URL pins the tab to that backend; an unknown id is ignored."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--per-tab-backend",
          "title": "Per-tab backend",
          "ids": [
            "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.",
          "raw": "**Per-tab backend (`F25.per-tab-backend`).** 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.",
          "source_line": 91,
          "expected": [
            "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>`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--manage-list-and-closing",
          "title": "Manage list and closing",
          "ids": [
            "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`. Focus is inside: the Add Backend bullet's focus eval with `manage-backends-modal` in place of `add-backend-modal` reads `close-manage-backends-modal` on open and stays `inDialog` `true` over nine Tabs and two Shift+Tabs (with two backends listed, Tab 8 reaches **Done** and Tab 9 wraps to the close button); after Escape, focus is back on the selector. 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`.",
          "raw": "**Manage list and closing (`F25.manage-backends`).** 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`. Focus is inside: the Add Backend bullet's focus eval with `manage-backends-modal` in place of `add-backend-modal` reads `close-manage-backends-modal` on open and stays `inDialog` `true` over nine Tabs and two Shift+Tabs (with two backends listed, Tab 8 reaches **Done** and Tab 9 wraps to the close button); after Escape, focus is back on the selector. 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`.",
          "source_line": 92,
          "expected": [
            "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; it takes keyboard focus on open and keeps Tab inside; Escape, a backdrop click, X and **Done** close it and return focus to the selector."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--select-from-manage",
          "title": "Select from Manage",
          "ids": [
            "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.",
          "raw": "**Select from Manage (`F25.manage-select`).** 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.",
          "source_line": 93,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--edit-validation-and-errors",
          "title": "Edit validation and errors",
          "ids": [
            "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`.",
          "raw": "**Edit validation and errors (`F25.edit-backend`).** 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`.",
          "source_line": 94,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--escape-in-edit",
          "title": "Escape in Edit",
          "ids": [
            "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`).",
          "raw": "**Escape in Edit (`F25.edit-escape`).** 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`).",
          "source_line": 95,
          "expected": [
            "Escape in the Edit modal should not discard the draft (the modal disables Escape)."
          ],
          "notes": [
            "Known failure (repro candidate, not an exemption): `F25.edit-escape` closes the Manage modal underneath and drops the Edit draft (Cancel and X close only Edit, `F25.edit-cancel`) ([#17953](https://github.com/OpenHands/OpenHands/issues/17953))."
          ],
          "annotations": [
            "Known failure, see Gotchas."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--edit-and-save",
          "title": "Edit and save",
          "ids": [
            "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`.",
          "raw": "**Edit and save (`F25.edit-backend`).** 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`.",
          "source_line": 96,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--health-status",
          "title": "Health status",
          "ids": [
            "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`.",
          "raw": "**Health status (`F25.health-status`).** 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`.",
          "source_line": 97,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#15822](https://github.com/OpenHands/OpenHands/issues/15822) (`F25.switch-backend` `F25.health-status` `F25.cloud-org-rows`): Cloud-proxy traffic was reported to continue after switching from Cloud to Local. Current source probes registered inactive Cloud backends on a five-minute cadence; recheck the reported repeated OPTIONS behavior with a real registered Cloud account/org and capture only credential-redacted request metadata after switching. This run has no authenticated Cloud backend.",
            "Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F25--recovery-gate",
          "title": "Recovery gate",
          "ids": [
            "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.",
          "raw": "**Recovery gate (`F25.recovery-gate`).** 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.",
          "source_line": 98,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--remove-the-active-backend",
          "title": "Remove the active backend",
          "ids": [
            "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`.",
          "raw": "**Remove the active backend (`F25.remove-backend`).** 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`.",
          "source_line": 99,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--add-from-manage-edit-cancel-and-x",
          "title": "Add from Manage, Edit Cancel and X",
          "ids": [
            "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`.",
          "raw": "**Add from Manage, Edit Cancel and X (`F25.manage-add`, `F25.edit-cancel`).** 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`.",
          "source_line": 100,
          "expected": [
            "**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.",
            "**Cancel** and the X in Edit backend close only the Edit modal and discard the draft; Manage backends stays open with the stored values."
          ],
          "notes": [
            "Known failure (repro candidate, not an exemption): `F25.edit-escape` closes the Manage modal underneath and drops the Edit draft (Cancel and X close only Edit, `F25.edit-cancel`) ([#17953](https://github.com/OpenHands/OpenHands/issues/17953))."
          ],
          "annotations": [],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--collapsed-rail",
          "title": "Collapsed rail",
          "ids": [
            "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'`.",
          "raw": "**Collapsed rail (`F25.collapsed-entry`).** 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'`.",
          "source_line": 101,
          "expected": [
            "with the rail collapsed, the backend icon's popover offers the same Add Backend and Manage Backends items, and both modals open."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--phone",
          "title": "Phone",
          "ids": [
            "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. Click `testid=manage-backends-edit-Local` and check `bbox 'testid=edit-backend-modal'`: `insideViewport` `true`; repeat at `browser viewport 320x568`. At `browser viewport 320x400`, clear `edit-backend-name`, fill `edit-backend-host` with `not a host` and focus `edit-backend-api-key` to show both validation messages. Run `browser scroll 'testid=edit-backend-form' --by 600`: Cancel and Save must come into view while the title and `edit-backend-close` stay visible. Check the modal and all three controls with `bbox`, then close with Cancel. Return with `browser viewport desktop`; reopen Edit and confirm its width is still 520px.",
          "raw": "**Phone (`F25.phone`).** 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. Click `testid=manage-backends-edit-Local` and check `bbox 'testid=edit-backend-modal'`: `insideViewport` `true`; repeat at `browser viewport 320x568`. At `browser viewport 320x400`, clear `edit-backend-name`, fill `edit-backend-host` with `not a host` and focus `edit-backend-api-key` to show both validation messages. Run `browser scroll 'testid=edit-backend-form' --by 600`: Cancel and Save must come into view while the title and `edit-backend-close` stay visible. Check the modal and all three controls with `bbox`, then close with Cancel. Return with `browser viewport desktop`; reopen Edit and confirm its width is still 520px.",
          "source_line": 102,
          "expected": [
            "at 390 px the drawer's selector opens Add and Manage inside the viewport without horizontal overflow. Edit backend fits at 320 and 390 px; on short viewports its form scrolls while the title and close control stay visible, and Cancel/Save remain reachable."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--providers-tab",
          "title": "Providers tab",
          "ids": [
            "F25.providers-tab"
          ],
          "body": "On the default run `browser eval \"!!window.marsBridge\"` is `true` (Preconditions); `curl -s \"http://127.0.0.1:$(control-openhands status | jq -r .ports.frontend)/mars/health\"` (the static child behind the ingress) returns the HTML index, not JSON: one bridge per stack. Open Add Backend: the tablist adds `tab \"Providers Connect through DigitalOcean and more.\"` (`testid=add-backend-option-providers`); `control-openhands browser click 'testid=add-backend-option-providers'` and `browser snapshot 'testid=add-backend-providers-panel'` show `button \"DigitalOcean Your team's OpenHands agents\"` and `More providers coming soon.` (`browser screenshot --feature F25.add-backend-modal --name with-providers-tab`). At phone width the modal stays inside the viewport with the three tab titles cut to `Ope…`, `Age…`, `Pro…` (`--feature F25.providers-tab --name phone-tabs`).",
          "raw": "**Providers tab (`F25.providers-tab`).** On the default run `browser eval \"!!window.marsBridge\"` is `true` (Preconditions); `curl -s \"http://127.0.0.1:$(control-openhands status | jq -r .ports.frontend)/mars/health\"` (the static child behind the ingress) returns the HTML index, not JSON: one bridge per stack. Open Add Backend: the tablist adds `tab \"Providers Connect through DigitalOcean and more.\"` (`testid=add-backend-option-providers`); `control-openhands browser click 'testid=add-backend-option-providers'` and `browser snapshot 'testid=add-backend-providers-panel'` show `button \"DigitalOcean Your team's OpenHands agents\"` and `More providers coming soon.` (`browser screenshot --feature F25.add-backend-modal --name with-providers-tab`). At phone width the modal stays inside the viewport with the three tab titles cut to `Ope…`, `Age…`, `Pro…` (`--feature F25.providers-tab --name phone-tabs`).",
          "source_line": 103,
          "expected": [
            "only when the page has the Managed Agents bridge (`window.marsBridge`), Add Backend shows a third tab **Providers** (\"Connect through DigitalOcean and more.\") with a **DigitalOcean** card (\"Your team's OpenHands agents\") and `More providers coming soon.`. Every launcher-served web page has it (the ingress answers `/mars/health`); without it (`MARS_WEB=0`, or a non-loopback bind with no session key at all) there are only the Cloud and Agent-server tabs and no Managed Agents UI anywhere. A non-loopback bind that has a key (F26 `--host 0.0.0.0` with `LOCAL_BACKEND_API_KEY`) mounts the bridge behind that key and offers the tab too, although the page itself carries no key (see the sign-in bullet's"
          ],
          "notes": [],
          "annotations": [
            "Known failure)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--digitalocean-sign-in",
          "title": "DigitalOcean sign-in",
          "ids": [
            "F25.digitalocean-sign-in"
          ],
          "body": "On the default run, run `control-openhands browser click 'testid=add-backend-provider-digitalocean'` and `browser wait 'testid=managed-agents-sign-in'`: heading `Connect your DigitalOcean team`, `textbox \"Personal access token\"` (`browser attr 'testid=managed-agents-token' type` is `password`), link `Create a token` (`/url: https://cloud.digitalocean.com/account/api/tokens`) and `button \"Connect with token\" [disabled]`; no OAuth button in a browser (`browser count 'testid=managed-agents-oauth'` is `0`). Fill `testid=managed-agents-token` with `dop_v1_qa_invalid_token_0000` and click `testid=managed-agents-token-submit`: `browser text 'testid=managed-agents-auth-error'` reads `That does not look like a DigitalOcean personal access token.` (`POST /mars/rpc/savePat` `500`). Fill a well-formed dummy, `dop_v1_` plus 64 zeros, and submit again: after a few seconds the error reads `401 Unauthorized` (`POST /mars/rpc/savePat` `401`, DigitalOcean's answer; `browser screenshot --feature F25.digitalocean-sign-in --name rejected-token`). Never type a real token here. `control-openhands browser click 'testid=add-backend-providers-back'` returns to the Providers panel (`browser count 'testid=managed-agents-sign-in'` is `0`; the providers panel itself stays mounted behind the sign-in screen, so its count is `1` either way); close with `testid=add-backend-close`. At phone width the sign-in screen fits (`bbox 'testid=add-backend-modal'` `insideViewport` `true`). Both errors also raise an error toast with the same text. Expected on a page served without a baked key (F26 `--host 0.0.0.0`, Docker), once the server's key was typed on the API-key screen: the same two answers as above. Known failure (reproduced 2026-10-14, #18434): every token, even the malformed one, ends in `session key required` (`POST /mars/rpc/getAuthState` and `savePat` `401`), because the web bridge client sends only the baked page key, never the typed one; it predates #18349 (BASE with `MARS_WEB=1` fails the same way). A `--frontend-only` page connected to another stack's backend fails the same way for a different reason (the typed key belongs to the other stack); #18434 leaves that case to a maintainer decision.",
          "raw": "**DigitalOcean sign-in (`F25.digitalocean-sign-in`).** On the default run, run `control-openhands browser click 'testid=add-backend-provider-digitalocean'` and `browser wait 'testid=managed-agents-sign-in'`: heading `Connect your DigitalOcean team`, `textbox \"Personal access token\"` (`browser attr 'testid=managed-agents-token' type` is `password`), link `Create a token` (`/url: https://cloud.digitalocean.com/account/api/tokens`) and `button \"Connect with token\" [disabled]`; no OAuth button in a browser (`browser count 'testid=managed-agents-oauth'` is `0`). Fill `testid=managed-agents-token` with `dop_v1_qa_invalid_token_0000` and click `testid=managed-agents-token-submit`: `browser text 'testid=managed-agents-auth-error'` reads `That does not look like a DigitalOcean personal access token.` (`POST /mars/rpc/savePat` `500`). Fill a well-formed dummy, `dop_v1_` plus 64 zeros, and submit again: after a few seconds the error reads `401 Unauthorized` (`POST /mars/rpc/savePat` `401`, DigitalOcean's answer; `browser screenshot --feature F25.digitalocean-sign-in --name rejected-token`). Never type a real token here. `control-openhands browser click 'testid=add-backend-providers-back'` returns to the Providers panel (`browser count 'testid=managed-agents-sign-in'` is `0`; the providers panel itself stays mounted behind the sign-in screen, so its count is `1` either way); close with `testid=add-backend-close`. At phone width the sign-in screen fits (`bbox 'testid=add-backend-modal'` `insideViewport` `true`). Both errors also raise an error toast with the same text. Expected on a page served without a baked key (F26 `--host 0.0.0.0`, Docker), once the server's key was typed on the API-key screen: the same two answers as above. Known failure (reproduced 2026-10-14, #18434): every token, even the malformed one, ends in `session key required` (`POST /mars/rpc/getAuthState` and `savePat` `401`), because the web bridge client sends only the baked page key, never the typed one; it predates #18349 (BASE with `MARS_WEB=1` fails the same way). A `--frontend-only` page connected to another stack's backend fails the same way for a different reason (the typed key belongs to the other stack); #18434 leaves that case to a maintainer decision.",
          "source_line": 104,
          "expected": [
            "the DigitalOcean card opens \"Connect your DigitalOcean team\" with a Back button, a masked **Personal access token** field, a **Create a token** link to `https://cloud.digitalocean.com/account/api/tokens` and **Connect with token**, disabled until a token is typed (the **Sign in with DigitalOcean** OAuth button exists only in the desktop app). A token of the wrong shape reads `That does not look like a DigitalOcean personal access token.`; a well-formed token DigitalOcean rejects shows its HTTP error (`401 Unauthorized`). Back returns to the Providers tab."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-14, #18434): every token, even the malformed one, ends in `session key required` (`POST /mars/rpc/getAuthState` and `savePat` `401`), because the web bridge client sends only the baked page key, never the typed one; it predates #18349 (BASE with `MARS_WEB=1` fails the same way). A `--frontend-only` page connected to another stack's backend fails the same way for a different reason (the typed key belongs to the other stack); #18434 leaves that case to a maintainer decision."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--managed-agents-with-an-account",
          "title": "Managed Agents with an account",
          "ids": [
            "F25.digitalocean-agents",
            "F25.digitalocean-backend-removal"
          ],
          "body": "Blocked: needs a DigitalOcean account with Managed Agents. With one, after a real token the panel lists agents (`digitalocean-agent-<id>`), **New Agent** opens `digitalocean-new-agent-form` (name, size), a session's Connect adds a backend and the sidebar shows `mars-session-panel`; removing that backend opens `mars-backend-removal-modal` with `disconnect-only-button` and `stop-session-button`.",
          "raw": "**Managed Agents with an account (`F25.digitalocean-agents`, `F25.digitalocean-backend-removal`).** Blocked: needs a DigitalOcean account with Managed Agents. With one, after a real token the panel lists agents (`digitalocean-agent-<id>`), **New Agent** opens `digitalocean-new-agent-form` (name, size), a session's Connect adds a backend and the sidebar shows `mars-session-panel`; removing that backend opens `mars-backend-removal-modal` with `disconnect-only-button` and `stop-session-button`.",
          "source_line": 105,
          "expected": [
            "signed in, the panel lists the team's OpenHands agents and their sessions, **New Agent** (name and sandbox size), refresh and sign-out; connecting a session (or **Resume and connect** a paused one) adds it as the active backend, the sidebar's conversation list becomes that agent's session panel (`mars-session-panel`), and Manage Backends' Managed Agents view offers Pause, Stop, Disconnect and Open per session (blocked: needs a DigitalOcean account with Managed Agents).",
            "removing a DigitalOcean backend in Manage Backends explains that it only disconnects and offers **Disconnect only** or **Stop session** (blocked: needs a connected DigitalOcean session)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F25--cloud-backend",
          "title": "Cloud backend",
          "ids": [
            "F25.cloud-device-flow",
            "F25.cloud-org-rows",
            "F25.cloud-settings-link",
            "F25.share-publicly",
            "F25.cloud-sandbox-states"
          ],
          "body": "Only with a Cloud account (Preconditions); it creates a device grant and one conversation on that account, and shows the account's own conversations in the sidebar (keep those screenshots private). Reopen Add Backend on the Cloud tab, `browser click 'testid=add-backend-login-button'`, then `control-openhands browser tab 1` (the verify popup: `Device Authorization Request`, the code, **Authorize Device**), `control-openhands browser click 'role=button[name=\"Authorize Device\"]'` (`Success!`) and `control-openhands browser tab 0`. Within about 15 s (token polling answers `400` until approved, then `200`) `browser count 'testid=add-backend-modal'` is `0`, the popup is gone and `browser snapshot 'testid=backend-selector'` reads `combobox \"OpenHands Cloud\": OpenHands Cloud – Personal Workspace`; the registry holds `{name: \"OpenHands Cloud\", host: \"https://app.all-hands.dev\", kind: \"cloud\"}` (`browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({name:b.name,host:b.host,kind:b.kind}))\"`). Open the selector: `option \"Connected OpenHands Cloud – Personal Workspace\"` (one option per organization) next to `Connected Local`; Manage Backends shows the row with `Personal Workspace` and the `Cloud` pill. `browser attr 'testid=backend-selector-settings-link' href` is `https://app.all-hands.dev/settings?org=<org id>` with `target` `_blank`; clicking it opens that tab (`browser wait-tab 'app.all-hands.dev/settings'`, then `browser close-tab 1`, `browser tab 0`). Start one conversation from Home: fill `testid=chat-input` with `Reply with exactly: QA_F25_CLOUD. Do not run any tools.` and `browser click 'testid=submit-button' --expect-url '/conversations/'`: the URL is `/conversations/task-<id>` while the sandbox starts (toasts `Creating conversation…`, `Sending...`), then moves to `/conversations/<id>?backend=<cloud id>&org=<org id>` and the reply `QA_F25_CLOUD` appears (about a minute). Its `conversation-name >> ellipsis-button` menu has `share-publicly-button` (**Public Share**, off); do not flip it unless the conversation may be public. Delete it with `delete-button` and `'role=dialog >> testid=confirm-button'`, then remove the Cloud backend in Manage Backends (`manage-backends-remove-OpenHands Cloud`, confirm): after `browser reload` the selector reads `Local`.",
          "raw": "**Cloud backend (`F25.cloud-device-flow`, `F25.cloud-org-rows`, `F25.cloud-settings-link`, `F25.share-publicly`, `F25.cloud-sandbox-states`).** Only with a Cloud account (Preconditions); it creates a device grant and one conversation on that account, and shows the account's own conversations in the sidebar (keep those screenshots private). Reopen Add Backend on the Cloud tab, `browser click 'testid=add-backend-login-button'`, then `control-openhands browser tab 1` (the verify popup: `Device Authorization Request`, the code, **Authorize Device**), `control-openhands browser click 'role=button[name=\"Authorize Device\"]'` (`Success!`) and `control-openhands browser tab 0`. Within about 15 s (token polling answers `400` until approved, then `200`) `browser count 'testid=add-backend-modal'` is `0`, the popup is gone and `browser snapshot 'testid=backend-selector'` reads `combobox \"OpenHands Cloud\": OpenHands Cloud – Personal Workspace`; the registry holds `{name: \"OpenHands Cloud\", host: \"https://app.all-hands.dev\", kind: \"cloud\"}` (`browser eval \"JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({name:b.name,host:b.host,kind:b.kind}))\"`). Open the selector: `option \"Connected OpenHands Cloud – Personal Workspace\"` (one option per organization) next to `Connected Local`; Manage Backends shows the row with `Personal Workspace` and the `Cloud` pill. `browser attr 'testid=backend-selector-settings-link' href` is `https://app.all-hands.dev/settings?org=<org id>` with `target` `_blank`; clicking it opens that tab (`browser wait-tab 'app.all-hands.dev/settings'`, then `browser close-tab 1`, `browser tab 0`). Start one conversation from Home: fill `testid=chat-input` with `Reply with exactly: QA_F25_CLOUD. Do not run any tools.` and `browser click 'testid=submit-button' --expect-url '/conversations/'`: the URL is `/conversations/task-<id>` while the sandbox starts (toasts `Creating conversation…`, `Sending...`), then moves to `/conversations/<id>?backend=<cloud id>&org=<org id>` and the reply `QA_F25_CLOUD` appears (about a minute). Its `conversation-name >> ellipsis-button` menu has `share-publicly-button` (**Public Share**, off); do not flip it unless the conversation may be public. Delete it with `delete-button` and `'role=dialog >> testid=confirm-button'`, then remove the Cloud backend in Manage Backends (`manage-backends-remove-OpenHands Cloud`, confirm): after `browser reload` the selector reads `Local`.",
          "source_line": 106,
          "expected": [
            "**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.",
            "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.",
            "with a Cloud backend active the gear next to the selector opens `<cloud host>/settings?org=<org>` in a new tab.",
            "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.",
            "Cloud conversations show waiting, archived and error sandbox states in chat."
          ],
          "notes": [
            "Known issue [#17390](https://github.com/OpenHands/OpenHands/issues/17390) (`F25.cloud-sandbox-states`): Every sent Cloud message can fail with `token_not_found_in_db` from the model proxy. Recheck with the affected Cloud account/session and a minimal no-tools prompt; capture the error type with provider token text and hashes redacted. The report supplies no deployment version or deterministic token-provisioning sequence, and this run has no Cloud account.",
            "Known issue [#17163](https://github.com/OpenHands/OpenHands/issues/17163) (`F25.cloud-sandbox-states`): A Free Cloud account can lose access to previously usable models with HTTP 403 and a team-restriction message despite no team being created. Recheck with the affected account and its model entitlement/org history; local credentials cannot prove Cloud eligibility and this run has no such account.",
            "Known issue [#16874](https://github.com/OpenHands/OpenHands/issues/16874) (`F25.cloud-sandbox-states`): A failed resume of a paused Cloud sandbox was reported to consume a one-shot guard and leave chat disconnected until remount. Current source uses a retrying resume hook, so recheck the original failure and a second pause in one route mount before concluding resolution. Blocked without a real paused Cloud sandbox and a controlled one-request backend/network failure; source changes alone are not a live pass.",
            "Known issue [#15676](https://github.com/OpenHands/OpenHands/issues/15676) (`F25.cloud-sandbox-states`): Refreshing an approximately 24-hour-old Cloud conversation on Vercel can fail to resume/reconnect. Recheck an authenticated affected Cloud deployment with an aged sandbox/session and exact console/network error; the report omits its commit and failure text, and no aged Cloud fixture is available here.",
            "Known issue [#15822](https://github.com/OpenHands/OpenHands/issues/15822) (`F25.switch-backend` `F25.health-status` `F25.cloud-org-rows`): Cloud-proxy traffic was reported to continue after switching from Cloud to Local. Current source probes registered inactive Cloud backends on a five-minute cadence; recheck the reported repeated OPTIONS behavior with a real registered Cloud account/org and capture only credential-redacted request metadata after switching. This run has no authenticated Cloud backend.",
            "Known issue [OpenHands/automation#565](https://github.com/OpenHands/automation/issues/565) (`F25.cloud-org-rows`, `F25.cloud-org-suspended`): An org-scoped Cloud automation request with an unauthorized `X-Org-Id` can turn the identity service's 403 into a misleading 502/outage page. Recheck with an authenticated Cloud org-bound key and a disposable unauthorized organization id, comparing automation list and the identity response while preserving credential redaction. Local mode bypasses this identity service and cannot reproduce it."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F25--device-verify-page",
          "title": "Device verify page",
          "ids": [
            "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.",
          "raw": "**Device verify page (`F25.device-verify-page`).** 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.",
          "source_line": 107,
          "expected": [
            "`/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**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--shared-view",
          "title": "Shared view",
          "ids": [
            "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`.",
          "raw": "**Shared view (`F25.shared-conversation-view`).** 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`.",
          "source_line": 108,
          "expected": [
            "`/shared/conversations/<id>` is a standalone read-only page (no app shell); on a local backend it reads `Conversation not found`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F25--no-public-share-locally",
          "title": "No Public Share locally",
          "ids": [
            "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.",
          "raw": "**No Public Share locally (`F25.share-publicly`).** 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.",
          "source_line": 109,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        },
        {
          "name": "F25--cleanup",
          "title": "Cleanup",
          "ids": [],
          "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.",
          "raw": "**Cleanup.** `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.",
          "source_line": 110,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F25--errors",
          "title": "Errors",
          "ids": [],
          "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).",
          "raw": "**Errors.** `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).",
          "source_line": 111,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F25--same-origin-credential-readability",
          "title": "Same-origin credential readability",
          "ids": [
            "F25.manage-backends",
            "F25.add-agent-server"
          ],
          "body": "In an authenticated private run, use read-only `browser eval` to return only the count of registered entries and the count whose `apiKey` is a nonempty string in `JSON.parse(localStorage.getItem('openhands-backends') || '[]')`. Expected: backend credentials are not readable by arbitrary same-origin script. Known failure, reproduced 2026-10-12: registered entries have readable plaintext keys ([#16492](https://github.com/OpenHands/OpenHands/issues/16492)). Retain counts/booleans only, never key values or whole storage JSON.",
          "raw": "**Same-origin credential readability (`F25.manage-backends`, `F25.add-agent-server`).** In an authenticated private run, use read-only `browser eval` to return only the count of registered entries and the count whose `apiKey` is a nonempty string in `JSON.parse(localStorage.getItem('openhands-backends') || '[]')`. Expected: backend credentials are not readable by arbitrary same-origin script. Known failure, reproduced 2026-10-12: registered entries have readable plaintext keys ([#16492](https://github.com/OpenHands/OpenHands/issues/16492)). Retain counts/booleans only, never key values or whole storage JSON.",
          "source_line": 113,
          "expected": [
            "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; it takes keyboard focus on open and keeps Tab inside; Escape, a backdrop click, X and **Done** close it and return focus to the selector.",
            "**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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: registered entries have readable plaintext keys ([#16492](https://github.com/OpenHands/OpenHands/issues/16492)). Retain counts/booleans only, never key values or whole storage JSON."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F25--manually-registered-same-origin-key-rotation",
          "title": "Manually registered same-origin key rotation",
          "ids": [
            "F25.add-agent-server",
            "F25.recovery-gate"
          ],
          "body": "Through **Add Backend → Agent-server**, add an owned entry named `QA_Rotation_Legacy` with the run's exact origin and fill its API Key using `--value-file <run>/private/session-key`; keep it active. Run `restart --rotate-key`, `doctor`, and `browser reload`. Compare only each saved entry's `apiKey === window.__AGENT_CANVAS_SESSION_API_KEY__` boolean. Expected: same-origin entries accept the newly injected session key and the active backend recovers. Known failure, reproduced 2026-10-12: `default-local` matches the injected key, the manually registered entry does not, and the UI shows **Disconnected**/**Unauthorized** ([#17371](https://github.com/OpenHands/OpenHands/issues/17371)). This version has visible status feedback, so do not repeat the older claim of a completely silent loop. Choose **Connected Local** to recover, then remove the owned duplicate.",
          "raw": "**Manually registered same-origin key rotation (`F25.add-agent-server`, `F25.recovery-gate`).** Through **Add Backend → Agent-server**, add an owned entry named `QA_Rotation_Legacy` with the run's exact origin and fill its API Key using `--value-file <run>/private/session-key`; keep it active. Run `restart --rotate-key`, `doctor`, and `browser reload`. Compare only each saved entry's `apiKey === window.__AGENT_CANVAS_SESSION_API_KEY__` boolean. Expected: same-origin entries accept the newly injected session key and the active backend recovers. Known failure, reproduced 2026-10-12: `default-local` matches the injected key, the manually registered entry does not, and the UI shows **Disconnected**/**Unauthorized** ([#17371](https://github.com/OpenHands/OpenHands/issues/17371)). This version has visible status feedback, so do not repeat the older claim of a completely silent loop. Choose **Connected Local** to recover, then remove the owned duplicate.",
          "source_line": 114,
          "expected": [
            "**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.",
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5789](https://github.com/OpenHands/software-agent-sdk/issues/5789) (`F25.add-agent-server`, `F25.health-status`): a deferred-init Agent Server can report ready after accepting a GUI origin through `/api/init`, while browser preflights from that origin still fail and Canvas cannot connect. Recheck an owned warm-pool server initialized with the actual non-localhost Canvas origin and compare admission with an origin supplied at server boot. The default loopback-origin stack is already allowed and cannot exercise this branch."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: `default-local` matches the injected key, the manually registered entry does not, and the UI shows **Disconnected**/**Unauthorized** ([#17371](https://github.com/OpenHands/OpenHands/issues/17371)). This version has visible status feedback, so do not repeat the older claim of a completely silent loop. Choose **Connected Local** to recover, then remove the owned duplicate."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F25--non-loopback-default-backend-name",
          "title": "Non-loopback default backend name",
          "ids": [
            "F25.selector-dropdown"
          ],
          "body": "Serve the unchanged frontend on an owned non-loopback LAN address with `--auth-required`, no session-key injection and real protected backend routes. Open that origin in a new browser tab with `--allow-external`; its Add a backend step defaults **Host Name** to `Local`. Authenticate only with the private run key file, continue once and skip the remaining setup, then inspect `backend-selector`. Expected: the default name identifies the backend's host on a remote origin. Known failure, reproduced 2026-10-12: the selector reads **Connected Local** on the non-loopback origin ([#16297](https://github.com/OpenHands/OpenHands/issues/16297)). This is a LAN-origin check, not a VM deployment claim. Stop only the owned fixture frontend and return to the original tab.",
          "raw": "**Non-loopback default backend name (`F25.selector-dropdown`).** Serve the unchanged frontend on an owned non-loopback LAN address with `--auth-required`, no session-key injection and real protected backend routes. Open that origin in a new browser tab with `--allow-external`; its Add a backend step defaults **Host Name** to `Local`. Authenticate only with the private run key file, continue once and skip the remaining setup, then inspect `backend-selector`. Expected: the default name identifies the backend's host on a remote origin. Known failure, reproduced 2026-10-12: the selector reads **Connected Local** on the non-loopback origin ([#16297](https://github.com/OpenHands/OpenHands/issues/16297)). This is a LAN-origin check, not a VM deployment claim. Stop only the owned fixture frontend and return to the original tab.",
          "source_line": 116,
          "expected": [
            "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**."
          ],
          "notes": [
            "Expected: Escape never clears the backend selector. Known failure (reproduced 2026-10-09, 2026-10-12, 2026-10-13 and 2026-10-14, [#18233](https://github.com/OpenHands/OpenHands/issues/18233)): Escape on the focused backend selector while its menu is closed (a second Escape after the one that closes the menu, and sometimes the Escape that closes Manage Backends opened from it) clears the selection: the closed selector shows the active backend's name as an italic placeholder (`browser value 'testid=backend-selector >> role=combobox'` is `\"\"`) and its status dot is gone, so `browser wait 'testid=backend-selector >> role=status[name=\"Disconnected\"]'` times out although the backend is down; a reload restores it. Reload before a closed-selector status check, as the Edit bullet does. Repro: `browser click 'testid=backend-selector'`, `browser press Escape` twice ([#18233](https://github.com/OpenHands/OpenHands/issues/18233)). On 2026-10-12 `bug proof` verified fix PR #18238 at 77de0ad24 against main f07aebdf5 (two `xfail`, two `xpass`, desktop). `control-openhands bug run F25.selector-dropdown` replays it (desktop, after a reload with the mouse off the selector: a reload under a mouse left on it opens its menu and empties its field, which would look like the bug) and reports `xfail` while the bug lasts:\n\n  ```sh known-bug F25.selector-dropdown escape-closed\n  control-openhands browser viewport desktop\n  control-openhands browser goto /\n  control-openhands browser hover 'testid=root-layout'\n  control-openhands browser reload\n  control-openhands browser wait 'testid=backend-selector >> role=status[name=\"Connected\"]' --timeout 15000\n  control-openhands assert --field value --equals Local -- browser value 'testid=backend-selector >> role=combobox'\n  control-openhands browser click 'testid=backend-selector'\n  control-openhands browser press Escape\n  control-openhands browser press Escape\n  control-openhands browser screenshot 'testid=backend-selector' --feature F25.selector-dropdown --name escape-closed\n  control-openhands assert --field value --equals Local --bug-shows '' -- browser value 'testid=backend-selector >> role=combobox'  # bug\n  ```"
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: the selector reads **Connected Local** on the non-loopback origin ([#16297](https://github.com/OpenHands/OpenHands/issues/16297)). This is a LAN-origin check, not a VM deployment claim. Stop only the owned fixture frontend and return to the original tab."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F25--contract-f25-cloud-log-back-in",
          "title": "Cloud log back in",
          "ids": [
            "F25.cloud-log-back-in"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "a logged-out Cloud row shows `Logged out` and a **Log back in** button that reruns the device flow."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        },
        {
          "name": "F25--contract-f25-cloud-org-suspended",
          "title": "Cloud org suspended",
          "ids": [
            "F25.cloud-org-suspended"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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)."
          ],
          "notes": [
            "Known issue [OpenHands/automation#565](https://github.com/OpenHands/automation/issues/565) (`F25.cloud-org-rows`, `F25.cloud-org-suspended`): An org-scoped Cloud automation request with an unauthorized `X-Org-Id` can turn the identity service's 403 into a misleading 502/outage page. Recheck with an authenticated Cloud org-bound key and a disposable unauthorized organization id, comparing automation list and the identity response while preserving credential redaction. Local mode bypasses this identity service and cannot reproduce it."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F25--contract-f25-locked-cloud",
          "title": "Locked cloud",
          "ids": [
            "F25.locked-cloud"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#18029](https://github.com/OpenHands/OpenHands/issues/18029) (`F25.locked-cloud`): A returning user in a locked Cloud deployment can reach the no-backend placeholder and fail to start conversations despite a healthy configured backend. The report proposes a stale document or alternate origin but does not capture which occurred. Recheck against the affected locked deployment with a Cloud session, its exact origin, cache response headers and injected host state; the normal local launcher does not reproduce this deployment condition."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        }
      ]
    },
    {
      "id": "F26",
      "title": "Launcher modes, Docker, desktop and library",
      "file": "F26-runtime-variants.md",
      "sha256": "7bb140ee703385f2ef526b0cbb7be0dc1fc6070c9e56c8d58a34f12fb87204e2",
      "overview": "Agent Canvas ships one UI in several runtime shapes. The npm launcher\n(`npx @openhands/agent-canvas`, here `node bin/agent-canvas.mjs`) runs the full\nstack, or only the frontend or only the backends, prints its version and stack\ninfo, refuses conflicting flags, and decides whether the session key is\ninjected into the page (loopback) or the user must paste it (`--public`, or a\nnon-loopback `--host`). The same stack also ships as a Docker image, an\nElectron desktop app and an embeddable React library. Launchers tell the agent\nwhich services exist through `/server_info.runtime_services` and a\n`<RUNTIME_SERVICES>` block in every new conversation's system prompt.",
      "entry": "- A terminal: `npx @openhands/agent-canvas [flags]`; from this checkout, `node bin/agent-canvas.mjs [flags]` (what `control-openhands launch` starts with `--port`, and `--public` for public mode).\n- The browser at the ingress URL the launcher prints (`http://localhost:8000/` by default).\n- Backend selector (`backend-selector`) → **Add Backend** (`add-backend-menu-item`) → **Agent-server** → **Remote**, and **Manage Backends** to remove it (see F25 for the backend registry itself).\n- Public mode and the API-key screen are mapped in F01 (`F01.api-key-entry`, `F01.onboarding-backend-step`).\n- `npm run build:lib` for the embeddable library; a host app imports `@openhands/agent-canvas` (see `docs/DEVELOPMENT.md`, \"Cloud organization recovery in embedded hosts\").\n- `docker run ... ghcr.io/openhands/agent-canvas` or `npm run build:docker`, then `http://localhost:8000/canvas`.\n- `helm install agent-canvas ./helm/agent-canvas` on a Kubernetes cluster (`helm/agent-canvas/README.md`).\n- `npm run desktop` (development) or a packaged build from `npm run build:desktop`; the window opens itself.\n- ACP agents (Claude Code, Codex, Gemini CLI) are chosen in onboarding (F01) and Settings → Agents (F13); this family does not repeat them.",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) on your own run: `control-openhands launch --new --build never`, `export OH_VERIFY_RUN=<run from the launch JSON>`, `control-openhands doctor`, `control-openhands onboard --skip`. Commands run from the checkout root.\n- `F26.desktop-*` need the Electron binary (`node_modules/electron/dist/electron`; `npx --no-install electron --version` downloads it on first use) and `Xvfb` plus ImageMagick `import` for headless capture. The app binds the fixed ports 8000, 18000, 18001 and 3001: check them like the blocks below and keep the run short.\n- `F26.runtime-services` and `F26.runtime-services-agent-use` need an active LLM profile (`control-openhands llm preset deepseek`); the second also needs the automation service running and costs two short runs (the conversation and the dispatched automation run).\n- The partial-stack and LAN bullets start extra launchers by hand (harness gap: `launch` has no `--frontend-only`, `--backend-only` or `--host`). Each one needs a free block of ports: these recipes use 18960–18963 and 18970–18973, plus 19961, the editor port a launcher that serves VS Code hands its agent-server (see Gotchas); check them first with `for p in 18960 18961 18962 18963 18970 18971 18972 18973 19961; do (echo > /dev/tcp/127.0.0.1/$p) 2>/dev/null && echo \"$p busy\"; done` (no output means free). Each launcher gets a private `HOME` and state under `$OH_VERIFY_RUN/private/`, so it never touches `~/.openhands` or your run.",
      "gotchas": "- `control-openhands launch` covers only the full stack and `--public`. Partial stacks and `--host` need a hand-started launcher with its own `HOME`, `OH_CANVAS_SAFE_STATE_DIR` and all three `OH_CANVAS_SAFE_*_PORT` variables; without them it writes into the real `~/.openhands/agent-canvas` and grabs the default ports 8000/18000/18001/3001 that another agent may be using.\n- A hand-started launcher's first start downloads the agent-server into its private `HOME` cache (30–90 s). Prefix assignments are expanded left to right, so `HOME=$Q/home UV_CACHE_DIR=$HOME/.cache/uv` points the cache into `$Q/home` too, not at your real cache. Reuse one `$Q` for repeat launches to keep it warm.\n- The browser daemon refuses other origins unless you pass `--allow-external` to `browser goto`. Each port is its own origin, so a partial-stack page starts at first run again (onboarding and a consent dialog), independent of your run's onboarding.\n- In a frontend-only UI the **Add a backend** step's Next is enabled with an empty key, while on a LAN or `--public` stack it is disabled until a key is typed: the host the step probes differs (503 vs. auth-required).\n- A partial-stack origin keeps its stored backend in the browser profile across launches, so a second pass lands straight in the shell. `control-openhands browser reset` brings back the first run on every origin; then `control-openhands browser goto /` and `control-openhands onboard --skip` restore your own run's baseline.\n- Which screen a frontend-only origin shows depends on the browser, not the launcher: a profile new to the origin gets **Add a backend** (first run owns the initial backend), while a profile that stores a backend for that origin (the `default-local` entry a full launcher on the same port seeded, or any backend added there earlier) gets the recovery gate of `F25.recovery-gate` as soon as that backend answers 503 (`F26.frontend-only-returning`). The no-toast check belongs to the first-run state only: on the gate, `browser toasts --history` lists generic `An error occurred` status toasts raised by the failing 503 probes (two within 12 s here), with no hint of which request failed. Known failure (reproduced 2026-10-08): both toasts come from the free-models hydrator's `GET /api/llm/models/verified` 503, which no `meta.disableToast` guards on that screen (nor does `backend-version.tsx`); [#18160](https://github.com/OpenHands/OpenHands/issues/18160).\n- Stopping a backend-only instance while the frontend-only page that uses it stays open is the same backend-down state as `F25.recovery-gate` after a reload; keep the returning-user bullet above as the frontend-only-specific check (it reproduces the `default-local` case).\n- The launcher serves the bundled VS Code editor. #17660 made that opt-in (`OH_CANVAS_ENABLE_VSCODE=true`) and #18048 reverted it, so only a checkout between the two needs the variable (`launch --vscode` sets it). A launcher that serves it reserves `/vscode/`: a frontend-only stack answers `503` there and logs `[static] /vscode -> 503 (rejected)`; a stack with an agent-server lists `/vscode → http://127.0.0.1:<agent-server port + 1000>` in its ingress banner and hands the agent-server that editor port (19961 for the hand-started launchers here, 19000 for the desktop app), which the launcher's own port check does not cover. On a checkout between the two without the variable, `/vscode/` is an ordinary SPA URL (`200`). Here the agent-server has no editor binary (`VSCode server binary not found, VSCode will be disabled` in the launcher log, `/api/vscode/status` `{\"running\":false,\"enabled\":true}`), so the editor port stays free even then.\n- The port-in-use error is printed twice, the second time with a Node stack trace, and suggests `PORT=<other>` rather than `--port` ([#17949](https://github.com/OpenHands/OpenHands/issues/17949)). The `--public` key error's example says `npm run dev -- --public` instead of the npx command.\n- A backend-only instance's `/server_info.runtime_services` still describes the ingress as routing `/* to the frontend`, which it does not run ([#17949](https://github.com/OpenHands/OpenHands/issues/17949)).\n- In this sandbox `node_modules/electron/dist` is absent and `node node_modules/electron/install.js` fails with `ERR_ASSERTION` (2026-10-13), so the `F26.desktop-*` rows stay blocked here.\n- The Electron app hardcodes ports 8000/18000/18001 (and the static server takes 3001), ignores `PORT`, and refuses to start as root without `--no-sandbox` (`Running as root without --no-sandbox is not supported`). `npm run desktop` first runs `build:app`, which rebuilds `build/` under every other run: start `node_modules/.bin/electron --no-sandbox electron` directly against the existing build.\n- Never stop the desktop app with one group-wide signal: when Xvfb dies with Electron, the app exits without stopping its backend, which is left orphaned (own process groups, parent 1) on 8000/18000/18001/3001 and blocks the next start. If that happens, find the orphans by their ports' command lines (`ps -eo pid,pgid,cmd`, look for `--port 18000`, `--port 18001`, `--port 3001`, `ingress.mjs --port 8000`) and stop each group by id.\n- The generated session key lives in `$HOME/.openhands/agent-canvas/api-key.txt`, not under `OH_CANVAS_SAFE_STATE_DIR`: a hand-started launcher without a private `HOME` reads and writes the operator's real key file.\n- A browser origin keeps the backends it stored on an earlier pass: after the LAN pass on 18960, a later loopback launch on 18960 shows the stale `QA_LAN` backend (red, old key) instead of onboarding, although the page injects a valid key. Assert key-injection bullets through the `window` globals, or `browser reset` first.\n- Electron's splash shows each raw service-log line (`agent-server: {\"asctime\": ...`) as its status text during boot; the named phases only appear between them.\n- `build:lib` from a copy whose `node_modules` is a symlink emits bundled dependencies under `dist/<absolute node_modules path>/...`; that path is an artifact of the copy, not of the product.\n- **Bug candidate (`F26.host-bind-lan-optin`):** `scripts/bind-host.mjs` tells npm users to pass `--allow-lan-session-key`, but neither `bin/agent-canvas.mjs` nor `scripts/dev-with-automation.mjs` parses or forwards it (only `scripts/static-server.mjs` and the Docker entrypoint do), and unknown flags are silently ignored ([#17949](https://github.com/OpenHands/OpenHands/issues/17949), whose first item is exactly this; reproduced again on 2026-10-08 at d2c89252d).\n- Known issue [#17946](https://github.com/OpenHands/OpenHands/issues/17946): the npm launcher never creates its `TMUX_TMPDIR`, so hand-started instances share one tmux server and a restart of one resets the others' terminals.\n- Known issue [#17763](https://github.com/OpenHands/OpenHands/issues/17763) (`F26.docker-image`): The Docker entrypoint can leave protected Agent Server APIs unauthenticated when only `LOCAL_BACKEND_API_KEY` is supplied, because its export to `OH_SESSION_API_KEYS_0` is inside the generate-key branch. Recheck an owned container with a dummy custom key: unauthenticated `GET /api/conversations/search` must be 401 and the same request with that dummy key 200. `/server_info` is intentionally public and is not an auth probe. Blocked here without a Docker daemon.\n- Known issue [#17623](https://github.com/OpenHands/OpenHands/issues/17623) (`F26.helm-chart`): The chart defaults service-account-token automounting to true even with RBAC disabled, exposing an unused cluster credential to agent processes. Recheck a disposable Kubernetes installation using the default values and an explicit opt-in; inspect token existence/readability only, never its contents. Blocked without Helm and a Kubernetes cluster.\n- Known issue [#17470](https://github.com/OpenHands/OpenHands/issues/17470) (`F26.docker-image`): On Apple Silicon under Colima/Virtualization.framework, the image's Python services can exit with SIGILL while the frontend leaves onboarding at Disconnected. The report isolates `import litellm` and compares `OPENSSL_armcap=0`; recheck on the affected ARM/Colima guest with the reported cryptography/OpenSSL combination. A native macOS npm stack is not that container environment.\n- Known issue [#17112](https://github.com/OpenHands/OpenHands/issues/17112) (`F26.desktop-main-window` `F26.desktop-external-links`): Desktop main-window navigation, an OAuth popup redirect, or a nested popup can render a remote page inside the app frame. Recheck all three on an owned running Electron shell with a harmless remote page and loopback redirect fixture; the browser harness cannot operate Electron windows and the installed Electron binary is unavailable here.\n- Known issue [#16872](https://github.com/OpenHands/OpenHands/issues/16872) (`F26.desktop-external-links`): Non-browser URI schemes were reported to reach the OS handler from the desktop popup flow. Current source now parses hosts and restricts external protocols; recheck refusal in a running owned Electron shell before concluding resolution. Observe an inert denied test scheme without invoking any real OS handler. The browser harness cannot drive Electron and no installed Electron binary is available here.\n- Known issue [#16774](https://github.com/OpenHands/OpenHands/issues/16774) (`F26.desktop-boot-splash` `F26.desktop-main-window`): The macOS 1.14.0 DMG installs an app that Gatekeeper reports as damaged instead of booting. Recheck the actual downloaded signed/notarized release artifact through install and first launch on macOS; starting source Electron or removing quarantine would bypass the reported failure. That release DMG/signature fixture is unavailable in this run.\n- Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).\n- Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.\n- Known issue [#16511](https://github.com/OpenHands/OpenHands/issues/16511) (`F26.docker-image`): The documented Docker bind mount can be unwritable to container uid 10001, killing Agent Server/automation while the frontend still serves Bad Gateway. Recheck a disposable host-owned mode-700 mount and a writable control against an owned image; never chmod the operator's real state directory. Blocked here without Docker.\n- Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.\n- Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.\n- Known issue [#15434](https://github.com/OpenHands/OpenHands/issues/15434) (`F26.remote-backend`): The embedded VS Code action on a self-hosted reverse-proxy origin was reported to reopen Canvas because its advertised URL omitted the separate editor port/path. Current launchers include an ingress `/vscode/` route; recheck against the affected HTTPS proxy deployment with an actually available editor binary and emitted URL before concluding resolution. This run lacks that self-hosted proxy/editor deployment.\n- Known issue [OpenHands/software-agent-sdk#5373](https://github.com/OpenHands/software-agent-sdk/issues/5373) (`F26.remote-backend`): A self-hosted Agent Server using default Rich traceback logging can wedge its event loop and grow memory when a websocket error has a cyclic/deep exception chain; Canvas then times out or disconnects. The npm launcher forces JSON logging, so rechecking needs a separately hosted default-Rich backend plus the reported pathological peer/backlog reproducer and bounded memory/time isolation. That original peer/driver is not included in the issue body; do not synthesize a server patch or stress the shared host and claim a live UI reproduction.\n- Known issue [OpenHands/software-agent-sdk#5310](https://github.com/OpenHands/software-agent-sdk/issues/5310) (`F26.docker-conversation-runtime`): A Docker conversation can lose the auxiliary title profile at the runtime boundary, leaving an ACP conversation untitled or producing an unrelated title-auth error while the agent still works. Recheck a saved reachable title profile and an ACP conversation inside an owned per-conversation Docker runtime, comparing its title/profile metadata with the host; inspect only profile names and credential-presence booleans. Blocked without Docker and real auxiliary-title/ACP credentials.\n- Known issue [OpenHands/software-agent-sdk#5307](https://github.com/OpenHands/software-agent-sdk/issues/5307) (`F26.docker-conversation-runtime`): New worktree on a Docker-backed conversation can create git metadata whose reverse path exists only inside the container, so the still-live host worktree reads `prunable gitdir file points to non-existent location`. Recheck a disposable real git repository mounted into an owned conversation container, comparing host and container `git worktree list`/status while it is alive; a local worktree does not test the filesystem boundary. Blocked without Docker.\n- Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.\n- Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it.\n- Known issue [OpenHands/software-agent-sdk#5731](https://github.com/OpenHands/software-agent-sdk/issues/5731) (`F26.cli-public-needs-key`): whitespace-only session keys remain configured credentials in the Agent Server. The Canvas launcher forwards a nonempty `LOCAL_BACKEND_API_KEY` verbatim, so a whitespace-only deployment value can leave the public login unable to use its intended key. Recheck an owned loopback-only public deployment with a whitespace-only supplied key, its actual HTTP/WebSocket authentication and a normal nonblank control; never test the server's default broad bind on an exposed interface. The SDK's missing-`OH_SECRET_KEY` cipher fallback is a separate configuration branch from a Canvas launcher that supplies an encryption key.",
      "features": [
        {
          "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](https://github.com/OpenHands/OpenHands/issues/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](https://github.com/OpenHands/OpenHands/issues/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.automation-startup-failure",
          "description": "when the requested automation backend exits before it is ready, the launcher should print no success banner, stop the services it started and exit nonzero with the automation's exit reason. Known failure (reproduced 2026-10-13, #18362): it prints the **Main UI** banner and keeps the stack up with `/api/automation/health` answering `502`."
        },
        {
          "id": "F26.mars-web-bridge",
          "description": "every launcher-served web stack hosts the DigitalOcean Managed Agents bridge on its ingress by default (`MARS_WEB=0` opts out): `/mars/health` answers `{\"ok\":true,\"authRequired\":true}` on a full, `--backend-only`, `--frontend-only` and `--host 0.0.0.0` launcher, `/mars/rpc/*` answers `401` without the session key and works with it, the static child behind the ingress hosts none, and the Electron stack hosts none either (its preload provides `window.marsBridge`)."
        },
        {
          "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. On a host whose kernel has no IPv6 (no `/proc/net/if_inet6`) the entrypoint logs `IPv6 is not available; binding the frontend to 0.0.0.0.` and the container keeps running (#18180)."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F26--version",
          "title": "Version",
          "ids": [
            "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`.",
          "raw": "**Version (`F26.cli-version`).** 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`.",
          "source_line": 73,
          "expected": [
            "`--version` and `-v` print the package version and exit 0."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--info",
          "title": "Info",
          "ids": [
            "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).",
          "raw": "**Info (`F26.cli-info`).** 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).",
          "source_line": 74,
          "expected": [
            "`--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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--help",
          "title": "Help",
          "ids": [
            "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.",
          "raw": "**Help (`F26.cli-help`).** 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.",
          "source_line": 75,
          "expected": [
            "`--help` prints usage, auth modes, options, env vars and examples."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--conflicting-flags",
          "title": "Conflicting flags",
          "ids": [
            "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`.",
          "raw": "**Conflicting flags (`F26.cli-flag-conflicts`).** 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`.",
          "source_line": 76,
          "expected": [
            "`--frontend-only` with `--backend-only`, and `--public` with `--frontend-only`, exit 1 with a one-line error before anything starts."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--public-mode-needs-a-key",
          "title": "Public mode needs a key",
          "ids": [
            "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.)",
          "raw": "**Public mode needs a key (`F26.cli-public-needs-key`).** 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.)",
          "source_line": 77,
          "expected": [
            "`--public` without `LOCAL_BACKEND_API_KEY` exits 1 with guidance."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5731](https://github.com/OpenHands/software-agent-sdk/issues/5731) (`F26.cli-public-needs-key`): whitespace-only session keys remain configured credentials in the Agent Server. The Canvas launcher forwards a nonempty `LOCAL_BACKEND_API_KEY` verbatim, so a whitespace-only deployment value can leave the public login unable to use its intended key. Recheck an owned loopback-only public deployment with a whitespace-only supplied key, its actual HTTP/WebSocket authentication and a normal nonblank control; never test the server's default broad bind on an exposed interface. The SDK's missing-`OH_SECRET_KEY` cipher fallback is a separate configuration branch from a Canvas launcher that supplies an encryption key."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F26--missing-build",
          "title": "Missing build",
          "ids": [
            "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`.",
          "raw": "**Missing build (`F26.cli-missing-build`).** 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`.",
          "source_line": 78,
          "expected": [
            "a package without `build/` exits 1 with `No build found` and build instructions."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--port-in-use",
          "title": "Port in use",
          "ids": [
            "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).",
          "raw": "**Port in use (`F26.cli-port-in-use`).** 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).",
          "source_line": 79,
          "expected": [
            "an occupied ingress (or service) port stops the launch with `Cannot start: the following ports are already in use` naming each busy port."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--loopback-only-ingress",
          "title": "Loopback-only ingress",
          "ids": [
            "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`.",
          "raw": "**Loopback-only ingress (`F26.loopback-bind-default`).** 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`.",
          "source_line": 80,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "not-run"
          ]
        },
        {
          "name": "F26--backend-only",
          "title": "Backend-only",
          "ids": [
            "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.",
          "raw": "**Backend-only (`F26.backend-only`).** 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.",
          "source_line": 81,
          "expected": [
            "`--backend-only` serves the APIs (key-protected) and answers 503 `No backend configured for this route` for `/` and static assets."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--frontend-only",
          "title": "Frontend-only",
          "ids": [
            "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).",
          "raw": "**Frontend-only (`F26.frontend-only`).** 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).",
          "source_line": 82,
          "expected": [
            "`--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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--cross-connect",
          "title": "Cross-connect",
          "ids": [
            "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`.",
          "raw": "**Cross-connect (`F26.cross-connect`).** 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`.",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--remote-backend-from-a-normal-canvas",
          "title": "Remote backend from a normal Canvas",
          "ids": [
            "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`.",
          "raw": "**Remote backend from a normal Canvas (`F26.remote-backend`).** 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`.",
          "source_line": 84,
          "expected": [
            "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`)."
          ],
          "notes": [
            "Known issue [#15434](https://github.com/OpenHands/OpenHands/issues/15434) (`F26.remote-backend`): The embedded VS Code action on a self-hosted reverse-proxy origin was reported to reopen Canvas because its advertised URL omitted the separate editor port/path. Current launchers include an ingress `/vscode/` route; recheck against the affected HTTPS proxy deployment with an actually available editor binary and emitted URL before concluding resolution. This run lacks that self-hosted proxy/editor deployment.",
            "Known issue [OpenHands/software-agent-sdk#5373](https://github.com/OpenHands/software-agent-sdk/issues/5373) (`F26.remote-backend`): A self-hosted Agent Server using default Rich traceback logging can wedge its event loop and grow memory when a websocket error has a cyclic/deep exception chain; Canvas then times out or disconnects. The npm launcher forces JSON logging, so rechecking needs a separately hosted default-Rich backend plus the reported pathological peer/backlog reproducer and bounded memory/time isolation. That original peer/driver is not included in the issue body; do not synthesize a server patch or stress the shared host and claim a live UI reproduction."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F26--seeded-local-backend",
          "title": "Seeded Local backend",
          "ids": [
            "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}`.",
          "raw": "**Seeded Local backend (`F26.seeded-local-backend`).** 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}`.",
          "source_line": 85,
          "expected": [
            "the first load of a launcher-served UI seeds one backend `Local` (id `default-local`) with the page origin and the injected key."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--rotated-session-key",
          "title": "Rotated session key",
          "ids": [
            "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.",
          "raw": "**Rotated session key (`F26.session-key-rotated`).** 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.",
          "source_line": 86,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--managed-agents-bridge",
          "title": "Managed Agents bridge",
          "ids": [
            "F26.mars-web-bridge"
          ],
          "body": "With both partial stacks up: `curl -s \"$(control-openhands status | jq -r .baseUrl)/mars/health\"`, `curl -s http://127.0.0.1:18960/mars/health` and `curl -s http://127.0.0.1:18970/mars/health` each print `{\"ok\":true,\"authRequired\":true}`, while `curl -s \"http://127.0.0.1:$(control-openhands status | jq -r .ports.frontend)/mars/health\" | head -c 15` prints `<!DOCTYPE html>` (the static child behind your run's ingress has no second bridge). `curl -s -o /dev/null -w '%{http_code}\\n' -X POST http://127.0.0.1:18960/mars/rpc/getAuthState` is `401` (no key). The frontend-only page has the bridge but no key to use it: after the cross-connect, `control-openhands browser eval \"({marsBridge: !!window.marsBridge, key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__)})\"` on `http://127.0.0.1:18970/` is `{marsBridge: true, key: false}` (what that does to DigitalOcean sign-in is F25's `F25.digitalocean-sign-in` known failure).",
          "raw": "**Managed Agents bridge (`F26.mars-web-bridge`).** With both partial stacks up: `curl -s \"$(control-openhands status | jq -r .baseUrl)/mars/health\"`, `curl -s http://127.0.0.1:18960/mars/health` and `curl -s http://127.0.0.1:18970/mars/health` each print `{\"ok\":true,\"authRequired\":true}`, while `curl -s \"http://127.0.0.1:$(control-openhands status | jq -r .ports.frontend)/mars/health\" | head -c 15` prints `<!DOCTYPE html>` (the static child behind your run's ingress has no second bridge). `curl -s -o /dev/null -w '%{http_code}\\n' -X POST http://127.0.0.1:18960/mars/rpc/getAuthState` is `401` (no key). The frontend-only page has the bridge but no key to use it: after the cross-connect, `control-openhands browser eval \"({marsBridge: !!window.marsBridge, key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__)})\"` on `http://127.0.0.1:18970/` is `{marsBridge: true, key: false}` (what that does to DigitalOcean sign-in is F25's `F25.digitalocean-sign-in` known failure).",
          "source_line": 87,
          "expected": [
            "every launcher-served web stack hosts the DigitalOcean Managed Agents bridge on its ingress by default (`MARS_WEB=0` opts out): `/mars/health` answers `{\"ok\":true,\"authRequired\":true}` on a full, `--backend-only`, `--frontend-only` and `--host 0.0.0.0` launcher, `/mars/rpc/*` answers `401` without the session key and works with it, the static child behind the ingress hosts none, and the Electron stack hosts none either (its preload provides `window.marsBridge`)."
          ],
          "notes": [],
          "annotations": [
            "known failure)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--stop-the-partial-stacks",
          "title": "Stop the partial stacks",
          "ids": [],
          "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.",
          "raw": "**Stop the partial stacks.** `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.",
          "source_line": 88,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        },
        {
          "name": "F26--lan-bind",
          "title": "LAN bind",
          "ids": [
            "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); the bridge is mounted behind the key (`F26.mars-web-bridge`): `curl -s http://127.0.0.1:18960/mars/health` is `{\"ok\":true,\"authRequired\":true}`, and `POST /mars/rpc/getAuthState` with `-H 'Content-Type: application/json' -d '{\"args\":[]}'` is `401` without and `200` with `-H 'X-Session-API-Key: qa-f26-lan-key'`; `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. A few seconds later the telemetry consent dialog for the new `QA_LAN` backend opens and blocks clicks; answer it (`role=button[name=\"Confirm preferences\"]`) before driving that page further. Stop it with `kill -TERM $(cat $Q/pid)`.",
          "raw": "**LAN bind (`F26.host-bind-lan`).** 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); the bridge is mounted behind the key (`F26.mars-web-bridge`): `curl -s http://127.0.0.1:18960/mars/health` is `{\"ok\":true,\"authRequired\":true}`, and `POST /mars/rpc/getAuthState` with `-H 'Content-Type: application/json' -d '{\"args\":[]}'` is `401` without and `200` with `-H 'X-Session-API-Key: qa-f26-lan-key'`; `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. A few seconds later the telemetry consent dialog for the new `QA_LAN` backend opens and blocks clicks; answer it (`role=button[name=\"Confirm preferences\"]`) before driving that page further. Stop it with `kill -TERM $(cat $Q/pid)`.",
          "source_line": 89,
          "expected": [
            "`--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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "not-run"
          ]
        },
        {
          "name": "F26--lan-opt-in",
          "title": "LAN opt-in",
          "ids": [
            "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)`.",
          "raw": "**LAN opt-in (`F26.host-bind-lan-optin`).** 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)`.",
          "source_line": 90,
          "expected": [
            "the opt-in the launcher's warning recommends (`--allow-lan-session-key`) should restore key injection on a LAN bind."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-08): the npm launcher ignores the flag and the page still carries only `__AGENT_CANVAS_AUTH_REQUIRED__`; [#17949](https://github.com/OpenHands/OpenHands/issues/17949)."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--generated-session-key-persists",
          "title": "Generated session key persists",
          "ids": [
            "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.",
          "raw": "**Generated session key persists (`F26.session-key-persist`).** 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.",
          "source_line": 91,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--pinned-session-key",
          "title": "Pinned session key",
          "ids": [
            "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}`.",
          "raw": "**Pinned session key (`F26.session-key-pinned`).** 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}`.",
          "source_line": 92,
          "expected": [
            "`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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--bind-host-from-the-environment",
          "title": "Bind host from the environment",
          "ids": [
            "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.",
          "raw": "**Bind host from the environment (`F26.host-bind-env`).** 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.",
          "source_line": 93,
          "expected": [
            "`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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--frontend-only-returning-user",
          "title": "Frontend-only, returning user",
          "ids": [
            "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`.",
          "raw": "**Frontend-only, returning user (`F26.frontend-only-returning`).** 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`.",
          "source_line": 94,
          "expected": [
            "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 ("
          ],
          "notes": [
            "Which screen a frontend-only origin shows depends on the browser, not the launcher: a profile new to the origin gets **Add a backend** (first run owns the initial backend), while a profile that stores a backend for that origin (the `default-local` entry a full launcher on the same port seeded, or any backend added there earlier) gets the recovery gate of `F25.recovery-gate` as soon as that backend answers 503 (`F26.frontend-only-returning`). The no-toast check belongs to the first-run state only: on the gate, `browser toasts --history` lists generic `An error occurred` status toasts raised by the failing 503 probes (two within 12 s here), with no hint of which request failed. Known failure (reproduced 2026-10-08): both toasts come from the free-models hydrator's `GET /api/llm/models/verified` 503, which no `meta.disableToast` guards on that screen (nor does `backend-version.tsx`); [#18160](https://github.com/OpenHands/OpenHands/issues/18160)."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-08: [#18160](https://github.com/OpenHands/OpenHands/issues/18160))."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--automation-exits-during-startup",
          "title": "Automation exits during startup",
          "ids": [
            "F26.automation-startup-failure"
          ],
          "body": "With 18960–18963 free, start a loopback launcher whose automation cannot install: `Q=$OH_VERIFY_RUN/private/f26-autofail; 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 OH_AUTOMATION_VERSION=0.0.1 LOCAL_BACKEND_API_KEY=qa-f26-autofail-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid`, then poll the log until `Main UI` appears or the pid exits. Expected (#18362): no `Main UI` banner, a message naming the automation startup failure, a nonzero exit and closed ports. Known failure (reproduced 2026-10-13 twice, with `0.0.1` and `0.0.0-qa-missing`): the log shows `[automation] ... No solution found ...` and `[automation] Exited with code 1` (code 2 for the unparsable version), then `Main UI: http://localhost:18960/` two seconds later; `kill -0 $(cat $Q/pid)` succeeds, `curl http://127.0.0.1:18960/` is `200` and `/api/automation/health` (with the key) is `502`. Stop it with `kill -TERM $(cat $Q/pid)`; the ports free within seconds. The #18299 migration failure in the issue is the same path through a different exit cause.",
          "raw": "**Automation exits during startup (`F26.automation-startup-failure`).** With 18960–18963 free, start a loopback launcher whose automation cannot install: `Q=$OH_VERIFY_RUN/private/f26-autofail; 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 OH_AUTOMATION_VERSION=0.0.1 LOCAL_BACKEND_API_KEY=qa-f26-autofail-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid`, then poll the log until `Main UI` appears or the pid exits. Expected (#18362): no `Main UI` banner, a message naming the automation startup failure, a nonzero exit and closed ports. Known failure (reproduced 2026-10-13 twice, with `0.0.1` and `0.0.0-qa-missing`): the log shows `[automation] ... No solution found ...` and `[automation] Exited with code 1` (code 2 for the unparsable version), then `Main UI: http://localhost:18960/` two seconds later; `kill -0 $(cat $Q/pid)` succeeds, `curl http://127.0.0.1:18960/` is `200` and `/api/automation/health` (with the key) is `502`. Stop it with `kill -TERM $(cat $Q/pid)`; the ports free within seconds. The #18299 migration failure in the issue is the same path through a different exit cause.",
          "source_line": 95,
          "expected": [
            "when the requested automation backend exits before it is ready, the launcher should print no success banner, stop the services it started and exit nonzero with the automation's exit reason."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-13 twice, with `0.0.1` and `0.0.0-qa-missing`): the log shows `[automation] ... No solution found ...` and `[automation] Exited with code 1` (code 2 for the unparsable version), then `Main UI: http://localhost:18960/` two seconds later; `kill -0 $(cat $Q/pid)` succeeds, `curl http://127.0.0.1:18960/` is `200` and `/api/automation/health` (with the key) is `502`. Stop it with `kill -TERM $(cat $Q/pid)`; the ports free within seconds. The #18299 migration failure in the issue is the same path through a different exit cause.",
            "Known failure (reproduced 2026-10-13, #18362): it prints the **Main UI** banner and keeps the stack up with `/api/automation/health` answering `502`."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--runtime-services",
          "title": "Runtime services",
          "ids": [
            "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.",
          "raw": "**Runtime services (`F26.runtime-services`).** 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.",
          "source_line": 96,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.",
            "Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.",
            "Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.",
            "Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F26--the-agent-uses-the-runtime-services",
          "title": "The agent uses the runtime services",
          "ids": [
            "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.",
          "raw": "**The agent uses the runtime services (`F26.runtime-services-agent-use`).** 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.",
          "source_line": 97,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--library-build",
          "title": "Library build",
          "ids": [
            "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`.",
          "raw": "**Library build (`F26.lib-build`).** 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`.",
          "source_line": 99,
          "expected": [
            "`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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--style-scope",
          "title": "Style scope",
          "ids": [
            "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`).",
          "raw": "**Style scope (`F26.lib-style-scope`).** 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`).",
          "source_line": 100,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F26--blocked",
          "title": "Blocked",
          "ids": [
            "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 (without it `uv pip install` / `pip install` in the final stage fail with `CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain`), and Docker Hub may answer 429. Build a scratch copy of the Dockerfile that only adds the CA, never an edited `docker/Dockerfile`: after `FROM node:24-slim AS frontend-build` add `COPY --from=ccrca ca-bundle.crt /tmp/qa-ca.crt` and `ENV NODE_EXTRA_CA_CERTS=/tmp/qa-ca.crt npm_config_cafile=/tmp/qa-ca.crt`, after the first `USER root` add the same `COPY` and `ENV SSL_CERT_FILE=/tmp/qa-ca.crt PIP_CERT=/tmp/qa-ca.crt REQUESTS_CA_BUNDLE=/tmp/qa-ca.crt UV_NATIVE_TLS=1`, then `docker build -f <copy> --build-context ccrca=<dir holding ca-bundle.crt> --network host --build-arg AGENT_SERVER_IMAGE=<images.agentServer>:<versions.agentServer>-python --build-arg AGENT_SERVER_VERSION=<versions.agentServer> --build-arg AUTOMATION_VERSION=<versions.automation> --build-arg VITE_BASE_PATH=/canvas -t qa-agent-canvas:target .` (the pins `scripts/docker-build.mjs` reads from `config/defaults.json`; about 5 min on a warm cache). Publish it on a free port (`-p 127.0.0.1:<free>:8000`; 8000 and the 188xx blocks may belong to other runs). Docker Hub may answer `429 Too Many Requests` for `node:24-slim`; in the scratch copy only, replace both `FROM node:24-slim` lines with `FROM mirror.gcr.io/library/node:24-slim` (arrange; say so in the note). Run the unmodified image with `docker run -d --name <qa-name> -p 127.0.0.1:<free>:8000 qa-agent-canvas:target`. On a kernel without IPv6, `docker logs <qa-name>` shows `IPv6 is not available; binding the frontend to 0.0.0.0.` and `Static-server + proxy listening on http://0.0.0.0:8000/canvas/`, `/canvas/` answers `200` within about 25 s and `docker inspect -f '{{.State.Status}}' <qa-name>` is still `running` minutes later (#18178 fixed by #18180, verified 2026-10-13); `docker exec <qa-name> ls /opt/agent-canvas` lists the `mars-*.mjs` bridge modules (#18343). The container's generated key is `docker exec <qa-name> cat /home/openhands/.openhands/agent-canvas/api-key.txt` (write it to `$OH_VERIFY_RUN/private/`, fill with `--value-file`). Settings → Application (`/canvas/settings/app`) shows the image's `Version` (`agent-canvas-update-card`), which matches `jq -r .version package.json`. Remove the containers and the image afterwards (`docker rm -f`, `docker rmi`). 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`.",
          "raw": "**Blocked (`F26.lib-host-app`, `F26.docker-image`, `F26.docker-conversation-runtime`, `F26.helm-chart`).** 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 (without it `uv pip install` / `pip install` in the final stage fail with `CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain`), and Docker Hub may answer 429. Build a scratch copy of the Dockerfile that only adds the CA, never an edited `docker/Dockerfile`: after `FROM node:24-slim AS frontend-build` add `COPY --from=ccrca ca-bundle.crt /tmp/qa-ca.crt` and `ENV NODE_EXTRA_CA_CERTS=/tmp/qa-ca.crt npm_config_cafile=/tmp/qa-ca.crt`, after the first `USER root` add the same `COPY` and `ENV SSL_CERT_FILE=/tmp/qa-ca.crt PIP_CERT=/tmp/qa-ca.crt REQUESTS_CA_BUNDLE=/tmp/qa-ca.crt UV_NATIVE_TLS=1`, then `docker build -f <copy> --build-context ccrca=<dir holding ca-bundle.crt> --network host --build-arg AGENT_SERVER_IMAGE=<images.agentServer>:<versions.agentServer>-python --build-arg AGENT_SERVER_VERSION=<versions.agentServer> --build-arg AUTOMATION_VERSION=<versions.automation> --build-arg VITE_BASE_PATH=/canvas -t qa-agent-canvas:target .` (the pins `scripts/docker-build.mjs` reads from `config/defaults.json`; about 5 min on a warm cache). Publish it on a free port (`-p 127.0.0.1:<free>:8000`; 8000 and the 188xx blocks may belong to other runs). Docker Hub may answer `429 Too Many Requests` for `node:24-slim`; in the scratch copy only, replace both `FROM node:24-slim` lines with `FROM mirror.gcr.io/library/node:24-slim` (arrange; say so in the note). Run the unmodified image with `docker run -d --name <qa-name> -p 127.0.0.1:<free>:8000 qa-agent-canvas:target`. On a kernel without IPv6, `docker logs <qa-name>` shows `IPv6 is not available; binding the frontend to 0.0.0.0.` and `Static-server + proxy listening on http://0.0.0.0:8000/canvas/`, `/canvas/` answers `200` within about 25 s and `docker inspect -f '{{.State.Status}}' <qa-name>` is still `running` minutes later (#18178 fixed by #18180, verified 2026-10-13); `docker exec <qa-name> ls /opt/agent-canvas` lists the `mars-*.mjs` bridge modules (#18343). The container's generated key is `docker exec <qa-name> cat /home/openhands/.openhands/agent-canvas/api-key.txt` (write it to `$OH_VERIFY_RUN/private/`, fill with `--value-file`). Settings → Application (`/canvas/settings/app`) shows the image's `Version` (`agent-canvas-update-card`), which matches `jq -r .version package.json`. Remove the containers and the image afterwards (`docker rm -f`, `docker rmi`). 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`.",
          "source_line": 101,
          "expected": [
            "mounted in a separate host app, Canvas styles stay inside the scope and `styleOverrides` restyle it. Blocked: no host-app example exists.",
            "`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. On a host whose kernel has no IPv6 (no `/proc/net/if_inet6`) the entrypoint logs `IPv6 is not available; binding the frontend to 0.0.0.0.` and the container keeps running (#18180).",
            "`OH_CONVERSATION_RUNTIME=docker` runs each new conversation in its own container. Blocked without a Docker daemon.",
            "`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."
          ],
          "notes": [
            "Known issue [#17763](https://github.com/OpenHands/OpenHands/issues/17763) (`F26.docker-image`): The Docker entrypoint can leave protected Agent Server APIs unauthenticated when only `LOCAL_BACKEND_API_KEY` is supplied, because its export to `OH_SESSION_API_KEYS_0` is inside the generate-key branch. Recheck an owned container with a dummy custom key: unauthenticated `GET /api/conversations/search` must be 401 and the same request with that dummy key 200. `/server_info` is intentionally public and is not an auth probe. Blocked here without a Docker daemon.",
            "Known issue [#17623](https://github.com/OpenHands/OpenHands/issues/17623) (`F26.helm-chart`): The chart defaults service-account-token automounting to true even with RBAC disabled, exposing an unused cluster credential to agent processes. Recheck a disposable Kubernetes installation using the default values and an explicit opt-in; inspect token existence/readability only, never its contents. Blocked without Helm and a Kubernetes cluster.",
            "Known issue [#17470](https://github.com/OpenHands/OpenHands/issues/17470) (`F26.docker-image`): On Apple Silicon under Colima/Virtualization.framework, the image's Python services can exit with SIGILL while the frontend leaves onboarding at Disconnected. The report isolates `import litellm` and compares `OPENSSL_armcap=0`; recheck on the affected ARM/Colima guest with the reported cryptography/OpenSSL combination. A native macOS npm stack is not that container environment.",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16511](https://github.com/OpenHands/OpenHands/issues/16511) (`F26.docker-image`): The documented Docker bind mount can be unwritable to container uid 10001, killing Agent Server/automation while the frontend still serves Bad Gateway. Recheck a disposable host-owned mode-700 mount and a writable control against an owned image; never chmod the operator's real state directory. Blocked here without Docker.",
            "Known issue [OpenHands/software-agent-sdk#5310](https://github.com/OpenHands/software-agent-sdk/issues/5310) (`F26.docker-conversation-runtime`): A Docker conversation can lose the auxiliary title profile at the runtime boundary, leaving an ACP conversation untitled or producing an unrelated title-auth error while the agent still works. Recheck a saved reachable title profile and an ACP conversation inside an owned per-conversation Docker runtime, comparing its title/profile metadata with the host; inspect only profile names and credential-presence booleans. Blocked without Docker and real auxiliary-title/ACP credentials.",
            "Known issue [OpenHands/software-agent-sdk#5307](https://github.com/OpenHands/software-agent-sdk/issues/5307) (`F26.docker-conversation-runtime`): New worktree on a Docker-backed conversation can create git metadata whose reverse path exists only inside the container, so the still-live host worktree reads `prunable gitdir file points to non-existent location`. Recheck a disposable real git repository mounted into an owned conversation container, comparing host and container `git worktree list`/status while it is alive; a local worktree does not test the filesystem boundary. Blocked without Docker."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F26--desktop-splash-and-window",
          "title": "Desktop splash and window",
          "ids": [
            "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). While it runs, `curl -s http://127.0.0.1:8000/mars/health | head -c 15` prints `<!DOCTYPE html>`: the desktop stack's ingress hosts no bridge (`marsWebBridge: false`, `F26.mars-web-bridge`). 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.",
          "raw": "**Desktop splash and window (`F26.desktop-boot-splash`, `F26.desktop-main-window`).** 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). While it runs, `curl -s http://127.0.0.1:8000/mars/health | head -c 15` prints `<!DOCTYPE html>`: the desktop stack's ingress hosts no bridge (`marsWebBridge: false`, `F26.mars-web-bridge`). 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.",
          "source_line": 102,
          "expected": [
            "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**).",
            "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)."
          ],
          "notes": [
            "Known issue [#17112](https://github.com/OpenHands/OpenHands/issues/17112) (`F26.desktop-main-window` `F26.desktop-external-links`): Desktop main-window navigation, an OAuth popup redirect, or a nested popup can render a remote page inside the app frame. Recheck all three on an owned running Electron shell with a harmless remote page and loopback redirect fixture; the browser harness cannot operate Electron windows and the installed Electron binary is unavailable here.",
            "Known issue [#16774](https://github.com/OpenHands/OpenHands/issues/16774) (`F26.desktop-boot-splash` `F26.desktop-main-window`): The macOS 1.14.0 DMG installs an app that Gatekeeper reports as damaged instead of booting. Recheck the actual downloaded signed/notarized release artifact through install and first launch on macOS; starting source Electron or removing quarantine would bypass the reported failure. That release DMG/signature fixture is unavailable in this run."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F26--external-links",
          "title": "External links",
          "ids": [
            "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).",
          "raw": "**External links (`F26.desktop-external-links`).** Not driven: record `not-run` (harness gap: no verb clicks inside the Electron window or observes the OS browser handoff).",
          "source_line": 103,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17112](https://github.com/OpenHands/OpenHands/issues/17112) (`F26.desktop-main-window` `F26.desktop-external-links`): Desktop main-window navigation, an OAuth popup redirect, or a nested popup can render a remote page inside the app frame. Recheck all three on an owned running Electron shell with a harmless remote page and loopback redirect fixture; the browser harness cannot operate Electron windows and the installed Electron binary is unavailable here.",
            "Known issue [#16872](https://github.com/OpenHands/OpenHands/issues/16872) (`F26.desktop-external-links`): Non-browser URI schemes were reported to reach the OS handler from the desktop popup flow. Current source now parses hosts and restricts external protocols; recheck refusal in a running owned Electron shell before concluding resolution. Observe an inert denied test scheme without invoking any real OS handler. The browser harness cannot drive Electron and no installed Electron binary is available here."
          ],
          "annotations": [],
          "states": [
            "issue",
            "not-run"
          ]
        },
        {
          "name": "F26--kv-survives-authentication-rotation",
          "title": "KV survives authentication rotation",
          "ids": [
            "F26.session-key-rotated",
            "F26.session-key-persist"
          ],
          "body": "With no explicit `AUTOMATION_KV_SECRET`, create an owned inert automation and PUT a harmless marker to `/api/automation/v1/kv/qa-session-rotation?automation_id=<id>` through `api --write`; GET must return that marker. Run `restart --rotate-key` and `doctor`, then GET again using the helper's fresh private key. Expected: rotating authentication preserves encrypted automation state. Known failure, reproduced 2026-10-12 against automation 1.19.3: the pre-rotation read is 200 and the fresh-auth read is 500 `Failed to decrypt value` ([#18301](https://github.com/OpenHands/OpenHands/issues/18301)). This uses only disposable state and does not establish the original incident's data-loss history.",
          "raw": "**KV survives authentication rotation (`F26.session-key-rotated`, `F26.session-key-persist`).** With no explicit `AUTOMATION_KV_SECRET`, create an owned inert automation and PUT a harmless marker to `/api/automation/v1/kv/qa-session-rotation?automation_id=<id>` through `api --write`; GET must return that marker. Run `restart --rotate-key` and `doctor`, then GET again using the helper's fresh private key. Expected: rotating authentication preserves encrypted automation state. Known failure, reproduced 2026-10-12 against automation 1.19.3: the pre-rotation read is 200 and the fresh-auth read is 500 `Failed to decrypt value` ([#18301](https://github.com/OpenHands/OpenHands/issues/18301)). This uses only disposable state and does not establish the original incident's data-loss history.",
          "source_line": 105,
          "expected": [
            "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).",
            "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."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12 against automation 1.19.3: the pre-rotation read is 200 and the fresh-auth read is 500 `Failed to decrypt value` ([#18301](https://github.com/OpenHands/OpenHands/issues/18301)). This uses only disposable state and does not establish the original incident's data-loss history."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--partial-sqlite-migration-startup",
          "title": "Partial SQLite migration startup",
          "ids": [
            "F26.runtime-services"
          ],
          "body": "Stop only the owned automation service, make a cold backup of `<run>/private/automation/automations.db`, and confirm its `automation_git_sync_org_config` already has `consecutive_auth_failures`. In that disposable DB only, set `alembic_version.version_num` to `028`; restart with a bounded timeout, then open `/automations`. Expected: required-service startup fails explicitly or the partial schema recovers without corrupting state. Known failure, reproduced 2026-10-12 against automation 1.19.3: migration 029 reports the duplicate column and exits 3, the launcher remains alive, and Canvas shows **Automations Unavailable** ([#18299](https://github.com/OpenHands/OpenHands/issues/18299)). Restore the cold backup, restart and doctor before continuing. Never apply this fixture to an operator database.",
          "raw": "**Partial SQLite migration startup (`F26.runtime-services`).** Stop only the owned automation service, make a cold backup of `<run>/private/automation/automations.db`, and confirm its `automation_git_sync_org_config` already has `consecutive_auth_failures`. In that disposable DB only, set `alembic_version.version_num` to `028`; restart with a bounded timeout, then open `/automations`. Expected: required-service startup fails explicitly or the partial schema recovers without corrupting state. Known failure, reproduced 2026-10-12 against automation 1.19.3: migration 029 reports the duplicate column and exits 3, the launcher remains alive, and Canvas shows **Automations Unavailable** ([#18299](https://github.com/OpenHands/OpenHands/issues/18299)). Restore the cold backup, restart and doctor before continuing. Never apply this fixture to an operator database.",
          "source_line": 106,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.",
            "Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.",
            "Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.",
            "Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 against automation 1.19.3: migration 029 reports the duplicate column and exits 3, the launcher remains alive, and Canvas shows **Automations Unavailable** ([#18299](https://github.com/OpenHands/OpenHands/issues/18299)). Restore the cold backup, restart and doctor before continuing. Never apply this fixture to an operator database."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F26--occupied-internal-frontend-port",
          "title": "Occupied internal frontend port",
          "ids": [
            "F26.cli-port-in-use"
          ],
          "body": "Reserve a loopback frontend port in an owned listener and run the genuine launcher with separate free ingress/backend/automation ports. The reported default-port branch can also be observed read-only when port 3001 is already occupied; do not stop its owner. Expected: an occupied internal frontend port receives a free fallback while explicit service-port conflicts keep their existing semantics. Known failure, reproduced 2026-10-12: `agent-canvas --port 18940` exits 1 with `frontend: port 3001`, despite the ingress override ([#17546](https://github.com/OpenHands/OpenHands/issues/17546), [#16226](https://github.com/OpenHands/OpenHands/issues/16226)). An explicit `OH_CANVAS_SAFE_VITE_PORT` is a current workaround; it does not provide automatic fallback.",
          "raw": "**Occupied internal frontend port (`F26.cli-port-in-use`).** Reserve a loopback frontend port in an owned listener and run the genuine launcher with separate free ingress/backend/automation ports. The reported default-port branch can also be observed read-only when port 3001 is already occupied; do not stop its owner. Expected: an occupied internal frontend port receives a free fallback while explicit service-port conflicts keep their existing semantics. Known failure, reproduced 2026-10-12: `agent-canvas --port 18940` exits 1 with `frontend: port 3001`, despite the ingress override ([#17546](https://github.com/OpenHands/OpenHands/issues/17546), [#16226](https://github.com/OpenHands/OpenHands/issues/16226)). An explicit `OH_CANVAS_SAFE_VITE_PORT` is a current workaround; it does not provide automatic fallback.",
          "source_line": 107,
          "expected": [
            "an occupied ingress (or service) port stops the launch with `Cannot start: the following ports are already in use` naming each busy port."
          ],
          "notes": [],
          "annotations": [
            "Known failure, reproduced 2026-10-12: `agent-canvas --port 18940` exits 1 with `frontend: port 3001`, despite the ingress override ([#17546](https://github.com/OpenHands/OpenHands/issues/17546), [#16226](https://github.com/OpenHands/OpenHands/issues/16226)). An explicit `OH_CANVAS_SAFE_VITE_PORT` is a current workaround; it does not provide automatic fallback."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F26--required-frontend-death",
          "title": "Required frontend death",
          "ids": [
            "F26.runtime-services"
          ],
          "body": "After a healthy private launch, identify only its required static-server PID using launcher ancestry plus its owned frontend port, then send that child SIGKILL. Open `/` and check the owned launcher's liveness and authenticated `/api/settings`. Expected: an unexpected required-service death stops the stack and reports failure. Known failure, reproduced 2026-10-12: the launcher and backend remain alive, settings returns 200, and the main UI returns 502 **Bad Gateway** ([#16927](https://github.com/OpenHands/OpenHands/issues/16927)). This live trigger is SIGKILL, not a port-race EADDRINUSE; the failing required-child exit branch is shared. Restart and doctor to restore the run.",
          "raw": "**Required frontend death (`F26.runtime-services`).** After a healthy private launch, identify only its required static-server PID using launcher ancestry plus its owned frontend port, then send that child SIGKILL. Open `/` and check the owned launcher's liveness and authenticated `/api/settings`. Expected: an unexpected required-service death stops the stack and reports failure. Known failure, reproduced 2026-10-12: the launcher and backend remain alive, settings returns 200, and the main UI returns 502 **Bad Gateway** ([#16927](https://github.com/OpenHands/OpenHands/issues/16927)). This live trigger is SIGKILL, not a port-race EADDRINUSE; the failing required-child exit branch is shared. Restart and doctor to restore the run.",
          "source_line": 108,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.",
            "Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.",
            "Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.",
            "Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: the launcher and backend remain alive, settings returns 200, and the main UI returns 502 **Bad Gateway** ([#16927](https://github.com/OpenHands/OpenHands/issues/16927)). This live trigger is SIGKILL, not a port-race EADDRINUSE; the failing required-child exit branch is shared. Restart and doctor to restore the run."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F26--static-server-credential-argv",
          "title": "Static-server credential argv",
          "ids": [
            "F26.runtime-services"
          ],
          "body": "Inspect only this run's owned static-server process for the frontend port. Read the private key in memory and retain booleans indicating whether `--session-api-key` is present and whether that literal key occurs in argv; never print argv or the key. Expected: other host users cannot recover the session key from command-line arguments. Known failure, reproduced 2026-10-12: both booleans are true for the owned static frontend ([#16383](https://github.com/OpenHands/OpenHands/issues/16383)). Docker remains a separate unavailable prerequisite here.",
          "raw": "**Static-server credential argv (`F26.runtime-services`).** Inspect only this run's owned static-server process for the frontend port. Read the private key in memory and retain booleans indicating whether `--session-api-key` is present and whether that literal key occurs in argv; never print argv or the key. Expected: other host users cannot recover the session key from command-line arguments. Known failure, reproduced 2026-10-12: both booleans are true for the owned static frontend ([#16383](https://github.com/OpenHands/OpenHands/issues/16383)). Docker remains a separate unavailable prerequisite here.",
          "source_line": 109,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.",
            "Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.",
            "Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.",
            "Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: both booleans are true for the owned static frontend ([#16383](https://github.com/OpenHands/OpenHands/issues/16383)). Docker remains a separate unavailable prerequisite here."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F26--keep-alive-proxy-timeout-listeners",
          "title": "Keep-alive proxy timeout listeners",
          "ids": [
            "F26.runtime-services"
          ],
          "body": "Through the owned ingress, send 20 sequential authenticated `/api/conversations/search?limit=1` requests on one HTTP keep-alive connection, recording response statuses and the reused local socket port, then inspect only the owned ingress's warning line. Expected: one socket does not acquire one timeout listener per request. Known failure, reproduced 2026-10-12: all 20 requests are 200 on the same socket and ingress emits `MaxListenersExceededWarning` for 11 timeout listeners ([#16839](https://github.com/OpenHands/OpenHands/issues/16839)). `/server_info` uses a custom enrichment handler here, so it is not the generic proxy branch. Keep header values and full logs private.",
          "raw": "**Keep-alive proxy timeout listeners (`F26.runtime-services`).** Through the owned ingress, send 20 sequential authenticated `/api/conversations/search?limit=1` requests on one HTTP keep-alive connection, recording response statuses and the reused local socket port, then inspect only the owned ingress's warning line. Expected: one socket does not acquire one timeout listener per request. Known failure, reproduced 2026-10-12: all 20 requests are 200 on the same socket and ingress emits `MaxListenersExceededWarning` for 11 timeout listeners ([#16839](https://github.com/OpenHands/OpenHands/issues/16839)). `/server_info` uses a custom enrichment handler here, so it is not the generic proxy branch. Keep header values and full logs private.",
          "source_line": 110,
          "expected": [
            "`/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."
          ],
          "notes": [
            "Known issue [#16720](https://github.com/OpenHands/OpenHands/issues/16720) (`F26.runtime-services`): CPU-heavy commands in several conversations can starve Agent Server and stop the UI loading; the report requests lower priority for agent commands than the service. Recheck with a disposable bounded workload, recorded CPU/resource limits and real command observations, never arbitrary stress on the shared machine. The exact Rust workload and affected resource envelope are missing, and real agent execution needs a model key. The backend counterpart is [OpenHands/software-agent-sdk#4575](https://github.com/OpenHands/software-agent-sdk/issues/4575).",
            "Known issue [#16690](https://github.com/OpenHands/OpenHands/issues/16690) (`F26.docker-image` `F26.runtime-services`): A Docker deployment with nine chats becomes slow/disconnected, then reloads lose the list and startup proxies report ECONNREFUSED. Recheck on the affected Windows-host Docker configuration with retained conversation state and a controlled workload; creating a few empty local chats cannot prove the reported resource/runtime failure. This run lacks Docker and the original conversation/workload fixture.",
            "Known issue [#16300](https://github.com/OpenHands/OpenHands/issues/16300) (`F26.runtime-services`): The npm launcher can fail while building LiteLLM native dependencies when resolved AWS crates require a newer Rust compiler, leaving backend services unavailable. Recheck an isolated uncached uv environment with `litellm==1.95.0`, the reported Rust 1.93.1/toolchain/platform and dependency resolution; a warm current backend cache cannot exercise that install failure. The historical dependency/toolchain fixture is unavailable.",
            "Known issue [#16208](https://github.com/OpenHands/OpenHands/issues/16208) (`F26.runtime-services`): On Fedora Rawhide/Python 3.14.6, settings work but starting any conversation returns HTTP 500. Recheck a native Fedora environment with the reported SDK/runtime and model/profile, including the underlying server traceback; this macOS current-runtime stack cannot prove that platform-specific failure.",
            "Known issue [OpenHands/software-agent-sdk#4901](https://github.com/OpenHands/software-agent-sdk/issues/4901) (`F26.runtime-services`): closing an ACP conversation on POSIX is reported to leave the adapter's descendant Node/CLI processes alive after the top npm process exits. Recheck an owned authenticated ACP provider command chain, record its exact descendant identities before close and inspect only those identities afterwards; cleanup must target those owned groups. The current run has no authorized authenticated ACP runtime for this path.",
            "Known issue [OpenHands/software-agent-sdk#5974](https://github.com/OpenHands/software-agent-sdk/issues/5974) (`F26.runtime-services`; workspace picker in F03): a saved-workspace mutation waiting for its registry file lock can block the Agent Server event loop, including `/alive`, until the lock is released. Recheck only an owned registry using a bounded lock holder, an independently confirmed waiting mutation, concurrent liveness probes and a successful post-release readback; the original evidence uses Linux kernel lock-wait readback. A healthy idle server or concurrent writes without deliberate lock contention do not cover it."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12: all 20 requests are 200 on the same socket and ingress emits `MaxListenersExceededWarning` for 11 timeout listeners ([#16839](https://github.com/OpenHands/OpenHands/issues/16839)). `/server_info` uses a custom enrichment handler here, so it is not the generic proxy branch. Keep header values and full logs private."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F26--default-rich-logging-responsiveness",
          "title": "Default Rich logging responsiveness",
          "ids": [
            "F26.remote-backend"
          ],
          "body": "Temporarily replace only an owned Agent Server with its same pinned interpreter/state on the same port; unset `LOG_JSON`, `CI`, `GITHUB_ACTIONS` and `LOG_RICH_TRACEBACKS`, and retain its PID for cleanup. Create a bare-tool conversation whose dummy LLM points at closed `http://127.0.0.1:9/v1` with zero retries. Start 60 direct `/alive` probes concurrently with a genuine Canvas **Send message**; inspect the rendered connection error and final conversation status. Expected: every probe stays below 1.5 s during failure. Known failure, reproduced 2026-10-12 on Agent Server 1.54.0: the conversation ends `error` and one health probe stalls 2.091 s ([OpenHands/software-agent-sdk#5633](https://github.com/OpenHands/software-agent-sdk/issues/5633)). This is a finite connection-error case, separate from the unprovided cyclic websocket fixture in SDK #5373. Stop that owned replacement, restore the normal launcher and doctor.",
          "raw": "**Default Rich logging responsiveness (`F26.remote-backend`).** Temporarily replace only an owned Agent Server with its same pinned interpreter/state on the same port; unset `LOG_JSON`, `CI`, `GITHUB_ACTIONS` and `LOG_RICH_TRACEBACKS`, and retain its PID for cleanup. Create a bare-tool conversation whose dummy LLM points at closed `http://127.0.0.1:9/v1` with zero retries. Start 60 direct `/alive` probes concurrently with a genuine Canvas **Send message**; inspect the rendered connection error and final conversation status. Expected: every probe stays below 1.5 s during failure. Known failure, reproduced 2026-10-12 on Agent Server 1.54.0: the conversation ends `error` and one health probe stalls 2.091 s ([OpenHands/software-agent-sdk#5633](https://github.com/OpenHands/software-agent-sdk/issues/5633)). This is a finite connection-error case, separate from the unprovided cyclic websocket fixture in SDK #5373. Stop that owned replacement, restore the normal launcher and doctor.",
          "source_line": 111,
          "expected": [
            "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`)."
          ],
          "notes": [
            "Known issue [#15434](https://github.com/OpenHands/OpenHands/issues/15434) (`F26.remote-backend`): The embedded VS Code action on a self-hosted reverse-proxy origin was reported to reopen Canvas because its advertised URL omitted the separate editor port/path. Current launchers include an ingress `/vscode/` route; recheck against the affected HTTPS proxy deployment with an actually available editor binary and emitted URL before concluding resolution. This run lacks that self-hosted proxy/editor deployment.",
            "Known issue [OpenHands/software-agent-sdk#5373](https://github.com/OpenHands/software-agent-sdk/issues/5373) (`F26.remote-backend`): A self-hosted Agent Server using default Rich traceback logging can wedge its event loop and grow memory when a websocket error has a cyclic/deep exception chain; Canvas then times out or disconnects. The npm launcher forces JSON logging, so rechecking needs a separately hosted default-Rich backend plus the reported pathological peer/backlog reproducer and bounded memory/time isolation. That original peer/driver is not included in the issue body; do not synthesize a server patch or stress the shared host and claim a live UI reproduction."
          ],
          "annotations": [
            "Known failure, reproduced 2026-10-12 on Agent Server 1.54.0: the conversation ends `error` and one health probe stalls 2.091 s ([OpenHands/software-agent-sdk#5633](https://github.com/OpenHands/software-agent-sdk/issues/5633)). This is a finite connection-error case, separate from the unprovided cyclic websocket fixture in SDK #5373. Stop that owned replacement, restore the normal launcher and doctor."
          ],
          "states": [
            "failure",
            "issue"
          ]
        },
        {
          "name": "F26--contract-f26-desktop-macos-titlebar",
          "title": "Desktop macos titlebar",
          "ids": [
            "F26.desktop-macos-titlebar"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "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)."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "blocked"
          ]
        }
      ]
    },
    {
      "id": "F27",
      "title": "Workspace tools: terminal, browser, planner, tasks and usage",
      "file": "F27-workspace-tools.md",
      "sha256": "69a3546b2daee179d2f49f47eff18b1107bfc419e17f88873e875c0c5996c12a",
      "overview": "The conversation's right-hand drawer holds the agent's workspace tools: a\nread-only Terminal that mirrors the agent's shell commands, a Browser tab that\nshows the agent browser tool's last screenshot, a Planner that renders\n`.agents_tmp/PLAN.md` (or starts a planning helper), a Task List that mirrors\nthe agent's `task_tracker`, and a Usage tab with the context meter, a manual\n\"Compact context\" action and token/cost totals. Around them sit the composer's\ncontext-window ring, the chat header's Overview column (peek, pinnable rows,\nsecondary drawer), and two client tools the agent can call to drive the UI:\n`canvas_ui_control` (open a tab or a file) and `launch_child_conversation`.",
      "entry": "- Conversation header panel button (`right-panel-toggle`, aria \"Show panel\"), then a drawer tab: `conversation-tab-files`, `-commits`, `-tasklist` (only after the agent used `task_tracker`), `-planner`, `-terminal`, `-browser`, `-usage`. Clicking the active tab closes the drawer (F08).\n- Drawer tabs menu: the \"...\" in `tabs-pane-header`, then `conversation-tabs-menu-open-<tab>` (also pin/unpin, F08).\n- Direct URL `/conversations/<id>` (drawer closed after load) or `/conversations/<id>/panel` (full-width drawer with Back at 1024 px or narrower, the phone entry, F07.panel-route; wider, it redirects to `/conversations/<id>` with the drawer open).\n- Composer context ring (`context-window-meter`) > Usage or bar; conversation \"...\" > Display Usage and Cost (F07.display-cost).\n- Chat header (i) (`conversation-overview-toggle`): hover for the peek, click for the Overview column and its \"...\" menu.\n- Composer `+` (`chat-plus-button`) > Show Available Skills / Hooks / Agent Tools & Metadata (also in the conversation \"...\" menu, F07).\n- The agent itself: `canvas_ui_control` (open a tab or file), `launch_child_conversation`, and file-path links in its messages (`markdown-file-path-link`).",
      "given": "Preconditions:\n\n- Baseline state (launched, doctored, `onboard --skip` done) and `control-openhands llm preset deepseek` (every populated state needs real agent turns).\n- Desktop viewport unless a bullet says phone.\n- `F27.browser-screenshot` is blocked when the agent server logs `Error preloading chromium` (no browser tools in Agent Tools & Metadata > Available Tools); it needs an agent server with working Chromium.\n- `F27.plugins-modal` needs a conversation started with at least one plugin (the menu item is hidden otherwise).\n- `F27.terminal-waiting` needs a conversation in the error state: arrange one with a profile whose key is invalid (no model cost; see the bullet).\n- `F27.usage-provider-balance` (card shown) needs an OpenRouter key; with DeepSeek only the hidden case is checkable.\n- `F27.overview-identity` needs a repo with a GitHub origin, its own so the shared `qa-repo` of F08 keeps none: `control-openhands fixture git-repo --name qa-f27-repo --remote https://github.com/qa-example/qa-f27-repo.git` (`--remote` only sets `origin`, also when the repo already exists, which the output reports as `remoteChanged`; no model is needed for the rows).",
      "gotchas": "- After a reload the Task List (and tab bar entry) appears only once history has loaded; wait about 2 s before counting `[data-active=\"true\"]` rows.\n- `[data-active]` is also used by sidebar conversation cards: always combine it with `has-text=<task title>`.\n- `browser click --observe` takes a plain CSS selector (`'[data-testid=compact-context-button]'`); the `testid=` form fails with a `querySelectorAll` SyntaxError.\n- \"Create a plan\" leaves that conversation in plan mode: the next composer message goes to the planner helper, which has no `launch_child_conversation` or `canvas_ui_control`. Use a separate conversation for client-tool prompts.\n- The child-launch toast's Open link animates; `browser click` on it times out with \"element is not stable\". Read the child id from the parent's `[child-conversation]` message instead.\n- `GET …/file/download?path=…/.agents_tmp/PLAN.md` 404 and `GET /api/llm/balance` 404 are expected app-origin errors (no plan yet; no balance endpoint). Count them in `browser errors --app-only` but do not report them.\n- Escape closes the composer context popover and the Overview \"...\" menu and returns focus to the trigger (#18044); an outside click, or a second click on the trigger, closes them too.\n- The Overview column is 28 px narrower than its content (`scrollWidth` 296, `clientWidth` 268). After some clicks inside it, Playwright's scroll-into-view shifts it and the card looks cut off on the left; `browser reload` restores it before taking screenshots.\n- The agent may summarize a client-tool action with its own title (for example \"Launch local child conversation to reply PONG\") instead of \"Launching a child conversation\"; assert the toast and events, not the chat title.\n- The Overview peek appears only while the drawer is open (`canPeekOnHover` needs `isRightPanelShown`); a `browser goto` or reload starts with the drawer closed.\n- The Overview's repo and branch rows depend on a bash probe (`useLocalGitInfo`) that runs only while the conversation's status is idle, running, waiting for confirmation or finished and the agent is not in error. Right after `conversation start` the probe has run and its result stays cached on the page, but on an errored conversation a reload hides the rows again, and `conversation pause` does not apply to an errored conversation. Read the rows before any `goto` or `reload`.\n- At phone width (and in a narrow drawer) the tab row shows only the tabs that fit; the rest are reachable only through the tabs menu (`conversation-tabs-menu-open-<tab>`), and which tabs fit depends on the active tab's label width.\n- Plain `agent-canvas` instances on one machine share the tmux socket `/tmp/tmux-<uid>/openhands` ([OpenHands/OpenHands#17946](https://github.com/OpenHands/OpenHands/issues/17946)), so another instance stopping can reset this one's terminal. `control-openhands launch` gives each run its own `TMUX_TMPDIR`, so in verification runs a reset means this run's agent server restarted.\n- Agent Tools & Metadata remembers the last tab (it reopens on Available Tools).\n- The Overview menu opens only Pull Requests (`conversation-overview-menu-open-git-pull_requests`); its testids are `conversation-overview-menu-open-*` and `-pin-*` for `workspace` and the Git rows only.\n- Known open issues: terminal history after reload ([#17566](https://github.com/OpenHands/OpenHands/issues/17566)) still fails (reproduced 2026-10-06); info modals at phone width (#17562) pass.\n- Agent tab requests (`open_tab`, `navigate_to_file`, chat file links) are saved per conversation like a user tab click (#18045): after the agent opens Terminal or a file and the page reloads, the drawer starts closed and the panel toggle reopens it on that tab; clicking that tab then closes the drawer. At phone width they navigate to `/conversations/<id>/panel`.\n- Known issue [#17774](https://github.com/OpenHands/OpenHands/issues/17774) (`F27.planner-empty` `F27.planner-plan`): Planner reads on a native Windows local backend reject drive-letter/mixed-separator workspace paths with `Conversation file path must stay inside the workspace` before a download request. Recheck both a scratch workspace and an opened drive-letter workspace, with and without `.agents_tmp/PLAN.md`; this run lacks a Windows local Agent Server.\n- Known issue [#17198](https://github.com/OpenHands/OpenHands/issues/17198) (`F27.terminal-output`): Native Windows desktop agents intermittently return `[Command finished execution with no output]` for real file-listing/terminal commands and retry them. Recheck with the affected Windows installer/runtime, project agent definitions and an exact command/workspace; no deterministic pattern or backend trace is provided, and this run has no Windows desktop runtime. The backend counterpart is [OpenHands/software-agent-sdk#4963](https://github.com/OpenHands/software-agent-sdk/issues/4963).\n- Known issue [#16551](https://github.com/OpenHands/OpenHands/issues/16551) (`F27.browser-screenshot`): On native Windows, the browser tool chooses system Chrome over installed Playwright Chromium and times out before returning state/screenshots. Recheck default browser selection and an explicit executable-path control on a Windows host with both browsers and the reported browser_use runtime; the verification Chromium itself is a different tool. Blocked here without Windows and real browser-tool execution credentials.\n- Known issue [#16581](https://github.com/OpenHands/OpenHands/issues/16581) (`F27.terminal-output`): A module-scoped terminal command index can suppress output in a second simultaneously mounted Terminal and reset the first when either unmounts. The report describes a future split-panel/embedded concurrent mount rather than a supported standalone entry point. Recheck in a host that actually mounts two independent Canvas Terminal consumers with real command histories; the current harness has one Terminal route and no such host fixture.\n- Known issue [#17384](https://github.com/OpenHands/OpenHands/issues/17384) (`F27.launch-child`): A file-based delegated subagent using a saved profile/provider connection can fail with HTTP 401 while the top-level conversation succeeds, because encrypted provider credentials are not decrypted in that backend factory path. Recheck a reviewed `.agents/agents/` worker, encrypted provider connection and saved profile using a real key, comparing parent and worker authentication. This is a delegated SDK subagent path, distinct from the client-tool child-launch recipe; neither a dummy key nor an unencrypted profile proves it.\n- Known issue [OpenHands/software-agent-sdk#5533](https://github.com/OpenHands/software-agent-sdk/issues/5533) (`F27.terminal-output`): A slow interactive Bash startup can make the first terminal command return early with empty output and a false successful exit code. Recheck both standalone and pooled tmux using a private `.bashrc` with `sleep 0.4`, then a bounded `sleep 2` first command and its real observation. The shell fixture can be arranged, but the mapped agent-command/Terminal path cannot run here without a model key.\n- Known issue [OpenHands/software-agent-sdk#5322](https://github.com/OpenHands/software-agent-sdk/issues/5322) (`F27.terminal-output`): The default tmux pane pool or Windows terminal can let `git log -p` start an interactive pager, so the next agent command receives stale paged output instead of its own result. Recheck a reviewed large git fixture, a real installed pager and a follow-up marker command on each affected terminal backend. The fixture is arrangeable, but the mapped agent-tool path needs a model key (and the Windows half needs Windows).\n- Known issue [#17383](https://github.com/OpenHands/OpenHands/issues/17383) (`F27.browser-screenshot`; dependency counterpart [OpenHands/software-agent-sdk#5152](https://github.com/OpenHands/software-agent-sdk/issues/5152)): unlocked dependency resolution can silently remove the agent's browser tools. The actual 2026-10-12 pinned runtime resolves browser-use 0.11.13, mcp 1.30.0 and fastmcp 3.4.8; its genuine BrowserToolExecutor constructs successfully. That narrowly fails to reproduce the SDK report's specified mcp 2.x import failure. Fresh constructor recheck on 2026-10-16 at `b34ac59`, using the actual Agent Server 1.55.0 interpreter and private HOME, again resolves browser-use 0.11.13, mcp 1.30.0 and fastmcp 3.4.8 and constructs successfully; no browser operation or model was run. The Canvas/global browser-operation path still needs an authorized real LLM and a genuine browser-enabled agent issuing a `browser_*` operation; construction alone does not establish that feature works.\n- Browser availability also depends on [OpenHands/software-agent-sdk#5899](https://github.com/OpenHands/software-agent-sdk/issues/5899) (`F27.browser-screenshot`; canonical tool-selection note in F13): a Chromium installed only under `PLAYWRIGHT_BROWSERS_PATH` is missed by the agent-tool probe. Recheck the real browser-tool operation on a host with only that custom cache and no standard browser, default cache or PATH executable. This Mac's user Chrome must remain installed; an executor constructor or the verification browser is not a successful agent browser operation.\n- Known issue [OpenHands/software-agent-sdk#5835](https://github.com/OpenHands/software-agent-sdk/issues/5835) (`F27.overview-identity`): the local Overview Git probe uses conversation-scoped Bash execution; an idle-eviction pass can kill scoped work without a final output. Recheck a disposable short-idle-TTL server while a genuine Canvas Git probe is still executing, with independently observed command lifetime and the eventual output/result. The report's standalone asynchronous `sleep` reproduction is supporting backend evidence, not proof of a Terminal-tab command, which uses the agent event stream instead.",
      "features": [
        {
          "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](https://github.com/OpenHands/OpenHands/issues/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. Above 70% fill, the meter label and bar use the warning feedback role; above 90%, its label uses error feedback and its bar uses status error. Text reaches 4.5:1 against the raised Usage surface and fills reach 3:1 against the track in all five themes."
        },
        {
          "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. Above 70% fill, \"Context filling up\" uses the theme warning feedback color."
        },
        {
          "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."
        }
      ],
      "scenarios": [
        {
          "name": "F27--empty-tools",
          "title": "Empty tools",
          "ids": [
            "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`.",
          "raw": "**Empty tools (`F27.terminal-empty`, `F27.browser-empty`, `F27.planner-empty`).** 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`.",
          "source_line": 70,
          "expected": [
            "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.\"",
            "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\"\".",
            "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."
          ],
          "notes": [
            "Known issue [#17774](https://github.com/OpenHands/OpenHands/issues/17774) (`F27.planner-empty` `F27.planner-plan`): Planner reads on a native Windows local backend reject drive-letter/mixed-separator workspace paths with `Conversation file path must stay inside the workspace` before a download request. Recheck both a scratch workspace and an opened drive-letter workspace, with and without `.agents_tmp/PLAN.md`; this run lacks a Windows local Agent Server."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F27--usage-totals",
          "title": "Usage totals",
          "ids": [
            "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`.",
          "raw": "**Usage totals (`F27.usage-metrics`, `F27.usage-provider-balance`).** 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`.",
          "source_line": 71,
          "expected": [
            "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. Above 70% fill, the meter label and bar use the warning feedback role; above 90%, its label uses error feedback and its bar uses status error. Text reaches 4.5:1 against the raised Usage surface and fills reach 3:1 against the track in all five themes.",
            "the provider balance card shows only when the agent server reports a balance (OpenRouter); otherwise it is absent without an error message."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--compact-context",
          "title": "Compact context",
          "ids": [
            "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`.",
          "raw": "**Compact context (`F27.usage-compact`).** 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`.",
          "source_line": 72,
          "expected": [
            "\"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. Above 70% fill, \"Context filling up\" uses the theme warning feedback color."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--composer-context-ring",
          "title": "Composer context ring",
          "ids": [
            "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`).",
          "raw": "**Composer context ring (`F27.composer-context-meter`).** 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`).",
          "source_line": 73,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--agent-driven-tools",
          "title": "Agent-driven tools",
          "ids": [
            "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.`",
          "raw": "**Agent-driven tools (`F27.terminal-output`, `F27.canvas-ui-open-tab`).** 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.`",
          "source_line": 74,
          "expected": [
            "the agent's commands appear as `$ <command>` followed by their output; typing into the terminal does nothing (stdin is disabled).",
            "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."
          ],
          "notes": [
            "Known issue [#17198](https://github.com/OpenHands/OpenHands/issues/17198) (`F27.terminal-output`): Native Windows desktop agents intermittently return `[Command finished execution with no output]` for real file-listing/terminal commands and retry them. Recheck with the affected Windows installer/runtime, project agent definitions and an exact command/workspace; no deterministic pattern or backend trace is provided, and this run has no Windows desktop runtime. The backend counterpart is [OpenHands/software-agent-sdk#4963](https://github.com/OpenHands/software-agent-sdk/issues/4963).",
            "Known issue [#16581](https://github.com/OpenHands/OpenHands/issues/16581) (`F27.terminal-output`): A module-scoped terminal command index can suppress output in a second simultaneously mounted Terminal and reset the first when either unmounts. The report describes a future split-panel/embedded concurrent mount rather than a supported standalone entry point. Recheck in a host that actually mounts two independent Canvas Terminal consumers with real command histories; the current harness has one Terminal route and no such host fixture.",
            "Known issue [OpenHands/software-agent-sdk#5533](https://github.com/OpenHands/software-agent-sdk/issues/5533) (`F27.terminal-output`): A slow interactive Bash startup can make the first terminal command return early with empty output and a false successful exit code. Recheck both standalone and pooled tmux using a private `.bashrc` with `sleep 0.4`, then a bounded `sleep 2` first command and its real observation. The shell fixture can be arranged, but the mapped agent-command/Terminal path cannot run here without a model key.",
            "Known issue [OpenHands/software-agent-sdk#5322](https://github.com/OpenHands/software-agent-sdk/issues/5322) (`F27.terminal-output`): The default tmux pane pool or Windows terminal can let `git log -p` start an interactive pager, so the next agent command receives stale paged output instead of its own result. Recheck a reviewed large git fixture, a real installed pager and a follow-up marker command on each affected terminal backend. The fixture is arrangeable, but the mapped agent-tool path needs a model key (and the Windows half needs Windows)."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F27--terminal-after-reload",
          "title": "Terminal after reload",
          "ids": [
            "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](https://github.com/OpenHands/OpenHands/issues/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.",
          "raw": "**Terminal after reload (`F27.terminal-history-reload`, `F27.terminal-live-append`).** 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](https://github.com/OpenHands/OpenHands/issues/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.",
          "source_line": 75,
          "expected": [
            "after a page reload the earlier commands and output are still shown (issue [#17566](https://github.com/OpenHands/OpenHands/issues/17566)).",
            "a command the agent runs while the page is open is appended live."
          ],
          "notes": [],
          "annotations": [
            "Known failure ([#17566](https://github.com/OpenHands/OpenHands/issues/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."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F27--agent-tab-survives-reload",
          "title": "Agent tab survives reload",
          "ids": [
            "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`).",
          "raw": "**Agent tab survives reload (`F27.canvas-ui-open-tab`).** 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`).",
          "source_line": 76,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--task-list",
          "title": "Task List",
          "ids": [
            "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`.",
          "raw": "**Task List (`F27.tasklist`).** 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`.",
          "source_line": 77,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--plan-and-build",
          "title": "Plan and Build",
          "ids": [
            "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).",
          "raw": "**Plan and Build (`F27.planner-plan`, `F27.planner-build`).** 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).",
          "source_line": 78,
          "expected": [
            "when the workspace has `.agents_tmp/PLAN.md`, the Planner renders it as Markdown with an enabled \"Build ⌘↩\" bar.",
            "\"Build ⌘↩\" sends \"Execute the plan based on the .agents_tmp/PLAN.md file.\" to the agent."
          ],
          "notes": [
            "Known issue [#17774](https://github.com/OpenHands/OpenHands/issues/17774) (`F27.planner-empty` `F27.planner-plan`): Planner reads on a native Windows local backend reject drive-letter/mixed-separator workspace paths with `Conversation file path must stay inside the workspace` before a download request. Recheck both a scratch workspace and an opened drive-letter workspace, with and without `.agents_tmp/PLAN.md`; this run lacks a Windows local Agent Server."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F27--create-a-plan",
          "title": "Create a plan",
          "ids": [
            "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).",
          "raw": "**Create a plan (`F27.planner-create`).** 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).",
          "source_line": 79,
          "expected": [
            "\"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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--open-a-file-from-the-agent",
          "title": "Open a file from the agent",
          "ids": [
            "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.",
          "raw": "**Open a file from the agent (`F27.canvas-ui-navigate-file`).** 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.",
          "source_line": 80,
          "expected": [
            "`canvas_ui_control` `navigate_to_file` (and clicking a file path in chat) opens Files with that file selected."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--launch-a-child",
          "title": "Launch a child",
          "ids": [
            "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`.",
          "raw": "**Launch a child (`F27.launch-child`).** 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`.",
          "source_line": 81,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#17384](https://github.com/OpenHands/OpenHands/issues/17384) (`F27.launch-child`): A file-based delegated subagent using a saved profile/provider connection can fail with HTTP 401 while the top-level conversation succeeds, because encrypted provider credentials are not decrypted in that backend factory path. Recheck a reviewed `.agents/agents/` worker, encrypted provider connection and saved profile using a real key, comparing parent and worker authentication. This is a delegated SDK subagent path, distinct from the client-tool child-launch recipe; neither a dummy key nor an unencrypted profile proves it."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F27--overview",
          "title": "Overview",
          "ids": [
            "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).",
          "raw": "**Overview (`F27.overview-peek`, `F27.overview-pin`, `F27.overview-changes-link`, `F27.overview-drawer`).** 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).",
          "source_line": 82,
          "expected": [
            "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).",
            "the Overview \"...\" menu pins and unpins rows (Workspace, Git parts); an unpinned row stays hidden after a reload.",
            "the Overview Changes row closes the Overview and opens the drawer on Commits.",
            "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\"."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--overview-menu-opens-commits",
          "title": "Overview menu opens Commits",
          "ids": [
            "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`.",
          "raw": "**Overview menu opens Commits (`F27.overview-menu-open`).** 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`.",
          "source_line": 83,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--workspace-and-repository-identity",
          "title": "Workspace and repository identity",
          "ids": [
            "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`).",
          "raw": "**Workspace and repository identity (`F27.overview-identity`).** 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`).",
          "source_line": 84,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [OpenHands/software-agent-sdk#5835](https://github.com/OpenHands/software-agent-sdk/issues/5835) (`F27.overview-identity`): the local Overview Git probe uses conversation-scoped Bash execution; an idle-eviction pass can kill scoped work without a final output. Recheck a disposable short-idle-TTL server while a genuine Canvas Git probe is still executing, with independently observed command lifetime and the eventual output/result. The report's standalone asynchronous `sleep` reproduction is supporting backend evidence, not proof of a Terminal-tab command, which uses the agent event stream instead."
          ],
          "annotations": [],
          "states": [
            "issue"
          ]
        },
        {
          "name": "F27--no-browser-tool-here-f27-browser-screenshot-blocked",
          "title": "No browser tool here (`F27.browser-screenshot`, blocked)",
          "ids": [
            "F27.browser-screenshot"
          ],
          "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`.",
          "raw": "**No browser tool here (`F27.browser-screenshot`, blocked).** `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`.",
          "source_line": 85,
          "expected": [
            "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."
          ],
          "notes": [
            "Known issue [#16551](https://github.com/OpenHands/OpenHands/issues/16551) (`F27.browser-screenshot`): On native Windows, the browser tool chooses system Chrome over installed Playwright Chromium and times out before returning state/screenshots. Recheck default browser selection and an explicit executable-path control on a Windows host with both browsers and the reported browser_use runtime; the verification Chromium itself is a different tool. Blocked here without Windows and real browser-tool execution credentials.",
            "Known issue [#17383](https://github.com/OpenHands/OpenHands/issues/17383) (`F27.browser-screenshot`; dependency counterpart [OpenHands/software-agent-sdk#5152](https://github.com/OpenHands/software-agent-sdk/issues/5152)): unlocked dependency resolution can silently remove the agent's browser tools. The actual 2026-10-12 pinned runtime resolves browser-use 0.11.13, mcp 1.30.0 and fastmcp 3.4.8; its genuine BrowserToolExecutor constructs successfully. That narrowly fails to reproduce the SDK report's specified mcp 2.x import failure. Fresh constructor recheck on 2026-10-16 at `b34ac59`, using the actual Agent Server 1.55.0 interpreter and private HOME, again resolves browser-use 0.11.13, mcp 1.30.0 and fastmcp 3.4.8 and constructs successfully; no browser operation or model was run. The Canvas/global browser-operation path still needs an authorized real LLM and a genuine browser-enabled agent issuing a `browser_*` operation; construction alone does not establish that feature works."
          ],
          "annotations": [],
          "states": [
            "issue",
            "blocked"
          ]
        },
        {
          "name": "F27--info-modals-at-phone-width",
          "title": "Info modals at phone width",
          "ids": [
            "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`.",
          "raw": "**Info modals at phone width (`F27.info-modals-phone`).** 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`.",
          "source_line": 86,
          "expected": [
            "the composer `+` menu's Skills, Hooks and Agent Tools & Metadata modals fit a 390 px viewport (issue #17562); content is covered by F07."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--agent-tab-at-phone-width",
          "title": "Agent tab at phone width",
          "ids": [
            "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`.",
          "raw": "**Agent tab at phone width (`F27.canvas-ui-phone`).** 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`.",
          "source_line": 87,
          "expected": [
            "at phone width an agent `open_tab` makes the requested tab visible on `/conversations/<id>/panel`."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--phone-panel-tabs",
          "title": "Phone panel tabs",
          "ids": [
            "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>`.",
          "raw": "**Phone panel tabs (`F27.phone`).** 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>`.",
          "source_line": 88,
          "expected": [
            "at 390 px the panel route shows the Usage, Task List and Planner tabs without overflow, and the tab row stays visible."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--terminal-waiting",
          "title": "Terminal waiting",
          "ids": [
            "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`.",
          "raw": "**Terminal waiting (`F27.terminal-waiting`).** 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`.",
          "source_line": 89,
          "expected": [
            "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."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--state-not-reached",
          "title": "State not reached",
          "ids": [
            "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.",
          "raw": "**State not reached (`F27.usage-empty`).** 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.",
          "source_line": 90,
          "expected": [
            "before any metrics arrive the Usage tab reads \"No metrics data available\"."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F27--contract-f27-plugins-modal",
          "title": "Plugins modal",
          "ids": [
            "F27.plugins-modal"
          ],
          "body": "",
          "raw": "",
          "contract_only": true,
          "source_line": null,
          "expected": [
            "a conversation started with plugins offers a plugins item that lists `active-plugin-<name>` rows."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "contract"
          ]
        }
      ]
    },
    {
      "id": "F28",
      "title": "Automation setup page",
      "file": "F28-automation-setup-page.md",
      "sha256": "c1feb57614926f30f12199caa69416710ecfc249ba260f260be326d7a06bda96",
      "overview": "The setup page is the one form for making and changing automations outside the\ntemplate dialog. **Add Automation → Create Automation** opens it blank at\n`/automations/setup`; a saved draft reopens at `/automations/setup?draftId=<id>`;\nkebab **Edit** opens an existing automation at\n`/automations/setup?automationId=<id>`. The form collects Name, a Prompt (or\nCustom Python with entrypoint and setup script), agent profile, model and\nrepositories, a Schedule or Event trigger, and optional plugins and timeout.\nA docked composer under the form can ask the agent to fill it. Create mode's\ntoolbar has **Save draft**, **Test** and **Create Automation**; edit mode's has\n**Test**, **Save** and **Close**. A server-backed draft carries the server's\nvalidation result and test runs; Create turns it into a real, enabled\nautomation and removes the draft.\n\nF22 keeps `F22.agent-assisted-setup` (the end-to-end agent, plugin, phone and\ndraft-Test recipes); F23 owns edit mode (`F23.edit-*`, including Test and the save state on the edit\npage). This family gives each create-mode behavior its own ID so a pass can fail one without\nfailing the others.",
      "entry": "- Dashboard header **Add Automation → Create Automation** (`automations-add-automation`, then `automations-add-automation-create`): blank create mode. Every create recipe below starts here.\n- Empty dashboard **Create Automation** (`automations-empty >> automations-create-automation`): the same blank form (`F21.empty-state`).\n- Dashboard Drafts group: name or **Resume** (`automation-setup-draft-resume-<draft-id>`) and the pencil (**Edit**, `automation-setup-draft-edit-<draft-id>`) open `?draftId=<id>`.\n- Kebab **Edit** on a dashboard card or the detail page header: edit mode (`?automationId=<id>`, `F21.edit`, `F23.edit-open`).\n- A direct URL with `draftId` or `automationId` (see the cold-load gotcha).",
      "given": "Preconditions:\n\n- Baseline state (`launch`, `doctor`, `onboard --skip`). No LLM is needed: every bullet uses the Custom Python kind or never dispatches, and the agent-assisted path stays in F22.\n- Desktop viewport; the `F28.error-contrast` bullet switches color themes (Settings → Application, `F16`) and restores the default.\n- Every recipe below was run as written on 2026-10-15 on a fresh local `control-openhands launch` of main with Agent Server 1.54.0 and no LLM. Documented known failures remain failures.",
      "gotchas": "- A toast can cover the toolbar (Save draft, Test, Close) and stays while the pointer rests on it, so the next toolbar click times out (\"subtree intercepts pointer events\"). After each save, `browser hover` a form field (for example `testid=automation-setup-name`) before clicking the toolbar again, as F22's Gotchas say.\n- Drive inputs with `browser fill`: setting `.value` from `browser eval` does not reach React state for every field, and a save then sends the old value.\n- Change one field per Save draft when checking a validation message: a save shows only the first server error, so two edits in one save hide the second.\n- A cold load of `/automations/setup?draftId=<id>` or `?automationId=<id>` (a reload or a typed URL) opens the form: re-checked 2026-10-14 on a `control-openhands launch` of main with this family (both read their Name back). An earlier instance of unknown build stayed on **Loading saved draft…** for more than 20 s; if that returns, compare with opening the same draft from the dashboard.\n- The draft bar duplicates the prompt error: `Field required` appears both under Prompt and in the bar.\n- Kebab **Edit** on a Custom Python automation shows the code editor instead of the saved bundle summary: open bug #18341 (`F23`), with fixes in PRs #18353 and #18444.\n- Escape on the setup page does nothing, by design (`F23.edit-close`).",
      "features": [
        {
          "id": "F28.open-blank",
          "description": "Create Automation opens `/automations/setup` with Name pre-filled **New Automation** (placeholder `Standup digest`), an empty Prompt, Schedule selected with Daily at `09:00` in `America/New_York` (the default, whatever the browser's time zone), the docked composer (`Need help creating an automation?`) and no new conversation. The save state already reads **Unsaved changes** and the toolbar **Test** is disabled."
        },
        {
          "id": "F28.kind-toggle",
          "description": "the button beside the Prompt label switches between Prompt and **Custom** (Custom Python). Custom shows a generated Python script that calls `AUTOMATION_CALLBACK_URL`, and **Entrypoint and setup** reveals Entrypoint `python3 main.py`, Setup script path `setup.sh` and a no-op setup script; switching back shows the Prompt again."
        },
        {
          "id": "F28.schedule-presets",
          "description": "in create mode the Frequency control (the same control as on the edit page, whose presets and saving F23 owns as edit-schedule and edit-cron) starts on Daily at `09:00`, and switching presets fills create-mode defaults: Once a date-time at the next whole hour today, Weekly Monday, and Custom the cron of the preset it replaces (from Weekly, Monday at `10:30` it reads `30 10 * * 1`), while a cron already typed in Custom stays. Known failure (reproduced 2026-10-15, [#18461](https://github.com/OpenHands/OpenHands/issues/18461)): Custom shows the last custom cron, `0 9 * * *` until one is typed, whatever the preset; from Weekly on Monday it reads `0 9 * * *`. Every preset keeps the 13-entry time zone select."
        },
        {
          "id": "F28.event-trigger",
          "description": "choosing **Event** replaces the schedule with Event source (default **GitHub**, sent as `github`; its menu lists the webhook sources registered on this backend plus **Custom**), Event key (default `issue_comment.created`), **Add Filter** (one free-text filter input), **Ask Agent**, the notice `Event sources need a public URL to deliver webhooks to this instance.`, and a **Test event payload** pre-filled with a JSON object for that source and key. A source the backend has not registered (menu **Custom**, then a typed name) adds **Register this as a custom webhook source**, checked by default."
        },
        {
          "id": "F28.timeout",
          "description": "**Add Timeout** adds an optional **Timeout seconds** number field with a remove button; `1800` saves and the draft reads **Ready to test**."
        },
        {
          "id": "F28.draft-save",
          "description": "**Save draft** works on any form, valid or not; the first save replaces the URL with `?draftId=<id>`, shows the **Draft saved** bar (relative time, **View test runs**, **Test**) and the toast `Draft saved in this conversation.`, and the save state reads **Saved just now**. The draft appears in the dashboard Drafts group (F21)."
        },
        {
          "id": "F28.draft-validation",
          "description": "the server validates on save, not on blur. An invalid draft is still saved (`dispatchable: false`) and its first error replaces **Ready to test** in the draft bar: an empty prompt shows `Field required` (also under Prompt), a bad custom cron shows `Value error, Invalid cron expression: every day`. A field error stays until the next save. **Test** stays enabled on a saved invalid draft: it saves again, opens **Test runs** with the same error in a red alert and dispatches nothing."
        },
        {
          "id": "F28.draft-save-error",
          "description": "a draft the server refuses outright (a timeout above 1800 returns `422`) sets the save state to **Save failed** and shows one error toast with the reason the API gives (a timeout of 1801 reads `Value error, timeout must not exceed 1800 seconds (30 minutes)`). The draft bar drops its validity pill (no stale **Ready to test**) until a save succeeds."
        },
        {
          "id": "F28.required-before-test",
          "description": "Test and Create check locally first and send nothing when a required field is empty: `Add a prompt before continuing.` (Prompt or Plugin kind), `Add a plugin source before continuing.`, `Add Python code before continuing.`, `Add an entrypoint command before continuing.`, `Add a setup script path before continuing.`, `Add a setup script before continuing.`; an event Test with a payload that is not a JSON object shows `Enter a valid JSON object before testing this event automation.` A blank Name is not an error: it is derived from the prompt."
        },
        {
          "id": "F28.test-gated",
          "description": "in create mode **Test** stays disabled until the draft is saved and unchanged since; editing any field after a save disables it again until the next **Save draft**."
        },
        {
          "id": "F28.draft-test",
          "description": "**Test** on a saved, valid draft opens **Test runs** (with **Back**), shows `Test run started.` and a Pending row; it runs a hidden disabled copy of the draft, not a dashboard automation. Expected: the row reaches Successful or Failed by itself. Known failure #18263 (reproduced again 2026-10-15 on a Custom Python draft): the row stays Pending and the page does not poll the runs after the first request."
        },
        {
          "id": "F28.create",
          "description": "**Create Automation** creates the automation enabled, navigates to `/automations/<id>` (a Custom Python automation shows **Script**), and removes the draft from the dashboard Drafts group."
        },
        {
          "id": "F28.error-contrast",
          "description": "the draft bar's error pill and the Test runs error alert use the theme's error color (`text-feedback-error`) and reach at least 4.5:1 in all five color themes. The pill uses a 5% red tint over the draft bar surface."
        }
      ],
      "scenarios": [
        {
          "name": "F28--blank-form",
          "title": "Blank form",
          "ids": [
            "F28.open-blank"
          ],
          "body": "Run `control-openhands conversation list`, `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'` and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup$'`. Then `browser value 'testid=automation-setup-name'` is `New Automation`, `browser attr 'testid=automation-setup-name' placeholder` is `Standup digest`, `browser value 'testid=automation-setup-prompt'` is empty, `browser attr 'testid=automation-setup-frequency-daily' aria-checked` is `true`, `browser value 'testid=automation-setup-time'` is `09:00`, `browser text 'testid=automation-setup-save-state'` is `Unsaved changes` and `browser enabled 'testid=automation-setup-test'` is `false`. `conversation list` is unchanged.",
          "raw": "**Blank form (`F28.open-blank`).** Run `control-openhands conversation list`, `control-openhands browser goto /automations`, `control-openhands browser click 'testid=automations-add-automation'` and `control-openhands browser click 'testid=automations-add-automation-create' --expect-url '/automations/setup$'`. Then `browser value 'testid=automation-setup-name'` is `New Automation`, `browser attr 'testid=automation-setup-name' placeholder` is `Standup digest`, `browser value 'testid=automation-setup-prompt'` is empty, `browser attr 'testid=automation-setup-frequency-daily' aria-checked` is `true`, `browser value 'testid=automation-setup-time'` is `09:00`, `browser text 'testid=automation-setup-save-state'` is `Unsaved changes` and `browser enabled 'testid=automation-setup-test'` is `false`. `conversation list` is unchanged.",
          "source_line": 55,
          "expected": [
            "Create Automation opens `/automations/setup` with Name pre-filled **New Automation** (placeholder `Standup digest`), an empty Prompt, Schedule selected with Daily at `09:00` in `America/New_York` (the default, whatever the browser's time zone), the docked composer (`Need help creating an automation?`) and no new conversation. The save state already reads **Unsaved changes** and the toolbar **Test** is disabled."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--presets",
          "title": "Presets",
          "ids": [
            "F28.schedule-presets"
          ],
          "body": "For each of `once`, `hourly`, `daily`, `weekdays`, `weekly`, `custom` run `control-openhands browser click 'testid=automation-setup-frequency-<preset>'` and `control-openhands browser testids 'testid=automation-setup-at-row'` (`browser snapshot` is an ARIA tree and does not list test ids): Once has `automation-setup-datetime`, Hourly only `automation-setup-timezone`, Daily and Weekdays `automation-setup-time`, Weekly also `automation-setup-weekday` (value `1`), Custom `automation-setup-custom-schedule`. Click Weekly, then Custom: expected `browser value 'testid=automation-setup-custom-schedule'` is Weekly's `0 9 * * 1`. Known failure (reproduced 2026-10-15, [#18461](https://github.com/OpenHands/OpenHands/issues/18461)): it reads `0 9 * * *`. `browser count 'testid=automation-setup-timezone >> option'` is `13`. Click Daily again.",
          "raw": "**Presets (`F28.schedule-presets`).** For each of `once`, `hourly`, `daily`, `weekdays`, `weekly`, `custom` run `control-openhands browser click 'testid=automation-setup-frequency-<preset>'` and `control-openhands browser testids 'testid=automation-setup-at-row'` (`browser snapshot` is an ARIA tree and does not list test ids): Once has `automation-setup-datetime`, Hourly only `automation-setup-timezone`, Daily and Weekdays `automation-setup-time`, Weekly also `automation-setup-weekday` (value `1`), Custom `automation-setup-custom-schedule`. Click Weekly, then Custom: expected `browser value 'testid=automation-setup-custom-schedule'` is Weekly's `0 9 * * 1`. Known failure (reproduced 2026-10-15, [#18461](https://github.com/OpenHands/OpenHands/issues/18461)): it reads `0 9 * * *`. `browser count 'testid=automation-setup-timezone >> option'` is `13`. Click Daily again.",
          "source_line": 56,
          "expected": [
            "in create mode the Frequency control (the same control as on the edit page, whose presets and saving F23 owns as edit-schedule and edit-cron) starts on Daily at `09:00`, and switching presets fills create-mode defaults: Once a date-time at the next whole hour today, Weekly Monday, and Custom the cron of the preset it replaces (from Weekly, Monday at `10:30` it reads `30 10 * * 1`), while a cron already typed in Custom stays."
          ],
          "notes": [],
          "annotations": [
            "Known failure (reproduced 2026-10-15, [#18461](https://github.com/OpenHands/OpenHands/issues/18461)): it reads `0 9 * * *`. `browser count 'testid=automation-setup-timezone >> option'` is `13`. Click Daily again.",
            "Known failure (reproduced 2026-10-15, [#18461](https://github.com/OpenHands/OpenHands/issues/18461)): Custom shows the last custom cron, `0 9 * * *` until one is typed, whatever the preset; from Weekly on Monday it reads `0 9 * * *`. Every preset keeps the 13-entry time zone select."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F28--event-fields",
          "title": "Event fields",
          "ids": [
            "F28.event-trigger"
          ],
          "body": "Run `control-openhands browser click 'role=radio[name=/^Event/]'`. `browser value 'testid=automation-setup-event-key'` is `issue_comment.created`, `browser visible 'testid=automation-setup-event-public-url-notice'` is `true`, and `browser count 'testid=automation-setup-custom-webhook-enabled'` is `0` for `github`. Click `testid=automation-setup-event-source-toggle`, `browser snapshot 'testid=automation-setup-event-source-menu'` lists the registered sources and `Custom` (GitHub, GitLab, Slack, Linear, Jira on a local stack). Click `testid=automation-setup-event-source-menu >> role=button[name=\"Custom\"]`, `browser fill 'testid=automation-setup-event-source' qa-hooks`: `browser visible 'testid=automation-setup-custom-webhook-enabled'` is `true` and `browser eval \"document.querySelector('[data-testid=automation-setup-custom-webhook-enabled]').checked\"` is `true`. Pick GitHub again from the same menu. Click `testid=automation-setup-add-event-filter` (`automation-setup-event-filter` appears) and `testid=automation-setup-event-test-payload-toggle`; `browser value 'testid=automation-setup-event-test-payload'` is a JSON object with `\"source\": \"github\"` and `\"event\": \"issue_comment.created\"`. Click `role=radio[name=/^Schedule/]` to return.",
          "raw": "**Event fields (`F28.event-trigger`).** Run `control-openhands browser click 'role=radio[name=/^Event/]'`. `browser value 'testid=automation-setup-event-key'` is `issue_comment.created`, `browser visible 'testid=automation-setup-event-public-url-notice'` is `true`, and `browser count 'testid=automation-setup-custom-webhook-enabled'` is `0` for `github`. Click `testid=automation-setup-event-source-toggle`, `browser snapshot 'testid=automation-setup-event-source-menu'` lists the registered sources and `Custom` (GitHub, GitLab, Slack, Linear, Jira on a local stack). Click `testid=automation-setup-event-source-menu >> role=button[name=\"Custom\"]`, `browser fill 'testid=automation-setup-event-source' qa-hooks`: `browser visible 'testid=automation-setup-custom-webhook-enabled'` is `true` and `browser eval \"document.querySelector('[data-testid=automation-setup-custom-webhook-enabled]').checked\"` is `true`. Pick GitHub again from the same menu. Click `testid=automation-setup-add-event-filter` (`automation-setup-event-filter` appears) and `testid=automation-setup-event-test-payload-toggle`; `browser value 'testid=automation-setup-event-test-payload'` is a JSON object with `\"source\": \"github\"` and `\"event\": \"issue_comment.created\"`. Click `role=radio[name=/^Schedule/]` to return.",
          "source_line": 57,
          "expected": [
            "choosing **Event** replaces the schedule with Event source (default **GitHub**, sent as `github`; its menu lists the webhook sources registered on this backend plus **Custom**), Event key (default `issue_comment.created`), **Add Filter** (one free-text filter input), **Ask Agent**, the notice `Event sources need a public URL to deliver webhooks to this instance.`, and a **Test event payload** pre-filled with a JSON object for that source and key. A source the backend has not registered (menu **Custom**, then a typed name) adds **Register this as a custom webhook source**, checked by default."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--invalid-drafts-save",
          "title": "Invalid drafts save",
          "ids": [
            "F28.draft-save",
            "F28.draft-validation"
          ],
          "body": "Fill `testid=automation-setup-name` with `QA_F28 Probe`, leave the prompt empty, `browser network --clear`, click `testid=automation-setup-save-draft`. `browser wait-url 'draftId='`; `browser toasts --history` includes `Draft saved in this conversation.`; `browser text 'testid=automation-setup-draft-validity'` is `Field required`; `control-openhands api GET /api/automation/v1/drafts/<draft-id>` (the id in the URL) has `validation_errors` `[{\"field\":\"prompt\",\"code\":\"missing\",…}]` and `dispatchable: false` (`browser network --bodies` shows request bodies, not the response); `browser text 'testid=automation-setup-save-state'` is `Saved just now`. Now fill the prompt with `Reply pong. Do not run any tools.`, click `testid=automation-setup-frequency-custom`, fill `testid=automation-setup-custom-schedule` with `every day`, `browser network --clear`, press Tab, and `browser network --filter 'automation/v1'` lists no request (no blur validation). Click Save draft: the validity text is `Value error, Invalid cron expression: every day`. `browser network --clear`, click `testid=automation-setup-test`: `browser text 'testid=automation-setup-draft-runs-page'` contains the same cron error, `browser network` lists only the draft `PATCH` (200) and nothing under `dispatch`; click `testid=automation-setup-draft-runs-back`. Fill the cron with `0 0 1 1 *` and Save draft: it reads `Ready to test`.",
          "raw": "**Invalid drafts save (`F28.draft-save`, `F28.draft-validation`).** Fill `testid=automation-setup-name` with `QA_F28 Probe`, leave the prompt empty, `browser network --clear`, click `testid=automation-setup-save-draft`. `browser wait-url 'draftId='`; `browser toasts --history` includes `Draft saved in this conversation.`; `browser text 'testid=automation-setup-draft-validity'` is `Field required`; `control-openhands api GET /api/automation/v1/drafts/<draft-id>` (the id in the URL) has `validation_errors` `[{\"field\":\"prompt\",\"code\":\"missing\",…}]` and `dispatchable: false` (`browser network --bodies` shows request bodies, not the response); `browser text 'testid=automation-setup-save-state'` is `Saved just now`. Now fill the prompt with `Reply pong. Do not run any tools.`, click `testid=automation-setup-frequency-custom`, fill `testid=automation-setup-custom-schedule` with `every day`, `browser network --clear`, press Tab, and `browser network --filter 'automation/v1'` lists no request (no blur validation). Click Save draft: the validity text is `Value error, Invalid cron expression: every day`. `browser network --clear`, click `testid=automation-setup-test`: `browser text 'testid=automation-setup-draft-runs-page'` contains the same cron error, `browser network` lists only the draft `PATCH` (200) and nothing under `dispatch`; click `testid=automation-setup-draft-runs-back`. Fill the cron with `0 0 1 1 *` and Save draft: it reads `Ready to test`.",
          "source_line": 58,
          "expected": [
            "**Save draft** works on any form, valid or not; the first save replaces the URL with `?draftId=<id>`, shows the **Draft saved** bar (relative time, **View test runs**, **Test**) and the toast `Draft saved in this conversation.`, and the save state reads **Saved just now**. The draft appears in the dashboard Drafts group (F21).",
            "the server validates on save, not on blur. An invalid draft is still saved (`dispatchable: false`) and its first error replaces **Ready to test** in the draft bar: an empty prompt shows `Field required` (also under Prompt), a bad custom cron shows `Value error, Invalid cron expression: every day`. A field error stays until the next save. **Test** stays enabled on a saved invalid draft: it saves again, opens **Test runs** with the same error in a red alert and dispatches nothing."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--required-fields-before-test",
          "title": "Required fields before Test",
          "ids": [
            "F28.required-before-test"
          ],
          "body": "Run `control-openhands browser fill 'testid=automation-setup-prompt' ''`, Save draft, `browser network --clear`, click `testid=automation-setup-test`: `browser text 'testid=automation-setup-draft-runs-page'` contains `Add a prompt before continuing.` and `browser network --filter 'dispatch'` lists nothing. Click `testid=automation-setup-draft-runs-back`, fill the prompt back in and Save draft.",
          "raw": "**Required fields before Test (`F28.required-before-test`).** Run `control-openhands browser fill 'testid=automation-setup-prompt' ''`, Save draft, `browser network --clear`, click `testid=automation-setup-test`: `browser text 'testid=automation-setup-draft-runs-page'` contains `Add a prompt before continuing.` and `browser network --filter 'dispatch'` lists nothing. Click `testid=automation-setup-draft-runs-back`, fill the prompt back in and Save draft.",
          "source_line": 59,
          "expected": [
            "Test and Create check locally first and send nothing when a required field is empty: `Add a prompt before continuing.` (Prompt or Plugin kind), `Add a plugin source before continuing.`, `Add Python code before continuing.`, `Add an entrypoint command before continuing.`, `Add a setup script path before continuing.`, `Add a setup script before continuing.`; an event Test with a payload that is not a JSON object shows `Enter a valid JSON object before testing this event automation.` A blank Name is not an error: it is derived from the prompt."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--timeout",
          "title": "Timeout",
          "ids": [
            "F28.timeout",
            "F28.draft-save-error"
          ],
          "body": "Click `testid=automation-setup-add-timeout`, fill `testid=automation-setup-timeout` with `1800`, Save draft: `Ready to test`. Fill `1801`, `browser toasts --history --clear`, `browser network --clear`, Save draft. `browser network --filter 'automation/v1/drafts'` shows `PATCH … 422` (`--bodies` adds the request body, not the response) and `browser text 'testid=automation-setup-save-state'` is `Save failed`. `browser toasts --history` holds one toast, `Value error, timeout must not exceed 1800 seconds (30 minutes)`, and `browser count 'testid=automation-setup-draft-validity'` is `0`. Click `testid=automation-setup-timeout-remove` and Save draft.",
          "raw": "**Timeout (`F28.timeout`, `F28.draft-save-error`).** Click `testid=automation-setup-add-timeout`, fill `testid=automation-setup-timeout` with `1800`, Save draft: `Ready to test`. Fill `1801`, `browser toasts --history --clear`, `browser network --clear`, Save draft. `browser network --filter 'automation/v1/drafts'` shows `PATCH … 422` (`--bodies` adds the request body, not the response) and `browser text 'testid=automation-setup-save-state'` is `Save failed`. `browser toasts --history` holds one toast, `Value error, timeout must not exceed 1800 seconds (30 minutes)`, and `browser count 'testid=automation-setup-draft-validity'` is `0`. Click `testid=automation-setup-timeout-remove` and Save draft.",
          "source_line": 60,
          "expected": [
            "**Add Timeout** adds an optional **Timeout seconds** number field with a remove button; `1800` saves and the draft reads **Ready to test**.",
            "a draft the server refuses outright (a timeout above 1800 returns `422`) sets the save state to **Save failed** and shows one error toast with the reason the API gives (a timeout of 1801 reads `Value error, timeout must not exceed 1800 seconds (30 minutes)`). The draft bar drops its validity pill (no stale **Ready to test**) until a save succeeds."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--test-gating",
          "title": "Test gating",
          "ids": [
            "F28.test-gated"
          ],
          "body": "After a successful Save draft `browser enabled 'testid=automation-setup-test'` is `true`; fill `testid=automation-setup-name` with `QA_F28 Probe 2` and it is `false`; Save draft and it is `true`.",
          "raw": "**Test gating (`F28.test-gated`).** After a successful Save draft `browser enabled 'testid=automation-setup-test'` is `true`; fill `testid=automation-setup-name` with `QA_F28 Probe 2` and it is `false`; Save draft and it is `true`.",
          "source_line": 61,
          "expected": [
            "in create mode **Test** stays disabled until the draft is saved and unchanged since; editing any field after a save disables it again until the next **Save draft**."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--custom-kind-and-draft-test",
          "title": "Custom kind and draft Test",
          "ids": [
            "F28.kind-toggle",
            "F28.draft-test"
          ],
          "body": "Click `testid=automation-setup-kind-custom` and `browser wait 'testid=automation-setup-custom-code'`; once the crossfade ends `browser count 'testid=automation-setup-prompt'` is `0` (right after the click it can still read `1`, and `visible` `true`) and `browser value 'testid=automation-setup-custom-code'` contains `AUTOMATION_CALLBACK_URL`. Click `testid=automation-setup-custom-details-toggle`: Entrypoint `python3 main.py`, Setup script path `setup.sh`. Save draft (`Ready to test`), click `testid=automation-setup-test` and `browser wait 'testid=automation-setup-draft-run'`: the row reads Pending and the page shows `Test run started.` Expected: `browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000`. Known failure #18263 (reproduced 2026-10-15): the row stays Pending for 45 s with a single runs request in `browser network --filter '/runs'`; after `browser reload`, `testid=automation-setup-view-test-runs` shows it Successful. Click `testid=automation-setup-draft-runs-back`. Switching back with `testid=automation-setup-kind-prompt` restores the Prompt; leave the draft on Custom for the next bullet.",
          "raw": "**Custom kind and draft Test (`F28.kind-toggle`, `F28.draft-test`).** Click `testid=automation-setup-kind-custom` and `browser wait 'testid=automation-setup-custom-code'`; once the crossfade ends `browser count 'testid=automation-setup-prompt'` is `0` (right after the click it can still read `1`, and `visible` `true`) and `browser value 'testid=automation-setup-custom-code'` contains `AUTOMATION_CALLBACK_URL`. Click `testid=automation-setup-custom-details-toggle`: Entrypoint `python3 main.py`, Setup script path `setup.sh`. Save draft (`Ready to test`), click `testid=automation-setup-test` and `browser wait 'testid=automation-setup-draft-run'`: the row reads Pending and the page shows `Test run started.` Expected: `browser wait 'testid=automation-setup-draft-run >> text=Successful' --timeout 45000`. Known failure #18263 (reproduced 2026-10-15): the row stays Pending for 45 s with a single runs request in `browser network --filter '/runs'`; after `browser reload`, `testid=automation-setup-view-test-runs` shows it Successful. Click `testid=automation-setup-draft-runs-back`. Switching back with `testid=automation-setup-kind-prompt` restores the Prompt; leave the draft on Custom for the next bullet.",
          "source_line": 62,
          "expected": [
            "the button beside the Prompt label switches between Prompt and **Custom** (Custom Python). Custom shows a generated Python script that calls `AUTOMATION_CALLBACK_URL`, and **Entrypoint and setup** reveals Entrypoint `python3 main.py`, Setup script path `setup.sh` and a no-op setup script; switching back shows the Prompt again.",
            "**Test** on a saved, valid draft opens **Test runs** (with **Back**), shows `Test run started.` and a Pending row; it runs a hidden disabled copy of the draft, not a dashboard automation. Expected: the row reaches Successful or Failed by itself."
          ],
          "notes": [],
          "annotations": [
            "Known failure #18263 (reproduced 2026-10-15): the row stays Pending for 45 s with a single runs request in `browser network --filter '/runs'`; after `browser reload`, `testid=automation-setup-view-test-runs` shows it Successful. Click `testid=automation-setup-draft-runs-back`. Switching back with `testid=automation-setup-kind-prompt` restores the Prompt; leave the draft on Custom for the next bullet.",
            "Known failure #18263 (reproduced again 2026-10-15 on a Custom Python draft): the row stays Pending and the page does not poll the runs after the first request."
          ],
          "states": [
            "failure"
          ]
        },
        {
          "name": "F28--create",
          "title": "Create",
          "ids": [
            "F28.create"
          ],
          "body": "Run `control-openhands browser click 'testid=automation-setup-create' --expect-url '/automations/[0-9a-f-]{36}$'`. The detail h1 is `QA_F28 Probe 2`, the badge reads Active and the page shows **Script**. `browser goto /automations`: `browser count 'testid=automation-setup-draft-resume-<draft-id>'` is `0`.",
          "raw": "**Create (`F28.create`).** Run `control-openhands browser click 'testid=automation-setup-create' --expect-url '/automations/[0-9a-f-]{36}$'`. The detail h1 is `QA_F28 Probe 2`, the badge reads Active and the page shows **Script**. `browser goto /automations`: `browser count 'testid=automation-setup-draft-resume-<draft-id>'` is `0`.",
          "source_line": 63,
          "expected": [
            "**Create Automation** creates the automation enabled, navigates to `/automations/<id>` (a Custom Python automation shows **Script**), and removes the draft from the dashboard Drafts group."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--error-contrast",
          "title": "Error contrast",
          "ids": [
            "F28.error-contrast"
          ],
          "body": "Switch to Light+ (`F16`), open a draft whose cron is `every day` (as in the invalid-drafts bullet), and `browser screenshot 'testid=automation-setup-draft-details' --feature F28.error-contrast --name light-plus`. Read the pill's computed foreground and background and its parent surface with `browser eval`; composite its 5% red tint (`#fb2c36`) over the draft bar background. Expected contrast is at least 4.5:1. Click `testid=automation-setup-test` and inspect `testid=automation-setup-draft-runs-error` the same way. Repeat both checks in OpenHands-DeepSea, OpenHands-Neutral, OpenHands-Neo and Solarized Light, then restore OpenHands-Neutral.",
          "raw": "**Error contrast (`F28.error-contrast`).** Switch to Light+ (`F16`), open a draft whose cron is `every day` (as in the invalid-drafts bullet), and `browser screenshot 'testid=automation-setup-draft-details' --feature F28.error-contrast --name light-plus`. Read the pill's computed foreground and background and its parent surface with `browser eval`; composite its 5% red tint (`#fb2c36`) over the draft bar background. Expected contrast is at least 4.5:1. Click `testid=automation-setup-test` and inspect `testid=automation-setup-draft-runs-error` the same way. Repeat both checks in OpenHands-DeepSea, OpenHands-Neutral, OpenHands-Neo and Solarized Light, then restore OpenHands-Neutral.",
          "source_line": 64,
          "expected": [
            "the draft bar's error pill and the Test runs error alert use the theme's error color (`text-feedback-error`) and reach at least 4.5:1 in all five color themes. The pill uses a 5% red tint over the draft bar surface."
          ],
          "notes": [],
          "annotations": [],
          "states": [
            "mapped"
          ]
        },
        {
          "name": "F28--cleanup",
          "title": "Cleanup",
          "ids": [],
          "body": "Delete `QA_F28 Probe 2` from its kebab and any leftover `QA_F28` draft with its trash icon, as in F21; `api GET /api/automation/v1/drafts` lists none.",
          "raw": "**Cleanup.** Delete `QA_F28 Probe 2` from its kebab and any leftover `QA_F28` draft with its trash icon, as in F21; `api GET /api/automation/v1/drafts` lists none.",
          "source_line": 65,
          "expected": [],
          "notes": [],
          "annotations": [],
          "states": [
            "support"
          ]
        }
      ]
    }
  ]
}
