Files
OpenHands/scripts
f0c36bac8f feat(acp): Settings → Agent + onboarding + chat-UI gating for ACP-driven conversations (#416)
* feat(acp): add minimal ACP agent UI (parity with OpenHands#14401)

Adds a Settings → Agent page so users can switch the conversation
between the built-in OpenHands agent and an external ACP (Agent Client
Protocol) subprocess (Claude Code, Codex, Gemini CLI, or custom command)
without hand-editing settings.

Discriminates in agent-server-adapter: when `agent_settings.agent_kind
=== "acp"`, build an `ACPAgent` payload (kind, acp_command, acp_model)
instead of the LLM-shaped Agent, and skip the LLM defaults that would
otherwise be rejected as extras. Stamps the provider key onto
`tags.acpserver` so the chip can resolve a brand name from a single
source.

Tag-key constant note: the conventional `acp_server` form is invalid —
agent-server validates tag keys against `^[a-z0-9]+$` and returns 422.
The flattened `acpserver` form survives validation; the named constant
`ACP_SERVER_TAG_KEY` keeps the regex and the key colocated.

Gates the LLM and Condenser nav items behind a `disabledByAcp` flag,
greys them out with a tooltip, and redirects to `/settings/agent` in
the settings loader (not a per-route useEffect, so there is no one-
frame flash of the LLM page before bouncing).

E2E validated against `ghcr.io/openhands/agent-server:fa29ae2-python`:
- PATCH /api/settings with `agent_kind: "acp"` round-trips
- POST /api/conversations with the adapter's ACP payload returns 201,
  `agent.kind=ACPAgent`, `acp_command` preserved, tags stamped.

Closes #412

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(acp): wire onboarding ChooseAgent step into ACP settings

Drops the "Support for other agents coming soon!" banner now that
the support exists. Enables the Claude Code / Codex tiles and adds a
Gemini CLI tile so the four options here match ``ACP_PROVIDERS`` from
the Settings → Agent page.

Selecting an ACP option and clicking Next persists ``agent_kind:"acp"``
plus the registry provider key (``acp_server``) via ``useSaveSettings``,
mirroring the diff the Settings page emits. The advance only happens
on save success — a failed PATCH stays on the step and surfaces a toast.

Skips the embedded LLM-setup step (index 2) on both forward and back
navigation when an ACP agent is active: the subprocess owns its own
LLM and authenticates through Secrets, so the form has nothing to
configure. OpenHands path is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(acp): seamless Claude Code + Codex CLI auth via dev-docker

Live-validated against `ghcr.io/openhands/agent-server:1.22.1-python`
(the canvas's default pin, which now ships ACPAgent natively — no
SHA override needed). Two changes surfaced by the run:

1. **Mount `~/.claude.json` in dev:docker.**  Recent Claude Code CLI
   versions persist auth + workspace state in `~/.claude.json` next
   to (not inside) `~/.claude/`.  Without this single-file mount,
   `@agentclientprotocol/claude-agent-acp` can't see the user's
   existing login and prompts to re-auth inside the sandbox.

2. **Use the new ACP package name in `ACP_PROVIDERS`.**  Upstream
   renamed `@zed-industries/claude-code-acp` → `@agentclientprotocol/
   claude-agent-acp`.  The old name still works but emits an npm
   deprecation warning; the agent-server's own OpenAPI example uses
   the new name.  Test fixtures pinning the legacy name updated to
   match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(acp): import ACP_PROVIDERS from typescript-client

Canvas was carrying its own copy of the ACP provider registry, which
drifted out of sync with the canonical Python SDK source and ended up
encoding an invalid Codex invocation (``@openai/codex acp`` — codex
CLI has no ``acp`` subcommand, so the spawn deadlocked silently with
``Error: stdin is not a terminal`` and no log line).

This change deletes ``src/constants/acp-providers.ts`` and imports the
registry from ``@openhands/typescript-client`` instead, which now
mirrors the Python SDK (see OpenHands/typescript-client#167). The TS
SDK pin in ``package.json`` is bumped to the PR-branch SHA
(``45a803c``) for now; once #167 merges and a new tagged release is
cut, the pin can flip to the tag in a follow-up commit.

Shape changes consumers needed to absorb:
- ``ACPProviderConfig[]`` → ``Record<string, ACPProviderInfo>``
  (lookup by key replaces ``.find``; ``Object.values`` where an array
  is needed)
- ``display_name`` → ``displayName`` (camelCase matches TS conventions)
- ``default_command`` → ``defaultCommand`` (and now ``readonly string[]``;
  components spread into a fresh array before passing to consumers that
  expect mutability)

``ACP_CUSTOM_PRESET_KEY`` is the only ACP-related constant that stays
canvas-local — it's a synthetic sentinel for the "Custom" dropdown
option, not a real provider, so it has no SDK counterpart. Moved to
``src/constants/acp-presets.ts``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(acp): keep ACP_PROVIDERS local to canvas

Reverts the brief detour through `@openhands/typescript-client` for
the ACP provider registry.  Splitting the registry across two repos
adds publish-coordination friction and doesn't actually eliminate
the drift problem — it just moves it from
"canvas vs. python-sdk" to "ts-sdk vs. python-sdk", with extra steps.

Now:

- `src/constants/acp-providers.ts` is the canvas-local copy again,
  with the corrected `codex` command (`@zed-industries/codex-acp`,
  the real ACP-protocol stdio server — not `@openai/codex acp`,
  which is the codex CLI's interactive mode and deadlocks the agent
  handshake when spawned without a TTY).
- The package.json pin reverts to `v0.6.0` (the typescript-client
  release that does not include the unmerged `ACP_PROVIDERS` export
  from #167, which is now closed).
- The split `acp-presets.ts` file is folded back in.

Drift risk between this file and the Python SDK source is tracked
in #587, with a longer-term plan to address it (TS-SDK mirror,
code-gen, or runtime endpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): resolve empty acp_command from registry in adapter

PR #416 ships the Settings → Agent page (and onboarding) with a
"default preset" shortcut that stores ``acp_command: []`` and trusts
the agent-server to resolve it from ``acp_server``.  It doesn't.

The agent-server's ``ACPAgent`` model has no ``acp_server`` field and
no registry resolution — it just hands ``acp_command`` straight to a
subprocess spawn.  Empty list trips ``acp_agent.py:1013`` with
``IndexError: list index out of range``, the agent loop dies silently
inside the agent-server's run thread, and the conversation hangs in
``idle`` with the user's message persisted but never answered.  No
error reaches the UI; from the user's perspective they sent a message
and nothing happened.

Caught while exercising the live ``dev:safe`` stack: a fresh
conversation seeded from the onboarding "Claude Code" tile produced
``Failed to start ACP server: list / IndexError: list index out of
range`` in the agent-server log.

The fix is purely client-side — expand ``acp_command`` against
``ACP_PROVIDERS`` (canvas's local mirror of the Python SDK registry,
see #587) before the payload leaves the adapter, when the user picked
a built-in preset.  ``acp_server: "custom"`` and any unknown key are
left untouched — those genuinely depend on the user's explicit
command, and silently inventing one would mask a real config bug.

Three new adapter tests cover:
- ``acp_command: []`` + ``acp_server: "claude-code"`` → command
  resolved to ``["npx","-y","@agentclientprotocol/claude-agent-acp"]``
- ``acp_command`` omitted entirely + ``acp_server: "codex"`` → same
  resolution path
- ``acp_command: []`` + ``acp_server: "custom"`` → left untouched

2273 tests pass, lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): pass disabled state into SettingsDesktopSidebar

When ACP is the active agent, ``useSettingsNavItems`` correctly tags
the LLM and Condenser entries with ``disabled: true``.  The mobile
drawer (rendered via ``SettingsNavLink``) already respected that.
The desktop sidebar (rendered via ``SidebarNavLink``, came in with
the recent sidebar refactor) was constructing the link without
forwarding the flag, so both items stayed fully clickable / styled
as enabled while the conversation was running on an ACP subprocess.

Two tiny changes:

1. ``SettingsDesktopSidebar`` passes ``renderedItem.disabled`` through
   to ``SidebarNavLink``.  That alone gives the right visual state
   (``opacity-50``, ``pointer-events-none``) and keyboard behaviour
   (``tabIndex=-1`` + ``onClick preventDefault``) — both already
   implemented by ``SidebarNavLink``.
2. ``SidebarNavLink`` additionally sets ``aria-disabled="true"`` when
   ``disabled``, closing a screen-reader gap that existed independently
   of this regression (the link sounded actionable to assistive tech
   even though it wasn't).

The ``clientLoader`` redirect in ``routes/settings.tsx`` continues to
handle direct URL navigation to a disabled-by-ACP page, so even if
someone bookmarks ``/settings/condenser`` and lands there while ACP
is active, they get bounced to ``/settings/agent``.

Two new tests in ``settings-navigation.test.tsx``:
- Disabled-by-ACP items in the desktop sidebar carry ``aria-disabled``.
- Enabled items don't.

2275 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): address PR #416 review feedback

Addresses both human and all-hands-bot review comments on #416:

**Critical bugs fixed**

- ``acp_args`` duplication on load (bot critical #1): the textarea
  is the single source of truth for the launch tokens, but save only
  wrote ``acp_command`` — any API-set ``acp_args`` survived and
  concatenated at spawn time. Save now always writes ``acp_args: []``.

- ``tokenizeCommand`` corrupted quoted Custom commands (human bug #2):
  ``bash -c "echo hello"`` got split into
  ``["bash","-c","\"echo","hello\""]`` and silently misbehaved. New
  ``src/utils/acp-command.ts`` wraps ``shell-quote`` with selective
  re-quoting (so ``npx -y @org/pkg`` renders verbatim, not ``\@org/pkg``)
  and filters non-string entries (redirects, env-var refs) out of the
  parsed argv. Round-trip tests pin the contract.

- Loader/component settings cache mismatch (bot critical #4): loader
  used ``SETTINGS_QUERY_KEYS.byScope("personal")``; ``useSettings``
  used ``[...byScope("personal"), backend.id, orgId]``. They didn't
  share cache. Aligned + set ``staleTime: 0`` on the loader read so
  cross-tab kind flips are picked up immediately (the in-render hook
  keeps its 5-minute stale window).

- ``getFirstAvailablePath`` ignored the new agent route (human bug #3):
  ``/settings/agent`` now precedes the others in the fallback list,
  so first-time / hide_llm_settings users land on the agent picker
  rather than ``/settings/app``.

- ``ACP_SETTINGS_KEYS`` documentation (human #4): pre-empts the
  "why not trim this list to UI-visible fields" question by spelling
  out that it serves as both the ACP allow-list and the OpenHands
  deny-list — trimming would silently leak API-set ``acp_*`` state.

**Refactor (human #2 + #3)**

- ``description_key`` moves into ``ACP_PROVIDERS``; the onboarding
  ``AGENT_OPTIONS`` is now derived from the registry so adding a new
  provider only needs one edit.
- One ``buildAcpAgentSettingsDiff`` helper replaces the two near-copies
  in ``choose-agent-step.tsx`` and ``agent-settings.tsx``; both call
  sites are now under a single contract for the agent_settings_diff
  shape.

**UX (bot)**

- Onboarding progress bar shows the actual visited-step count when
  the LLM step is skipped (3 segments for ACP, 4 for OpenHands).
  Previously segment 2 popped "completed" on a slide the user never
  visited.

**Test coverage (bot)**

- New ``__tests__/utils/acp-command.test.ts`` covers parseCommand /
  formatCommand round-trips, quoted args, embedded escapes, shell-
  operator filtering, package-style tokens.
- Adapter: empty ``acp_model: ""``, unknown ``acp_server`` key,
  ACP→OH→ACP round trip (no field leakage either direction).
- agent-settings: cleared input keeps Save disabled, whitespace-only
  same, full Custom command with quoted args round-trips through
  shell-quote.
- choose-agent-step: provider switching (claude-code → codex) rebuilds
  the diff cleanly, no leak from the prior selection.

**Acknowledged (no action)**

- Bot critical #2 (supply chain drift) — same problem as the existing
  agent-canvas#587, already tracked.
- Bot critical #3 (desktop sidebar disabled) — fixed in 27a3e79 a few
  commits before this review was written; review snapshot was stale.
- Translation duplication (human #1) — matches the existing
  ``translation.json`` convention (every key has all 15 locales).
- Option-bag → split functions (human #5) — cosmetic; defer.

2292 tests pass, lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): pre-bundle shell-quote so the Vite dev server can load it

``shell-quote`` is a CommonJS module that does ``module.exports = {
parse, quote }``. The previous commit wired it into ``src/utils/
acp-command.ts`` with a named ESM import, which the dev server
rejected on the first ``agent-settings.tsx`` load:

    SyntaxError: The requested module '/node_modules/shell-quote/
    index.js?v=...' does not provide an export named 'parse'

Switching to a namespace import (``import * as shellQuote from
"shell-quote"; const { parse, quote } = shellQuote;``) makes the
named-export check pass, but Vite then served the raw CJS file
to the browser unchanged and the next request died with:

    ReferenceError: exports is not defined

This second failure is because ``vite.config.ts`` sets
``optimizeDeps.noDiscovery: true`` — new dependencies must be listed
in ``optimizeDeps.include`` or Vite won't run them through its
CJS-to-ESM prebundler. Adding ``"shell-quote"`` there fixes it; the
existing entry has a comment block explaining the same constraint
for other deps. The Rollup-based prod build was unaffected.

Verified: dev server boots clean, ``GET /settings/agent`` returns
200, no ``exports is not defined`` in the Vite client log, 9 unit
tests in ``__tests__/utils/acp-command.test.ts`` pass on the Node
test runner (vitest) where the CJS interop already worked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): address second-pass review on PR #416

Addresses the second all-hands-bot review's critical + improvements:

**Critical: load path mis-merged acp_command + acp_args**

Settings stored with the registry-default shortcut (``acp_command:
[]``, ``acp_server: "claude-code"``) plus a non-empty ``acp_args``
showed only the args in the textarea — no registry prefix. Saving
then sent ``acp_command: ["--extra-arg"]`` and flipped the preset
to ``custom``, silently losing the ``npx -y @agentclientprotocol/
claude-agent-acp`` prefix. The fix expands the registry default
*before* concatenating with args, so the textarea always shows
the full launch command and round-trips cleanly.

**Improvement: formatCommand drops empty-string args**

``formatCommand(["bash", "-c", ""])`` rendered as ``"bash -c "``
which parsed back to ``["bash", "-c"]``, silently losing the empty
slot. Now quotes empty tokens explicitly so they survive.

**Improvement: desktop sidebar disabled tooltip parity**

Mobile drawer's ``SettingsNavLink`` already showed "Disabled while
{agentName} is active" on greyed-out items; the desktop
``SidebarNavLink`` had no explanation. Added a ``disabledReason``
prop (i18n-agnostic; the caller formats the string) and wrap with
``StyledTooltip`` when disabled-with-reason. ``SettingsDesktopSidebar``
now forwards the formatted message — same UX on both surfaces.

**Test coverage gaps the bot flagged**

- ``agent-settings``: new regression guard for ``acp_command:[]`` +
  non-empty ``acp_args`` load (would have caught the critical bug
  above).
- ``acp-command``: empty-string round-trip case + explicit assertion;
  five more shell-operator filters (pipe, ``;``, ``&&``, ``||``,
  ``>>``).
- ``settings-navigation``: desktop sidebar wraps disabled items in
  StyledTooltip when ``disabledReason`` is supplied; not when omitted.

Plus a clean merge from ``origin/main`` (one-line import conflict
in ``agent-server-adapter.ts``).

2350 tests pass, lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): address third-pass review on PR #416

- parseCommand: try/catch around shell-quote.parse so a malformed
  command in the textarea can't crash Settings → Agent mid-render
- Rewrite shell-metasyntax tests to pin the *actual* shell-quote
  behaviour (it's a parser, not a security filter) — operators,
  globs, and comments are dropped; backticks / $VAR / $(...) survive
  as literal tokens but are NOT expanded at parse time
- Add npm URLs + verification date (2026-05-19) to each ACP_PROVIDERS
  entry so future maintainers can re-check upstream packages
- Document the silent preset-switch behaviour on detectPreset (the
  dropdown follows the textarea; the textarea is the source of truth)
- Use the exported ACP_SERVER_TAG_KEY constant in the adapter test
  so a rename surfaces as a compile error rather than a runtime
  schema mismatch
- Restore the canonical typescript-client lock entry (drop the
  git+ssh:// + SHA bump that crept in from a local npm install)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): don't expose LLM-switch UI on ACP conversations

The SDK's ACPAgent carries a sentinel ``llm`` (``acp-managed``) for
cost-attribution only — the real model lives on the ACP subprocess
via ``acp_model`` and isn't visible on ``agent.llm.model``. Without
this fix, ``toAppConversation`` surfaced the sentinel as the
conversation's ``llm_model``, and the chat header's
SwitchProfileButton happily let users "change the model" while the
running Claude-Code / Codex / Gemini subprocess kept its own. A
confusing silent no-op.

Two layers of defence so no future consumer has to re-derive the rule:

  1. Boundary normalisation: ``toAppConversation`` reads the
     pydantic discriminator (``info.agent.kind === "ACPAgent"``),
     surfaces it as ``agent_kind: "acp" | "openhands"`` on
     AppConversation, and nulls ``llm_model`` for ACP. Mirrors
     OpenHands PR #14401.

  2. UI gate: SwitchProfileButton returns null when
     ``conversation.agent_kind === "acp"``. The right control for ACP
     model switching is the ``acp_model`` field on Settings → Agent,
     not this picker.

Tests cover both: a new ``toAppConversation`` case asserts
``agent_kind === "acp"`` + ``llm_model === null`` for an
``{kind: "ACPAgent"}`` payload, and a new SwitchProfileButton case
asserts the button hides for an ``agent_kind: "acp"`` conversation
even when profiles are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): bridge Settings → Secrets into the ACP subprocess env

The bare ``payload.secrets`` channel lands in the agent-server's
``secret_registry`` server-side, which the OpenHands ``Agent`` reads
directly — but ``ACPAgent._start_acp_server`` builds its subprocess
env from ``agent_context.secrets``, not from the registry. Without a
bridge, a Settings → Secrets entry like ``ANTHROPIC_API_KEY`` is
silently invisible to the ACP CLI (Claude Code, Codex, Gemini), so
users hit "authentication failed" with no on-screen hint that their
configured secret never reached the subprocess.

Mirror the same LookupSecret map onto
``payload.agent.agent_context.secrets`` when ``acpMode === true``,
so the agent-server's existing env-injection loop picks them up.
The bare ``payload.secrets`` channel is also kept (it serves other
consumers + remains the canonical "conversation secrets" wire). The
mirroring fires only when there's something to bridge; non-ACP
payloads are unchanged.

This is a shim. Once canvas pins to an agent-server build that
includes software-agent-sdk PR #3299 (which teaches ACPAgent to
also read from ``state.secret_registry``), the ``if (acpMode)``
branch can be deleted with no behaviour change.

Tests:
- New: ACP payload mirrors customSecrets onto agent_context.secrets
- New: empty customSecrets does NOT synthesize an empty bridge map
- New: non-ACP payload does NOT get an agent_context.secrets bridge
- All 42 adapter tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): hide MCP nav + cloud LLM-model fallback while ACP is active

Two ACP-leak fixes the review surfaced:

MCP page reachable + editable under ACP
- The SDK's ``ACPAgent`` rejects ``mcp_config`` on init (acp_agent.py:845)
  and the canvas adapter already strips it from start payloads, but the
  /mcp route and the Extensions nav still let users add / edit / delete
  MCP servers — silent no-ops against the running subprocess.
- Add a ``clientLoader`` on /mcp that bounces to /settings/agent when
  ``agent_kind === "acp"``. Grey out the MCP item in
  ExtensionsNavigation with the same explanatory tooltip the LLM /
  Condenser items already use under ACP.
- Extract the redirect into ``utils/acp-route-guard.redirectIfAcpActive``
  so /settings and /mcp share one cache-key + redirect-target
  definition. settings.tsx's clientLoader now calls into it.

Cloud chat ``ChatInputModel`` falls back to ``settings.llm_model`` for ACP
- ``toAppConversation`` writes ``llm_model: null`` on ACP conversations
  (commit 8f0efe62), but ChatInputModel did
  ``conversation?.llm_model ?? settings?.llm_model``, resurrecting the
  user's default OpenHands model on a Claude-Code conversation and
  linking to /settings (which is itself ACP-disabled). Gate on
  ``conversation?.agent_kind === "acp"`` and return null instead.

Tests:
- New: ExtensionsNavigation greys MCP under ACP, leaves Skills + non-ACP
  clickable
- New: /mcp clientLoader redirects under ACP, returns null otherwise +
  on settings-fetch errors (no redirect-loop)
- New: ChatInputModel returns null for ACP even when settings has a model
- All 18 affected tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): preserve unknown acp_server on no-op saves + reviewer cleanups

Fourth-pass review (PR #416 review comment 4486154133). Triage:

Fixed:
- **Unknown ``acp_server`` demoted to ``"custom"`` on save** (P1, real
  data corruption). A user with ``acp_server`` set out-of-band to a
  provider canvas's registry doesn't carry yet (e.g. a future provider,
  or one removed from the local mirror) would open Settings → Agent
  and lose the original key on the next Save — ``detectPreset`` routes
  every unknown server to ``ACP_CUSTOM_PRESET_KEY``. Now we capture
  the loaded ``acp_server`` + textarea at load time, and on save —
  when both are unchanged and the loaded key is non-empty,
  non-``"custom"``, and absent from ``ACP_PROVIDERS`` — pass it back
  verbatim via a new ``allowUnknownServer`` opt on
  ``buildAcpAgentSettingsDiff``. Editing the command still demotes
  to ``"custom"`` (user is configuring a new thing, so the preset
  name follows the command).
- **Dead ``...existingContext`` spread** in the ACP secret bridge.
  ``createAgentFromSettings`` never populates ``agent_context`` on the
  ACP branch, so the spread always merged into ``{}``. Direct
  assignment — and a comment explaining why a deep-merge would be the
  wrong direction (ACPAgent only treats ``secrets`` as acp_compatible).
- **Misleading ``$VAR`` test comment**. Reworded to lead with the
  no-leak contract (host env values must not end up in the persisted
  ``acp_command``) rather than the implementation-detail tangent.

Documented but not changed:
- **``acp_args: []`` "data loss" concern** — false alarm. Load merges
  ``acp_command + acp_args`` into the textarea before render; save
  persists the merged tokens as ``acp_command`` with ``acp_args: []``.
  Round-trip is correct. Added an inline comment on the load merge so
  the next reviewer doesn't re-flag the reset.
- **Silent preset migration without user feedback** — by design.
  The dropdown re-derives from the textarea so it always reflects
  what will be saved; adding a toast on every keystroke would be
  noise. Already documented as intentional on ``detectPreset``.

Tracked elsewhere:
- Supply-chain drift / npm verification: agent-canvas#587.
- Gemini onboarding icon: agent-canvas#621.

Tests:
- New: ``preserves an unknown loaded acp_server when the user saves
  without editing``
- New: ``demotes an unknown loaded acp_server to 'custom' when the
  user edits the command``
- All 73 affected tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): stop silently corrupting argv, restore AgentContext, fix home-screen gating

Three real review findings, all wired:

1. ``parseCommand`` silently dropped URL tokens with ``?`` query strings
   (and any other shell-glob metacharacter). Reproducer:

     node acp.js --endpoint https://example.com/acp?tenant=abc

   ``shell-quote.parse`` read ``?tenant=abc`` as a glob pattern and
   emitted a non-string AST node; the ``.filter(string)`` then dropped
   the URL entirely, persisting ``["node","acp.js","--endpoint"]``.
   Replaced ``shell-quote.parse`` with a small custom argv tokenizer
   that handles single/double quotes + backslash escapes and treats
   every other character — ``?``, ``*``, ``$``, ``|``, ``>``, ``#``,
   ``&``, ``;``, ``(``, ``)``, backticks — as literal. The agent-server
   passes the argv straight to ``subprocess.create_subprocess_exec``
   anyway (no shell intermediary), so the literal-only model matches
   what actually happens at spawn time. ``shell-quote.quote`` is
   still used by ``formatCommand`` for output.

2. The ACP path skipped the ``agent_context`` block that the OpenHands
   path seeded with ``load_public_skills`` / ``load_user_skills`` /
   optional ``system_message_suffix``. All three are marked
   ``acp_compatible: true`` on the SDK ``AgentContext`` model — the
   ACP CLI renders them via ``ACPAgent._render_suffix`` — so ACP
   conversations were silently shipping a smaller system prompt than
   OpenHands ones. ``createAgentFromSettings`` now seeds the same
   block on both branches. The secret bridge below merges into that
   block (was overwriting it) so ``{ secrets }`` no longer wipes the
   skill flags.

3. ``ChatInputModel`` and ``SwitchProfileButton`` only checked
   ``conversation?.agent_kind``. On the home screen (and during the
   task-startup window) ``conversation`` is undefined, so the
   per-conversation check missed and both surfaces fell back to
   ``settings.llm_model`` / the LLM-profile picker — even when
   ``settings.agent_settings.agent_kind === "acp"`` made it clear
   the next-created conversation would be ACP. Added a settings
   fallback so both controls hide consistently with the rest of the
   ACP nav gating.

Tests:
- parseCommand: new "preserves URLs with query strings" + "preserves
  URLs with multiple query params" + "preserves shell metacharacters as
  literal argv tokens" cases; the old "filters operator" cases flipped
  to "preserves operator as literal". 17 parseCommand cases pass.
- adapter: assertion on the ACP payload's ``agent_context`` updated to
  expect the skill flags instead of ``undefined``.
- chat-input-model + switch-profile-button: new "hides on the home
  page when ACP is the default agent" cases.
- 83 tests pass across the 5 affected files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): two chat-rendering UX glitches on streaming ACP tool calls

1. Half-formed ACP tool-call cards flashed in the chat before the
   final state arrived. ACP servers stream multiple events per
   ``tool_call_id`` (status flips ``in_progress`` → ``completed`` /
   ``failed``); the intermediate events carry partial
   ``raw_input`` / ``raw_output`` / ``title``. The previous gate
   suppressed only ``in_progress`` and let ``null`` through (a
   "backwards compat" carve-out for older agent-server builds that no
   longer apply at our pinned version). Streaming intermediates often
   arrive without a status set yet, so they leaked.

   Tighten ``shouldRenderEvent`` to require ``status === "completed" ||
   "failed"``. ``handleEventForUI`` already collapses by
   ``tool_call_id`` in place, so the terminal event lands at the
   original position once it arrives — no flash, no double-render.

2. "Reading Read /Users/foo/bar" — Claude Code emits titles like
   ``"Read /Users/foo/bar"`` for a read tool, and our i18n template
   ``"Reading <cmd>{{title}}</cmd>"`` then doubles up the verb.

   Add ``stripRedundantTitlePrefix`` keyed by ``tool_kind``: read →
   strip ``"Read"``, edit → strip ``"Edit"`` / ``"Write"``, execute →
   strip ``"Bash"`` / ``"Run"``, fetch → strip ``"Fetch"`` /
   ``"WebFetch"``. Boundary-checked via trailing whitespace so a token
   like ``"Reads-from"`` is left alone. English-only on purpose: ACP
   servers are anglophone and emit english titles regardless of the
   user's canvas locale; matching translated verbs would go stale the
   moment a new server is added. Titles already lacking a redundant
   prefix (the OpenHands ACP wrapper, future servers) round-trip
   verbatim — the strip is a no-op there.

Tests:
- ``shouldRenderEvent``: ``null`` status now flips to false +
  comment explains why (treated as in-flight, not legacy).
- New ``stripRedundantTitlePrefix`` describe block covers the four
  tool kinds, the no-op case, the word-boundary guard, ``tool_kind:
  null`` (no strip), and empty titles.
- 53 tests pass across the conversation-events helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): drop React Router type import on /mcp clientLoader (CI build)

CI's ``build:lib`` failed with:

  src/routes/mcp.tsx(3,23): error TS6059: File '.../.react-router/types/
  src/routes/+types/mcp.ts' is not under 'rootDir' '/src'.

``tsconfig.lib.json`` sets ``rootDir: "src"`` and pulls in
``src/components/**/*.tsx``. ``src/components/settings/index.ts``
re-exports from ``routes/mcp-settings``, which imports ``routes/mcp``
— so the lib's typecheck graph reaches ``routes/mcp.tsx`` and trips
on the generated ``./+types/mcp`` import that lives under
``.react-router/types/``, outside the lib's rootDir. (``routes/
settings.tsx`` uses the same import pattern but isn't reachable from
the lib graph, which is why local typecheck passed.)

Drop the type import and declare the loader with no parameters —
matches the existing ``index-redirect`` and ``mcp-settings-redirect``
loader pattern. Test calls collapsed to ``clientLoader()`` to match
the new signature.

``npm run build:lib`` now passes locally; 248 tests pass across the
affected suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(acp): tighten adapter comments around SDK refs

Two reviewer-flagged comment fixes:

- ``ACP_SETTINGS_KEYS`` docblock no longer claims there's a matching
  ``ACP_SETTINGS_KEYS`` constant in the Python SDK (there isn't;
  the fields are model attributes on ``ACPAgentSettings``). Reworded
  to "Keep aligned with the ``acp_*`` fields on ``ACPAgentSettings``
  in ``openhands-sdk/openhands/sdk/settings/model.py``" with an
  explicit "no matching SDK constant — hand-maintained" note, and
  cross-linked to the existing #587 drift tracker.

- ``createAgentFromSettings`` now spells out where the
  ``acp_compatible`` markers live on each of the three fields we set
  (``system_message_suffix`` L66, ``load_user_skills`` L80,
  ``load_public_skills`` L89 in
  ``openhands-sdk/openhands/sdk/context/agent_context.py``) plus what
  happens when a future SDK bump drops one (422 at conversation start
  → drop the demoted field, don't wrap a workaround). Line refs are
  brittle by design — they're the tripwire that surfaces a regression
  here rather than in production.

No behaviour change; lint + 42 adapter tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:04:45 +00:00
..