Files
omnigent-ai--omnigent/tests/e2e_ui/start_session/test_start_session.py
T
Dhruv Gupta bf2e1e9454 feat(harness): add OpenCode (native-server: serve + SSE forwarder + TUI takeover) (#576)
* docs(design): opencode harness + unified harness-interface (draft)

* docs(design): full opencode-native + unified harness-interface design

Covers: harness core (HTTP+SSE), opencode TUI attach takeover, ap-web
integration, opencode optional+runtime-selectable for polly & debby,
and the unified HarnessDescriptor/NativeServerHarness interface.
Supersedes the v1 draft.

* feat(opencode): harness core + unified native-server interface (fronts A, E)

Add the opencode-native harness and the HarnessDescriptor single-registration
that the scattered registries now derive from.

Front A (opencode core):
- opencode_native_bridge/state: per-session bridge dir, XDG roots, auth
  secret, durable launch state.
- opencode_native_client: typed HTTP+SSE client shaped from the pinned
  opencode 1.17.x OpenAPI (sessions/prompt/abort/fork/permission + /event).
- opencode_native_app_server: opencode serve process manager (loopback,
  version-check, readiness) + attach argv/env builders.
- opencode_native_forwarder: SSE -> Omnigent event translation per the
  design table (session.next.* text/tool/step, permission.v2.asked), dedupe,
  reconnect.
- opencode_native_permissions: normalize + once/always/reject mapping.
- inner/opencode_native_executor + harness: thin create_app wrapper built on
  the shared NativeServerHarness base.

Front E (unified interface):
- runtime/harness_descriptors: HarnessDescriptor + HARNESS_DESCRIPTORS, the
  single source of truth; _HARNESS_MODULES / OMNIGENT_HARNESSES /
  HARNESS_ALIASES / NATIVE_HARNESSES now derive from it.
- native_server_transport: NativeServerTransport protocol + dataclasses.
- native_server_harness: shared Executor base for native-server harnesses.
- opencode_http_transport + codex_ws_transport: two concrete transports
  proving the abstraction.

Registries wired for opencode-native: spec allowlist, runtime modules,
aliases, native set, model-override (via native), install metadata,
readiness gating, wrapper label, native_coding_agents, built-in agent
seeding, and runner harness spawn-env.

Co-authored-by: Isaac

* feat(opencode): runner-owned serve + attach terminal takeover (front B)

Add the runner-side native terminal auto-create for opencode-native,
mirroring _auto_create_codex_terminal:
- _opencode_native_launch_config: fetch + validate the session snapshot.
- _auto_create_opencode_terminal: boot opencode serve, resume-or-create the
  OpenCode session, persist external_session_id + bridge state, start the
  SSE forwarder (supervised so the server is closed on teardown), and
  register the `opencode attach` TUI as a streamable terminal resource.
- ensure_native_terminal dispatch branch for terminal_name == "opencode".
- OPENCODE_NATIVE_TERMINAL_ROLE constant.

The forwarder stays live independent of TUI process lifetime, so human
TUI actions keep mirroring into the web transcript.

Co-authored-by: Isaac

* feat(opencode): optional worker for polly/debby + allowlisted args.harness (front D)

Short-term (declared optional worker):
- examples/polly/agents/opencode and examples/debby/agents/opencode: optional
  opencode-native workers, default-off (gated by `opencode` CLI presence).
- polly config: roster up to FOUR sub-agents, preflight probes `opencode`,
  cross-review tracks harness AND model provider (opencode = 4th vendor, not
  independent of same-provider implementers).
- debby config: optional third "OpenCode perspective", default fanout stays
  Claude + GPT; three-way debate only on explicit request.

Long-term (runtime harness override):
- sys_session_send args gains an optional `harness` field.
- tool_dispatch validates it against the sub-agent's
  executor.config.allowed_harnesses allowlist + OMNIGENT_HARNESSES and threads
  it as harness_override into the child create (rejected on by-session-id mode).
- examples/polly/agents/codex opts in via allowed_harnesses:
  [codex-native, opencode-native].
- conversation.harness_override docstring: a sub-agent may carry its OWN
  create-time override (it still never inherits the parent brain's).

The server create route already validates + persists harness_override and the
runner already honors it, so the long-term path works end to end.

Co-authored-by: Isaac

* test(opencode): harness test matrix + conformance suite + scaffold generator (front E)

- tests/harness_conformance/: drift tests asserting every scattered registry
  derives from HARNESS_DESCRIPTORS, plus the NativeServerTransport contract
  driving NativeServerHarness over a fake transport AND both real transports
  (OpenCodeHttpTransport via a fake HTTP server, CodexWsTransport via a fake
  app-server client) — two implementations proving the abstraction.
- opencode unit tests mirroring the codex matrix: bridge state, launch state,
  permissions mapping, HTTP/SSE client (httpx.MockTransport fake server, SSE
  framing), app-server arg/env/version/start, forwarder translation table
  (text/tool/step/permission/dedupe/filter/reconnect), executor turn lifecycle
  (inject/abort/enqueue/image-block/mismatch).
- omnigent/scaffold_harness.py: dev generator for new-harness boilerplate +
  the extension-point checklist.

104 new tests, all green.

Co-authored-by: Isaac

* feat(opencode): wire OpenCode into ap-web native UI (front C)

Mirror codex/pi native-agent wiring for OpenCode:
- OpenCodeIcon (@lobehub/icons/es/OpenCode); "opencode" added to the
  NativeCodingAgentIconKind / ConversationIconKind unions.
- nativeCodingAgents.ts: OpenCode entry (opencode-native-ui / opencode-native,
  sortRank 25, approvalMode) — derived lookup maps pick it up.
- NewChatDialog (display order + builtin set), SubagentsPanel (child icon +
  subagent wrapper label), AgentCard (icon), sidebarNav (icon kind),
  useTerminals (terminal_opencode_main excluded from the shell inventory).
- test-setup.ts: global OpenCodeIcon mock paralleling the Claude/Codex mocks
  (the @lobehub icon import chain breaks under vitest otherwise).
- Tests extended across nativeCodingAgents / AgentCard / useAvailableAgents /
  SubagentsPanel / sidebarNav / useTerminals.

tsc -b clean; vitest 2838 passed / 3 expected-fail / 2 skipped.

Co-authored-by: Isaac

* test(opencode): front D worker discovery + args.harness dispatch + readiness map

- test_opencode_polly_debby_worker: polly/debby specs declare the opencode
  worker; codex worker allowlists the opencode-native override; preflight
  probes opencode; debby keeps it optional.
- test_subagent_harness_override: args.harness extraction + allowlist
  canonicalization helpers.
- harness_readiness test: opencode-native / native-opencode spellings added to
  the configured-harness-map coverage assertion.

Co-authored-by: Isaac

* fix(opencode): eliminate mypy no-any-return at the transport/forwarder JSON boundary

Wrap the opaque JSON-RPC / SSE return values so the typed return contracts
hold (bool / str / Mapping), leaving only the explicit-any annotations the
repo sanctions for opaque JSON payloads (matching the existing codex modules).

Co-authored-by: Isaac

* test: update polly/debby worker-set expectations for the opencode worker

The optional opencode worker joins polly (4 workers, 4 vendors, 7 function
policies) and debby (3 workers, 3 vendors; default fanout still claude+gpt).
Update the brain-harness-override test and the example-bundle parse tests
accordingly.

Co-authored-by: Isaac

* fix(opencode): allowlist-gate args.harness schema + reconcile CI

Front D advertised args.harness unconditionally in the sys_session_send
schema, which broke two tests pinning the base args object to
{input, purpose, model} and diverged from design D.4 (the runtime harness
override is allowlist-gated, opt-in only).

- spawn.py: advertise `harness` in the args object only when at least one
  declared sub-agent opts in via executor.config.allowed_harnesses (mirrors
  the per-child dispatch guard in tool_dispatch.py). Specs without the
  opt-in keep the base {input, purpose, model} contract, so the two pinned
  schema tests stay correct as-is.
- test_sys_session.py: add a test asserting `harness` is present for an
  opted-in sub-agent and absent otherwise (and that a mix opts the tool in).
- test_run_harness_without_agent_e2e.py: exclude opencode-native from the
  live `omnigent run --harness` matrix. It is a terminal-takeover
  native-server harness (same shape as claude/codex-native), so it cannot
  round-trip through this gateway-backed no-AGENT matrix. Fixes E2E shard 1/4.
- test_start_session.py: add a hermetic e2e_ui Playwright test covering the
  OpenCode agent in the new-chat picker (harness-derived "OpenCode" label,
  not the raw "opencode-native-ui") and the terminal-first wrapper labels on
  create.

Co-authored-by: Isaac

* fix(opencode): wire permission policy gate + per-prompt model pin

Addresses blocking cross-vendor review findings on the OpenCode harness.

BLOCKING #1 — security: OpenCode permissions no longer silently auto-approve.
- opencode_native_forwarder.py: the permission ``default_decision`` flips
  from ``allow_once`` to ``reject``. An unconfigured or unreachable policy
  now FAILS CLOSED — a headless OpenCode turn can never silently approve a
  sensitive op. Only an explicit policy ``allow`` reaches ``once``/``always``.
- runner/app.py: wire a real ``policy_evaluator`` at forwarder
  instantiation. ``_build_opencode_policy_evaluator`` POSTs each
  ``permission.v2.asked`` to the session's ``/v1/sessions/{id}/policies/evaluate``
  endpoint as a ``PHASE_TOOL_CALL`` event — the SAME server-side gate
  codex-native's policy hook uses, where an ``ask`` verdict is parked as a
  human approval card and blocks until resolved. Unreachable / non-200 /
  malformed / unresolved-ask all fail closed to deny.
- tests: assert no auto-approve absent policy, explicit allow → once,
  allow_always → always, deny/ask → reject, the evaluator receives the
  normalized policy input, and the runner evaluator's request shape +
  verdict mapping + fail-closed paths.

BLOCKING #2 — OpenCode model override now governs the run from turn one.
- Verified against the OpenCode SDK that ``POST /session`` does NOT accept a
  model (the stale client docstring is corrected); the model is a per-prompt
  field ``{providerID, modelID}``. OpenCodeNativeExecutor now threads the
  session's ``model_override`` (from bridge state) onto every injected
  prompt. OpenCode persists the last-used model as the session default, so
  pinning the first turn also governs later TUI-typed turns — the override
  controls the run from the start, not only a later web turn.
- test asserts the resolved model reaches the prompt body as
  ``{"providerID","modelID"}`` (and is absent when no override is set).

NON-BLOCKING — tighten OpenCode server env isolation.
- opencode_native_app_server.py: drop ``OPENCODE_CONFIG`` /
  ``OPENCODE_CONFIG_CONTENT`` from the env passthrough so the parent shell's
  GLOBAL OpenCode config can't defeat the per-session XDG isolation. Other
  ``OPENCODE_*`` vars (and the server password we set) are unaffected.

BLOCKING #3 (NativeServerHarness migration of codex-native) is NOT included:
a behavior-preserving migration is not safely landable here — see the PR
discussion. codex-native is unchanged; its executor tests stay green.

Co-authored-by: Isaac

* fix(opencode): address AI-review static-analysis nits + add deferral note

Resolve all 11 github-code-quality[bot]/CodeQL findings on PR #576,
all low-severity static-analysis nits with no behavior change:

- opencode_native_executor.py: rename subclass methods so they no longer
  shadow the base NativeServerHarness instance attributes set from the
  injected callbacks (_build_prompt -> _build_prompt_with_model_override,
  _resolve_session_id -> _resolve_opencode_session_id). Bodies unchanged.
- native_server_transport.py: replace every `...` Protocol-method body
  with `raise NotImplementedError` so CodeQL's "statement has no effect"
  doesn't re-flag the stragglers. Interface semantics unchanged.
- opencode_native_bridge.py: document the two intentionally-ignored read
  errors in ensure_auth_secret (missing/unreadable secret => regenerate).

Also append a "Deferred to a follow-up PR" section to the design doc
documenting that codex-native is not yet migrated onto NativeServerHarness
and CodexWsTransport is defined but not wired into any production path.

* fix(opencode): address CodeQL static-analysis nits

- test_opencode_native_forwarder: import the forwarder module one way only
  (consolidate to `import ... as fwd_mod`, drop the duplicate import-from),
  clearing CodeQL "module imported with import and import-from".
- codex_ws_transport / opencode_http_transport: export the client-factory
  type aliases (`CodexClientFactory`, `ClientFactory`) via `__all__`. They are
  the documented annotation for each transport's `client_factory` param, but
  PEP 563 stringifies that use so CodeQL saw them as unused globals.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* docs: drop opencode design doc from the PR (kept locally)

The 2k-line design doc inflated the PR diff without being code under
review. Untracked from the PR tree; it stays on disk locally for reference.

Co-authored-by: Isaac

* feat(opencode): web-UI terminal auto-create + Databricks-gateway provider wiring

Two gaps surfaced by a full-stack host e2e (isolated $HOME, real opencode serve):

1. Web-UI terminal auto-create: opencode-native was MISSING from the runner's
   session-creation terminal dispatch (claude/codex/pi/cursor each have a
   branch; opencode only had the on-demand ensure_native_terminal path). A
   host/web-UI opencode session therefore never booted its opencode serve + SSE
   forwarder + opencode attach terminal, so the UI had no terminal+chat view to
   embed. Add the opencode-native branch alongside the other natives (idempotent
   with the on-demand path via the existing per-session lock).

2. Databricks-gateway provider config: unlike codex/claude/pi (which consume
   HARNESS_*_GATEWAY_* env their CLI translates), opencode reads provider/auth
   from its own config file. Add omnigent/opencode_native_provider.py to resolve
   a gateway from the spec's Databricks profile (via databricks-sdk) and
   synthesize an opencode.json (custom @ai-sdk/openai-compatible provider at
   {host}/serving-endpoints) into the per-session XDG config dir at spawn, with
   the per-prompt model pinned to provider/endpoint. Best-effort: no profile or
   no SDK -> opencode falls back to its ambient provider config.

Tests:
- tests/test_opencode_native_provider.py (13): synthesis shape, 0600 write,
  model normalization, SDK-absent/no-token/success resolution.
- tests/e2e/test_host_opencode_native_e2e.py (opt-in OMNIGENT_E2E_OPENCODE_NATIVE):
  built-in agent registered + host session auto-creates terminal_opencode_main.

Validated against the real Databricks AI gateway (databricks-claude-sonnet-4-6):
resolve -> synthesized opencode.json -> prompt round-trip returns assistant text.

Co-authored-by: Isaac

* fix(opencode): mirror assistant output to the web chat view + add `opencode` alias

#2 (chat view): the SSE forwarder was keyed on a `session.next.*` /
`permission.v2.asked` event vocabulary that opencode 1.17.x never emits, so every
real assistant-text/tool event hit `_HANDLERS.get(...) -> None` and was silently
dropped — the TUI showed the turn but nothing reached the web chat view (the
durable items the chat reads). The old unit tests passed only because they fed
the same fake event names.

Rewrite the handlers against opencode's real PART-based model (verified by
capturing a live `opencode serve` turn):
- text: `message.part.updated`(type=text, role-filtered to assistant) finalized
  into a durable conversation item on `step-finish`/`session.idle`, plus
  `message.part.delta`(field=text) streamed live (ephemeral);
- tools: `message.part.updated`(type=tool) — call posted once its `state.input`
  is populated, output once `state.status` is completed/error (deduped by callID);
- lifecycle: `message.updated`(info.role), `session.status`(busy), `session.idle`;
- permissions: register both `permission.asked` (1.17.x) and `permission.v2.asked`.
Resume-dedupe is made type-aware so a reconnect never re-posts finalized parts.

Validated against a real Databricks-gateway turn: assistant text + bash tool
call/output now post as durable chat items; 17 forwarder unit tests rewritten to
the real event shapes (incl. user-text-not-mirrored + tool-snapshot dedup).

#3 (alias): accept `opencode` as a friendly alias for `opencode-native` (no
separate SDK `opencode` harness exists, so the bare name is free); added to the
descriptor `aliases` + `runtime_aliases`.

Co-authored-by: Isaac

* feat(opencode): show OpenCode in the `omni setup` harness picker

#1 (setup picker): OpenCode was absent from the `omni setup` harness overview, so
there was no obvious place to set it up. Add an OpenCode row (readiness = is the
`opencode` CLI installed) plus a `_manage_opencode_harness` drill-in that installs
the CLI when missing and explains where its credential actually lives — OpenCode
is a native-server harness with no Omnigent-stored key of its own; it routes
through the bound agent's Databricks gateway profile (synthesized into opencode's
per-session config) or ambient OpenAI-/Anthropic-compatible env vars.

Co-authored-by: Isaac

* feat(opencode): `omni opencode` CLI launcher + pin the setup install to 1.17.x

#4 (CLI launcher): `omni --harness opencode-native` errored "No native terminal
launcher wired" because opencode had no `run_*_native` launcher (every native
harness ships its own). Add one, mirroring `omnigent codex` / `omnigent pi`:

- `run_opencode_native` (omnigent/opencode_native.py): ensure a local daemon +
  runner, create-or-resume the `opencode-native-ui` session (whose runner
  auto-creates the `opencode serve` + `opencode attach` terminal — the branch
  added that dispatch), then attach this TTY directly to the runner-owned tmux
  pane. Reuses the shared `native_terminal` / `host.daemon_launch` helpers and
  the same direct-tmux attach codex/pi use.
- An `omnigent opencode` command (resume/--model/passthrough args), and the
  missing `native_agent.key == "opencode"` dispatch arm so
  `omni run --harness opencode-native` routes here too.

Install version pin: `omni setup` → install OpenCode ran `npm install -g
opencode-ai`, but that package's npm `latest` is a broken `0.0.0-beta-*`
pre-release — so it installed a version the runtime version-check rejects. Pin
the install spec to `opencode-ai@~1.17.7` (mirrors the runtime
>=1.17.7,<1.18.0 range), so setup installs a working opencode.

Validated on an isolated-home daemon: the host-created opencode session
auto-creates `terminal_opencode_main` with the `tmux_socket`/`tmux_target`
metadata the launcher attaches to.

Co-authored-by: Isaac

* fix(opencode): stop emitting unreconciled live text deltas to the web chat

Follow-up to the forwarder rewrite. Posting `external_output_text_delta` for
opencode's `message.part.delta` left the web chat view broken: the UI builds a
`live:<message_id>` streaming-preview block from text deltas and only retires it
via a finalize/retire handshake (a `final=True` delta / authoritative done +
itemId reconciliation). The forwarder never completed that handshake and the
committed item carried no correlating id, so the live preview lingered alongside
the separate committed message — duplicated / garbled assistant text in chat
(the terminal/TUI was unaffected).

Drop the live-delta path: forward only the durable `external_conversation_item`
(role=assistant, full text), exactly the codex-native finalized-message path
that renders correctly today. The assistant message now appears cleanly when
each step completes. Removed the now-dead `_on_part_delta` / `_post_text_delta`
/ `next_text_index` / `_EXTERNAL_TEXT_DELTA`.

Live token-by-token streaming is deferred to a follow-up: it must match the web
UI's live-preview retire protocol (claude-native style) and be verified against
the real chat renderer, which can't be checked from a headless harness.

Reproduced via a real gateway turn: before, the forwarder posted a delta
(message_id `opencode:ses:text:prt`) AND a committed item (response_id `ses`)
with no correlation; after, only `running` → assistant item → `idle`.

Co-authored-by: Isaac

* fix(opencode): per-turn response_id so chat messages keep conversation order

Reported symptom: in the web chat, all assistant messages clustered together,
separated from the user messages, instead of interleaving per turn.

Cause: the forwarder stamped EVERY mirrored item with
``response_id = opencode_session_id`` — a single constant for the whole
session. The chat view groups items into a "response" by ``response_id``, so a
constant id collapsed every turn's assistant text/tool items into one response
block, which the renderer placed at the first item's position — pulling all
assistant output above the later user messages. (codex-native avoids this by
stamping a per-turn response id.)

Fix: stamp each item with opencode's per-assistant-message ``messageID`` as the
``response_id`` (falling back to the session id only when unknown), so each
turn is its own response group and items order by position as a normal
conversation. Threaded the messageID through `_post_assistant_text` /
`_post_tool_call` / `_post_tool_output` and the text/tool handlers.

Verified on a real 2-turn gateway conversation: the two assistant messages now
carry two DISTINCT response_ids (were one shared id before). Added a unit test
asserting per-turn response_ids + response_id assertions on the existing
text/tool tests.

Co-authored-by: Isaac

* fix(opencode): mirror user messages in the forwarder so chat keeps turn order

Reported: the web chat showed every assistant message clustered first, then the
user messages out of order (and one missing) — while the TUI was correct.

Root cause: for native-server harnesses the forwarder is the SOLE source of the
conversation transcript — omnigent does NOT separately persist a user item for
these sessions (the runner mirrors the native transcript; cf. runner/app.py's
`is_native_harness` history gate, and codex-native's `_post_user_message` /
`_ensure_user_message_posted`, which exist precisely because omnigent doesn't
record it). The opencode forwarder SKIPPED user-role text, so user messages were
never durably recorded; the chat only showed transient optimistic echoes —
inconsistent and unordered. (The earlier per-turn response_id fix was necessary
but not sufficient: the user items weren't being persisted at all.)

Fix: mirror the user message in the forwarder. On a user-role `message.part.updated`
text part, post a `role=user` conversation item EAGERLY (deduped by part id) so it
takes an earlier position than its assistant reply — matching codex-native. User +
assistant now interleave by turn. Resume dedupe pre-marks user-text parts too.

Unit-tested (forwarder now posts user-before-assistant, deduped, with a per-turn
response_id). The full multi-turn render is covered by the opt-in host e2e
(`test_opencode_native_multiturn_item_order`, asserts strict user/assistant
interleaving) for CI + manual QA.

Co-authored-by: Isaac

* chore(opencode): drop the 35k-line vendored OpenAPI dump from the PR

The vendored `omnigent/opencode/openapi-1.17.7.json` (34,576 lines) was ~80% of
the PR diff and made it unreviewable (goose's comparable harness PR is ~5k). It
was added to make the descriptor's `openapi_schema` reference real, but the
typed client is hand-maintained and the live wire-contract e2e
(`test_opencode_native_wire_contract_e2e`, opt-in) validates it against a real
`opencode serve` — a far better drift guard than a checked-in schema dump.

Remove the file and the descriptor's `openapi_schema` field (defaults to None).
The conformance check that vendored schemas exist still guards any future
descriptor that sets the field; it just skips when none do.

Co-authored-by: Isaac

* feat(opencode): make the `omni setup` OpenCode section manage providers

Before, the OpenCode setup drill-in just printed a static note — it did nothing
useful. Now it mirrors the Goose/Qwen pattern.

New read-only reporter `omnigent/onboarding/opencode_auth.py`
(`opencode_auth_summary`): reads OpenCode's own credential state — stored
providers from `~/.local/share/opencode/auth.json` (XDG_DATA_HOME-aware, JSON
keyed by provider id per the OpenCode source) + detected provider env keys
(OPENAI_API_KEY / ANTHROPIC_API_KEY / …). Robust: reads auth.json directly
rather than scraping `opencode auth list` output.

The drill-in now reports which providers OpenCode can reach and offers
`opencode auth login`, `opencode auth list`, and a help note — never storing a
key through Omnigent (OpenCode owns its auth; the Databricks-gateway path stays
the agent profile synthesized into opencode's per-session config). The setup
overview row's ✓/✗ now reflects real readiness (CLI installed AND a provider
reachable), not just the binary being present.

+ unit tests for the reporter (auth.json parsing, env detection, readiness).

Co-authored-by: Isaac

* refactor(opencode): ship the harness the scattered way; defer the unified interface

Splits PR #576 in two. This PR adds OpenCode as a harness exactly like
goose/qwen/cursor-native were added — scattered registration across the
hand-maintained registries — and DEFERS the unified-interface refactor
(the single-source ``HarnessDescriptor`` registry, the descriptor-parity
conformance suite, and the harness scaffold generator) to a follow-up so this
PR can be reviewed as a focused harness addition.

Removed (moves to the follow-up):
- omnigent/runtime/harness_descriptors.py — the HarnessDescriptor registry.
- omnigent/scaffold_harness.py — the new-harness scaffold generator.
- omnigent/codex_ws_transport.py — the (unused) codex WS transport that
  generalized the native-server transport for a future codex migration.
- tests/harness_conformance/ — the descriptor-parity / transport-contract /
  scaffold conformance suite.

Re-scattered the registration that Front E had made descriptor-derived, adding
OpenCode the old way alongside the existing harnesses:
- runtime/harnesses/__init__.py: ``_HARNESS_MODULES`` back to a literal dict
  (+ ``opencode-native`` and its ``opencode`` runtime alias).
- harness_aliases.py: ``HARNESS_ALIASES`` / ``NATIVE_HARNESSES`` back to
  literals (+ ``opencode`` / ``native-opencode`` → ``opencode-native``).
- spec/_omnigent_compat.py: ``OMNIGENT_HARNESSES`` / ``OMNIGENT_HARNESS_ALIASES``
  back to literals (+ opencode id and aliases).
- onboarding/harness_install.py: ``_HARNESS_NAME_TO_KEY`` back to the
  alias-keyed map (+ opencode), ``required_cli_for_harness`` back to the direct
  lookup (no ``descriptor_for``).

Decoupled the kept OpenCode runtime from the descriptor registry:
- native_server_harness.py: take ``harness_id`` + ``supports_enqueue`` directly
  instead of a ``HarnessDescriptor``.
- inner/opencode_native_executor.py: pass those literals.
- native_server_transport.py / opencode_http_transport.py: drop the
  CodexWsTransport docstring references.

The OpenCode harness itself (executor, forwarder, typed client, app-server,
bridge, permissions, provider, ``omni opencode`` launcher, ap-web wiring,
``omni setup`` section, examples, and its test matrix) is unchanged. ruff
clean; opencode + registry + spec + dispatch suites green.

Co-authored-by: Isaac

* style(opencode): apply ruff format + prettier

Green the pre-commit (`ruff format`) and npm-test (`prettier --check`) CI gates:
- ruff format: opencode_native.py, opencode_native_provider.py,
  test_host_opencode_native_e2e.py, test_opencode_auth.py (line-wrapping only).
- prettier: ap-web/src/lib/nativeCodingAgents.ts.

Formatting only — no behavior change.

Co-authored-by: Isaac

* fix(opencode): recover native-server coverage + fix enqueue harness-id

The split removed tests/harness_conformance/, which had been the coverage for
the *kept* native-server runtime (native_server_harness.py +
opencode_http_transport.py), dropping total coverage below the CI gate. Add
focused, Front-E-free unit tests:
- tests/test_native_server_harness.py — drives the transport-agnostic base over
  an in-memory fake transport (run-turn boot-poll / model pin / error branches,
  interrupt, enqueue, capabilities).
- tests/test_opencode_http_transport.py — the prompt-payload builder + every
  transport method over an injected fake OpenCodeClient.

The base test caught a real regression from the descriptor de-coupling: the
enqueue-failure path still referenced the removed ``self.descriptor.id`` (an
AttributeError on that error branch) — now ``self._harness_id``.

Co-authored-by: Isaac

* feat(opencode): pick a default model from `omni setup`

`omni opencode` spawns `opencode serve` with a per-session XDG config (the
user's global ~/.config/opencode is intentionally ignored), so with no model
configured opencode falls back to its built-in default (opencode/big-pickle)
even after `opencode auth login` adds a provider. Add a way to choose the
launch model:

- `omni setup` → OpenCode → "Set default model": lists `opencode models`,
  persists the pick as the `opencode_model` global-config key (+ a Clear
  option). New helpers `_list_opencode_models` / `_set_opencode_default_model`.
- `omni opencode` (no --model) now prefers `opencode_model`, falling back to the
  shared `model` key for back-compat.
- Runner: write the resolved model into the per-session opencode.json at spawn
  (build_opencode_model_default_config) so the TUI and the first turn launch on
  it, not big-pickle — for both the user-provider and Databricks-gateway paths.
- Register `opencode_model` in `_GLOBAL_CONFIG_KEYS` so `omni config` accepts it.

Also registers the `opencode` command in `_CLICK_SUBCOMMANDS` (it was registered
on the CLI group but unreachable from main(), which failed
test_click_subcommands_allowlist_covers_registered_commands).

+ unit tests (provider helper, model picker persist/clear/cancel/empty).

Co-authored-by: Isaac

* test(opencode): cover the `omni opencode` launcher helpers

opencode_native.py (the `omni opencode` launcher) had no direct unit tests —
556 lines of spec-materialization, payload parsing, tmux-attach gating, and
httpx session/terminal helpers sitting uncovered (the biggest single coverage
sink in the harness, and part of why dropping the well-covered Front E modules
pushed total coverage under the gate).

Add tests/test_opencode_native.py covering the unit-testable surface over a
fake AsyncClient: `_materialize_opencode_agent_spec` (model on/off),
`_launched_opencode_terminal_from_payload`, `_direct_tmux_unavailable_reason`,
`_resolve_session_id_for_resume`, and the session/terminal helpers
(`_create_opencode_session`, `_fetch_opencode_session`,
`_ensure_opencode_terminal_on_runner`, `_find_running_opencode_terminal` incl.
404 / not-running / offline-runner branches). Launcher coverage 0% → 56%; the
daemon/tmux attach plumbing stays for the live host e2e.

Co-authored-by: Isaac

* test(opencode): smoke-test the opencode-native harness create_app/factory

inner/opencode_native_harness.py (the `harness: opencode-native` entry point)
was at 0% — add a create_app() FastAPI smoke test + an executor-factory test
(builds OpenCodeNativeExecutor from the spawn env). 0% -> 100%.

Co-authored-by: Isaac

* fix(opencode): seed user auth into the session server so the chosen model works

The runner spawns `opencode serve` with a per-session XDG_DATA_HOME (isolating
session state), which also hid the user's `opencode auth login` credentials
(~/.local/share/opencode/auth.json). Without them the server could only reach
OpenCode's no-auth default (opencode/big-pickle), so `omni opencode` ignored
the selected provider/model — even with the model pinned into opencode.json.

- bridge: `seed_opencode_auth()` copies the user's auth.json into the
  per-session XDG_DATA_HOME at spawn (0600, refreshed each launch); the runner
  calls it before `opencode serve` starts. No-op on a remote runner / the
  Databricks-gateway path (no local auth.json).
- setup: the "Set default model" picker listed every models.dev model
  (hundreds) — overflowing the menu viewport and flickering. Filter to models
  whose provider the user can authenticate (stored auth.json + env keys) via
  the new `reachable_provider_ids()`; fall back to the full list only if that
  filter would hide everything.

+ tests (auth-seed copy/no-op, reachable provider ids).

Co-authored-by: Isaac

* fix(setup): scrolling viewport for the OpenCode model picker (no more flicker)

The model picker still flickered when the reachable-provider model list was
longer than the terminal: select() rendered every row and redrew in place, so a
frame taller than the screen overflowed and flickered.

Add an opt-in scrolling viewport to select(max_visible=...): when set and the
list is longer, it renders only a window of rows that follows the cursor (with
"↑ N more" / "↓ N more" markers), bounding the frame to one screen. Default
(None) renders every row, so all other menus are unchanged. The OpenCode "Set
default model" picker sizes the viewport to the terminal height.

+ tests for the windowed vs full render.

Co-authored-by: Isaac

* test(opencode): raise coverage — test tractable gaps + pragma e2e-only orchestration

The split dropped Front E's well-covered code, dipping total coverage past the
code-coverage ratchet's 0.5% tolerance. Recover it honestly — real unit tests
for the testable surface, and `# pragma: no cover` only on integration-only
orchestration that the live host e2e exercises but unit tests can't.

Unit tests:
- launcher: _preflight_local_tools, _update_startup_progress,
  _direct_tmux_unavailable_reason (tmux-missing / all-present),
  _wait_for_opencode_terminal_ready (found / timeout).
- app-server: find_opencode_cli (absolute exe) + resolve_opencode_version
  (parse / run-error / unparseable).
- client: error + edge branches (non-object bodies, HTTP errors).
- forwarder: seed_dedupe_from_history (resume seeding + best-effort failure).

pragma (e2e-covered, not unit-testable — see tests/e2e/test_host_opencode_native_e2e.py):
- launcher daemon/tmux flow: run_opencode_native, _run_with_remote_server,
  _prepare_opencode_terminal_via_daemon, _attach_terminal_resource,
  _attach_direct_tmux, and the SDK resume picker.
- OpenCodeNativeServer.close().

Co-authored-by: Isaac

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-23 19:04:37 -07:00

1145 lines
50 KiB
Python

"""E2E: starting a new session from the home composer ("/").
The landing composer (``NewChatLandingScreen`` in
``ap-web/src/shell/NewChatDialog.tsx``) owns session creation end to end:
the textarea is the new session's first message and the footer chips —
host, working directory, git worktree — plus the agent picker and its
Advanced settings menu supply every create parameter. Hitting Send POSTs
``/v1/sessions`` and navigates to the new session; there is no modal.
These tests cover the three configuration affordances the user reaches
before sending:
1. **Permission mode** — Claude Code's ``--permission-mode`` choices, in
the agent picker's Advanced settings menu. A non-default pick rides
along as ``terminal_launch_args``.
2. **Working directory** — the file-browser popover behind the working-
directory chip. Browsing into a folder sets the session's
``workspace``.
3. **Git worktree** — the branch chip's popover. Naming a branch attaches
a ``git`` worktree spec to the create.
Why the heavy ``page.route`` stubbing (mirrors
``sessions/test_initial_prompt_session_switch.py``): the e2e harness's
runner is directly tunneled into the server and registers no *host*, and
the host filesystem endpoint has nothing to browse. The composer needs an
online host, an agent catalog, and (for the folder test) a directory
listing the headless harness can't produce, so ``/v1/hosts``,
``/v1/agents``, and ``/v1/hosts/{id}/filesystem`` are faked. The create
``POST /v1/sessions`` is intercepted too: rather than really launch a
session, the handler *captures the request body* — which is the thing
under test (that each selection reached the create call) — and returns a
real pre-seeded session id so the post-send navigation lands somewhere
real. ``/events`` is stubbed so the auto-sent first prompt never dispatches
a real LLM turn.
The async-in-a-fresh-thread shape is inherited from
``test_initial_prompt_session_switch`` for the same reason documented
there: once a pytest-playwright *sync* test has run in the session,
pytest-asyncio can't start a loop on the main thread, so each async body
runs in its own thread via :func:`asyncio.run`.
"""
from __future__ import annotations
import asyncio
import json
import re
import threading
from collections.abc import Coroutine
from typing import Any
from playwright.async_api import Route, async_playwright, expect
# Stubbed host the composer auto-selects (the tunneled runner registers no
# host). Keyed identically in the recent-workspaces localStorage seed.
_HOST_ID = "host_e2e"
# Bare create endpoint: ``/v1/sessions`` with an optional query, but NOT
# ``/v1/sessions/{id}/...`` — so the GET conversation list and the
# agent-discovery scan pass through to the real server while only the POST
# create is faked.
_SESSIONS_RE = re.compile(r"/v1/sessions(\?.*)?$")
# Any host filesystem listing, base (home) or a nested path. ``search``
# matches the substring, so it catches both ``…/filesystem`` and
# ``…/filesystem/home/e2e/projects``; it never matches the bare
# ``/v1/hosts`` list (no ``/filesystem`` segment).
_FILESYSTEM_RE = re.compile(r"/v1/hosts/[^/]+/filesystem")
def _run_in_fresh_loop(coro: Coroutine[Any, Any, None]) -> None:
"""Run *coro* to completion in a dedicated thread with its own event loop.
The e2e_ui suite runs many pytest-playwright **sync** tests in the same
session; once one has run, pytest-asyncio can't start a loop on the main
thread. Running the coroutine from a fresh thread via :func:`asyncio.run`
sidesteps that. Any exception (including assertion failures) is captured
and re-raised on the calling thread so the test fails normally.
:param coro: The coroutine to run to completion.
:raises Exception: Whatever the coroutine raised, re-raised here.
"""
captured: dict[str, Exception] = {}
def _worker() -> None:
try:
asyncio.run(coro)
except Exception as exc:
captured["error"] = exc
thread = threading.Thread(target=_worker)
thread.start()
thread.join()
if "error" in captured:
raise captured["error"]
async def _wait_until(predicate, *, timeout_s: float = 15.0) -> None:
"""Poll ``predicate`` on the event loop until true or timeout.
:param predicate: Zero-arg callable returning truthy when satisfied.
:param timeout_s: Max seconds to wait before failing the test.
:raises AssertionError: If the predicate never becomes truthy.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
if predicate():
return
await asyncio.sleep(0.05)
raise AssertionError(f"condition not met within {timeout_s:.0f}s")
def _agents_body() -> str:
"""Stub body for ``GET /v1/agents``: a single Claude Code agent.
``claude-native-ui`` is the only built-in the picker needs here — its
name is what gates the permission-mode UI (``isClaudeNativeAgent``) and,
ranked first by display name, it auto-selects so no explicit pick is
required. ``harness: null`` keeps the "needs setup" badge off regardless
of the (stubbed) host's readiness map.
"""
return json.dumps(
{
"data": [
{
"id": "ag_claude_e2e",
"name": "claude-native-ui",
"display_name": "Claude Code",
"description": "Anthropic's coding agent",
"harness": None,
"skills": [],
}
]
}
)
def _codex_native_agents_body() -> str:
"""Stub body for ``GET /v1/agents``: the native Codex agent.
``codex-native-ui`` + ``harness: "codex-native"`` is what the frontend
maps (via ``nativeCodingAgents``) to the ``approvalMode`` capability,
gating the Codex approval-mode UI in the Advanced menu. Sole agent, so
it auto-selects and no explicit pick is needed.
"""
return json.dumps(
{
"data": [
{
"id": "ag_codex_e2e",
"name": "codex-native-ui",
"display_name": "Codex",
"description": "OpenAI's coding agent",
"harness": "codex-native",
"skills": [],
}
]
}
)
def _bundle_agents_body() -> str:
"""Stub body for ``GET /v1/agents``: the two harness-overridable bundle agents.
Polly and Debby are multi-agent bundles, not native terminal wrappers, so
their spec declares a brain harness (``harness: "claude-sdk"``) that lands
them in ``BRAIN_HARNESS_LABELS``. That — and the fact that neither is named
``claude-native-ui`` — is what makes the Advanced menu render the **Agent
Harness** radio group instead of Claude Code's permission modes. Polly is
ranked ahead of Debby by ``AGENT_DISPLAY_ORDER``, so it auto-selects and no
explicit agent pick is needed. ``harness: null`` would suppress the section
entirely, so it must be a real harness id here.
"""
return json.dumps(
{
"data": [
{
"id": "ag_polly_e2e",
"name": "polly",
"display_name": "Polly",
"description": "Multi-agent coding",
"harness": "claude-sdk",
"skills": [],
},
{
"id": "ag_debby_e2e",
"name": "debby",
"display_name": "Debby",
"description": "Multi-agent debate",
"harness": "claude-sdk",
"skills": [],
},
]
}
)
def _pi_native_agents_body() -> str:
"""Stub body for ``GET /v1/agents``: the native Pi agent.
``name: "pi-native-ui"`` + ``harness: "pi-native"`` is what the frontend
maps (via ``nativeCodingAgents``) to the display label **"Pi"** and the
pi-native wrapper labels. The wire ``display_name`` is deliberately set to
the raw ``"pi-native-ui"`` to prove the picker derives "Pi" itself
(``displayNameForAgent`` ignores the wire value) rather than echoing the
server — the regression showed the raw "Pi-native-ui" here. Sole agent, so
it auto-selects and no explicit pick is needed.
"""
return json.dumps(
{
"data": [
{
"id": "ag_pi_e2e",
"name": "pi-native-ui",
"display_name": "pi-native-ui",
"description": "Pi coding agent",
"harness": "pi-native",
"skills": [],
}
]
}
)
def _opencode_native_agents_body() -> str:
"""Stub body for ``GET /v1/agents``: the native OpenCode agent.
``name: "opencode-native-ui"`` + ``harness: "opencode-native"`` is what the
frontend maps (via ``nativeCodingAgents``) to the display label
**"OpenCode"** and the opencode-native wrapper labels. As with the Pi stub,
the wire ``display_name`` is deliberately the raw ``"opencode-native-ui"``
to prove the picker derives "OpenCode" itself (the harness→display mapping
wins) rather than echoing the server's raw value. Sole agent, so it
auto-selects and no explicit pick is needed.
"""
return json.dumps(
{
"data": [
{
"id": "ag_opencode_e2e",
"name": "opencode-native-ui",
"display_name": "opencode-native-ui",
"description": "OpenCode coding agent",
"harness": "opencode-native",
"skills": [],
}
]
}
)
def _hosts_body() -> str:
"""Stub body for ``GET /v1/hosts``: one online host the composer picks."""
return json.dumps(
{
"hosts": [
{
"host_id": _HOST_ID,
"name": "e2e-host",
"owner": "e2e",
"status": "online",
}
]
}
)
async def _register_common_routes(
page,
*,
created_session_id: str,
create_bodies: list[dict[str, Any]],
agents_body: str | None = None,
) -> None:
"""Register the host/agent/create/events stubs shared by every test.
:param page: The Playwright page to install routes on.
:param created_session_id: Real pre-seeded session id the faked create
returns, so the post-send navigation lands on a real page.
:param create_bodies: Sink the create ``POST /v1/sessions`` body is
appended to — the assertion target for each test.
:param agents_body: Override for the ``GET /v1/agents`` stub body;
defaults to the single Claude Code agent (:func:`_agents_body`).
"""
resolved_agents_body = agents_body if agents_body is not None else _agents_body()
async def handle_hosts(route: Route) -> None:
await route.fulfill(status=200, content_type="application/json", body=_hosts_body())
async def handle_agents(route: Route) -> None:
await route.fulfill(status=200, content_type="application/json", body=resolved_agents_body)
async def handle_events(route: Route) -> None:
# Swallow the auto-sent initial prompt so no real LLM turn runs.
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"queued": True, "item_id": "ci_e2e"}),
)
async def handle_sessions(route: Route) -> None:
# Capture ONLY the composer's create POST (the thing under test) and
# return a real session id so navigation lands somewhere real.
# Everything else (GET conversation list, agent-discovery scan) goes
# to the real server.
if route.request.method == "POST":
create_bodies.append(route.request.post_data_json)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"id": created_session_id}),
)
else:
await route.continue_()
await page.route("**/v1/hosts", handle_hosts)
await page.route("**/v1/agents", handle_agents)
await page.route("**/v1/sessions/*/events", handle_events)
await page.route(_SESSIONS_RE, handle_sessions)
def test_start_session_select_permission_mode(seeded_session: tuple[str, str]) -> None:
"""Picking a non-default permission mode rides along to the create call.
Selecting "Accept edits" in the agent picker's Advanced settings menu
must (a) surface in the agent chip label as immediate feedback and
(b) reach ``POST /v1/sessions`` as
``terminal_launch_args: ["--permission-mode", "acceptEdits"]``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_permission_mode(base_url, session_id))
async def _drive_permission_mode(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
# Seed a recent working directory for the stubbed host so the
# working-directory chip auto-fills and Send can enable without
# touching the (host-less) file browser. Set before the SPA boots
# so the landing composer reads it on mount.
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Claude Code auto-selects (only built-in, ranked first), so the
# Advanced chip — gated on the Claude-native agent — is present.
await page.get_by_test_id("new-chat-landing-advanced-chip").click()
# All six Claude permission modes render as radio rows.
for mode in ("default", "auto", "acceptEdits", "plan", "dontAsk", "bypassPermissions"):
await expect(
page.get_by_test_id(f"new-chat-landing-permission-{mode}")
).to_be_visible()
await page.get_by_test_id("new-chat-landing-permission-acceptEdits").click()
# The chip label reflects the non-default pick immediately.
await expect(page.get_by_test_id("new-chat-landing-agent-select")).to_contain_text(
"Accept edits"
)
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_claude_e2e", body
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
assert body.get("terminal_launch_args") == ["--permission-mode", "acceptEdits"], body
finally:
await browser.close()
def test_start_session_select_approval_mode(seeded_session: tuple[str, str]) -> None:
"""Picking a non-default approval preset rides along to the create call.
Selecting "Full access" in the agent picker's Advanced settings menu
must (a) surface in the agent chip label as immediate feedback and
(b) reach ``POST /v1/sessions`` as
``terminal_launch_args: ["--sandbox", "danger-full-access",
"--ask-for-approval", "never"]``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_approval_mode(base_url, session_id))
async def _drive_approval_mode(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page,
created_session_id=session_id,
create_bodies=create_bodies,
agents_body=_codex_native_agents_body(),
)
# Neutralize agent discovery so only the stubbed Codex agent
# feeds the picker.
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Codex auto-selects (only built-in), so the Advanced chip —
# gated on the Codex-native agent — is present.
await page.get_by_test_id("new-chat-landing-advanced-chip").click()
# All three Codex approval presets render as radio rows.
for mode in ("default", "full-access", "read-only"):
await expect(
page.get_by_test_id(f"new-chat-landing-approval-{mode}")
).to_be_visible()
await page.get_by_test_id("new-chat-landing-approval-full-access").click()
# The chip label reflects the non-default pick immediately.
await expect(page.get_by_test_id("new-chat-landing-agent-select")).to_contain_text(
"Full access"
)
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_codex_e2e", body
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
assert body.get("terminal_launch_args") == [
"--sandbox",
"danger-full-access",
"--ask-for-approval",
"never",
], body
finally:
await browser.close()
def test_start_session_select_harness(seeded_session: tuple[str, str]) -> None:
"""For a bundle agent (Polly/Debby), Advanced offers an agent-harness pick.
Unlike Claude Code — whose Advanced menu shows permission modes — Polly and
Debby declare a brain harness, so their Advanced menu renders an "Agent
Harness" radio group. Selecting a non-default harness ("Pi") must (a) show
all four harness options, (b) surface the pick in the agent chip label, and
(c) reach ``POST /v1/sessions`` as ``harness_override: "pi"``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_select_harness(base_url, session_id))
async def _drive_select_harness(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page,
created_session_id=session_id,
create_bodies=create_bodies,
agents_body=_bundle_agents_body(),
)
# Neutralize agent discovery so only the stubbed bundle agents
# (Polly/Debby) feed the picker. The landing picker merges
# `/v1/agents` with agents found by scanning the caller's sessions
# (`/v1/sessions?kind=any`); on the shared e2e_ui server, a native
# fork another test left behind sorts ahead of bundle agents and
# auto-selects, so the Advanced chip would open permission modes
# (or nothing) instead of Polly's harness group. Registered after
# _register_common_routes so it wins the kind=any scan.
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
# Seed a recent working directory so the working-directory chip
# auto-fills and Send can enable without touching the file browser.
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Polly auto-selects (ranked ahead of Debby), so the Advanced chip —
# present because Polly declares a harness — opens the harness group.
await page.get_by_test_id("new-chat-landing-advanced-chip").click()
# All four brain harnesses render as radio rows, in registry order.
for harness in ("claude-sdk", "openai-agents", "codex", "pi"):
await expect(
page.get_by_test_id(f"new-chat-landing-harness-{harness}")
).to_be_visible()
await page.get_by_test_id("new-chat-landing-harness-pi").click()
# The chip label reflects the non-default harness immediately.
await expect(page.get_by_test_id("new-chat-landing-agent-select")).to_contain_text(
"Polly (Pi)"
)
await page.get_by_test_id("new-chat-landing-input").fill("debate the design")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_polly_e2e", body
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
assert body.get("harness_override") == "pi", body
finally:
await browser.close()
def test_start_session_pi_native_picker_and_wrapper_labels(
seeded_session: tuple[str, str],
) -> None:
"""Native Pi: the picker shows "Pi" and create carries terminal-first labels.
Covers the user-facing Pi native-agent flow this PR adds:
1. **Picker label/icon** — the agent chip renders the harness-derived
display label **"Pi"** (via ``nativeCodingAgents``), NOT the raw agent
name ``"pi-native-ui"`` the server sends. (The pre-fix bug surfaced the
raw name capitalized as "Pi-native-ui".)
2. **Session-creation wrapper labels** — selecting Pi and sending must POST
``/v1/sessions`` with the terminal-first wrapper labels
(``omnigent.ui: terminal`` + ``omnigent.wrapper: pi-native-ui``) that
make the runner launch the Pi TUI and the web UI render the
Chat/Terminal view.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_pi_native_start(base_url, session_id))
async def _drive_pi_native_start(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page,
created_session_id=session_id,
create_bodies=create_bodies,
agents_body=_pi_native_agents_body(),
)
# Neutralize agent discovery so the picker shows ONLY the stubbed
# built-in Pi. The landing picker merges `/v1/agents` with agents
# found by scanning the caller's sessions (`/v1/sessions?kind=any`);
# on the shared e2e_ui server, sessions other tests left behind
# (e.g. a claude-native fork) would otherwise leak in and — ranking
# ahead of Pi — auto-select, so the chip would read "Claude Code".
# Registered after _register_common_routes so it wins for the
# kind=any scan; the bare POST /v1/sessions create still falls
# through to the capturing handler.
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
# Seed a recent working directory so the working-directory chip
# auto-fills and Send can enable without touching the file browser.
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Pi auto-selects (sole agent). The chip shows the derived label
# "Pi" — and crucially NOT "...native...": the regression rendered
# the raw agent name "Pi-native-ui" when the harness→display
# mapping was missing.
agent_chip = page.get_by_test_id("new-chat-landing-agent-select")
await expect(agent_chip).to_contain_text("Pi")
await expect(agent_chip).not_to_contain_text("native")
await page.get_by_test_id("new-chat-landing-input").fill("explore the repo")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_pi_e2e", body
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
# The terminal-first wrapper labels are the contract that drives the
# runner-owned Pi TUI and the web UI's Chat/Terminal view.
assert body.get("labels") == {
"omnigent.ui": "terminal",
"omnigent.wrapper": "pi-native-ui",
}, body
finally:
await browser.close()
def test_start_session_opencode_native_picker_and_wrapper_labels(
seeded_session: tuple[str, str],
) -> None:
"""Native OpenCode: the picker shows "OpenCode" and create carries labels.
Covers the user-facing OpenCode native-agent flow this PR adds (mirrors
the Codex / Pi native rows):
1. **Picker label/icon** — the agent chip renders the harness-derived
display label **"OpenCode"** (via ``nativeCodingAgents``), NOT the raw
agent name ``"opencode-native-ui"`` the server sends.
2. **Session-creation wrapper labels** — selecting OpenCode and sending
must POST ``/v1/sessions`` with the terminal-first wrapper labels
(``omnigent.ui: terminal`` + ``omnigent.wrapper: opencode-native-ui``)
that make the runner launch the OpenCode TUI and the web UI render the
Chat/Terminal view.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_opencode_native_start(base_url, session_id))
async def _drive_opencode_native_start(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page,
created_session_id=session_id,
create_bodies=create_bodies,
agents_body=_opencode_native_agents_body(),
)
# Neutralize agent discovery so the picker shows ONLY the stubbed
# built-in OpenCode. The landing picker merges `/v1/agents` with
# agents found by scanning the caller's sessions
# (`/v1/sessions?kind=any`); on the shared e2e_ui server, sessions
# other tests left behind (e.g. a claude-native fork) would
# otherwise leak in and — ranking ahead of OpenCode — auto-select,
# so the chip would read the wrong label. Registered after
# _register_common_routes so it wins for the kind=any scan; the
# bare POST /v1/sessions create still falls through to the
# capturing handler.
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
# Seed a recent working directory so the working-directory chip
# auto-fills and Send can enable without touching the file browser.
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# OpenCode auto-selects (sole agent). The chip shows the derived
# label "OpenCode" — and crucially NOT "...native...": the raw
# agent name "opencode-native-ui" must never surface.
agent_chip = page.get_by_test_id("new-chat-landing-agent-select")
await expect(agent_chip).to_contain_text("OpenCode")
await expect(agent_chip).not_to_contain_text("native")
await page.get_by_test_id("new-chat-landing-input").fill("explore the repo")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_opencode_e2e", body
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
# The terminal-first wrapper labels are the contract that drives the
# runner-owned OpenCode TUI and the web UI's Chat/Terminal view.
assert body.get("labels") == {
"omnigent.ui": "terminal",
"omnigent.wrapper": "opencode-native-ui",
}, body
finally:
await browser.close()
def test_start_session_select_folder(seeded_session: tuple[str, str]) -> None:
"""Browsing into a folder sets the new session's working directory.
The composer seeds the working directory to the host's home, then the
user opens the file browser and navigates into a subfolder. The chip
label must follow the navigation and the picked path must reach
``POST /v1/sessions`` as ``workspace``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_folder_selection(base_url, session_id))
async def _drive_folder_selection(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
async def handle_filesystem(route: Route) -> None:
# Home ("/home/e2e") and the bare home listing both show the
# two top-level folders; "/home/e2e/projects" shows its child.
# Absolute paths let the picker pass entries straight through.
path_part = route.request.url.split("?")[0]
if path_part.endswith("/filesystem/home/e2e/projects"):
entries = [
{
"name": "src",
"path": "/home/e2e/projects/src",
"type": "directory",
"bytes": None,
"modified_at": 0,
}
]
else:
entries = [
{
"name": "projects",
"path": "/home/e2e/projects",
"type": "directory",
"bytes": None,
"modified_at": 0,
},
{
"name": "repo",
"path": "/home/e2e/repo",
"type": "directory",
"bytes": None,
"modified_at": 0,
},
]
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"object": "list", "data": entries, "has_more": False}),
)
# Registered last so it wins over the broader **/v1/hosts glob for
# filesystem URLs.
await page.route(_FILESYSTEM_RE, handle_filesystem)
# No recent seed here: with no recent, the composer derives the
# host's home from the filesystem listing and seeds the working
# directory to it, so the chip starts at "e2e" (basename of
# /home/e2e) and the test changes it by browsing.
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Working directory auto-fills to the derived home.
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"e2e"
)
# Open the file browser and navigate into the "projects" folder.
await page.get_by_test_id("new-chat-landing-workspace-chip").click()
await expect(page.get_by_test_id("workspace-picker")).to_be_visible()
await page.get_by_test_id("workspace-picker-entry-projects").click()
# The child listing confirms we navigated in.
await expect(page.get_by_test_id("workspace-picker-entry-src")).to_be_visible()
# Filling the message clicks outside the popover, closing it; the
# chip now shows the navigated folder.
await page.get_by_test_id("new-chat-landing-input").fill("explore the project")
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"projects"
)
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/home/e2e/projects", body
finally:
await browser.close()
def test_start_session_create_folder(seeded_session: tuple[str, str]) -> None:
"""Creating a folder in the picker makes it the session's workspace.
The user opens the file browser, navigates into a folder, clicks "New
folder", names it, and confirms. The picker POSTs
``/v1/hosts/{id}/directories``, drops into the freshly created
directory, and the working-directory chip follows. On Send the new
folder's path must reach ``POST /v1/sessions`` as ``workspace`` — i.e.
the agent's working directory is the folder the user just made.
Like the other tests here, the tunneled runner registers no host, so
``/v1/hosts/{id}/directories`` is faked: the handler captures the
requested path and echoes it back as the created absolute path (the
real ``os.makedirs`` never runs in this harness).
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_create_folder(base_url, session_id))
async def _drive_create_folder(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
async def handle_filesystem(route: Route) -> None:
# Home shows "projects"; "/home/e2e/projects" shows its child;
# the freshly created "/home/e2e/projects/new-app" lists empty.
# Deepest match first so the new folder isn't shadowed.
path_part = route.request.url.split("?")[0]
if path_part.endswith("/filesystem/home/e2e/projects/new-app"):
entries: list[dict[str, Any]] = []
elif path_part.endswith("/filesystem/home/e2e/projects"):
entries = [
{
"name": "src",
"path": "/home/e2e/projects/src",
"type": "directory",
"bytes": None,
"modified_at": 0,
}
]
else:
entries = [
{
"name": "projects",
"path": "/home/e2e/projects",
"type": "directory",
"bytes": None,
"modified_at": 0,
}
]
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"object": "list", "data": entries, "has_more": False}),
)
create_dir_bodies: list[dict[str, Any]] = []
async def handle_create_dir(route: Route) -> None:
# Mirror the server's success shape: echo the requested path
# back as the created absolute path. Capturing the body lets
# the test assert the picker sent the joined parent + name.
body = json.loads(route.request.post_data or "{}")
create_dir_bodies.append(body)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"object": "directory", "path": body["path"]}),
)
# Registered after the broad globs so these win for their URLs.
await page.route(_FILESYSTEM_RE, handle_filesystem)
await page.route(re.compile(r"/v1/hosts/[^/]+/directories$"), handle_create_dir)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"e2e"
)
# Open the picker and navigate into "projects" so the new folder
# has a resolved absolute parent to be created under.
await page.get_by_test_id("new-chat-landing-workspace-chip").click()
await expect(page.get_by_test_id("workspace-picker")).to_be_visible()
await page.get_by_test_id("workspace-picker-entry-projects").click()
await expect(page.get_by_test_id("workspace-picker-entry-src")).to_be_visible()
# Create a new folder under /home/e2e/projects.
await page.get_by_test_id("workspace-picker-new-folder").click()
await page.get_by_test_id("workspace-picker-new-folder-input").fill("new-app")
await page.get_by_test_id("workspace-picker-new-folder-create").click()
# The picker POSTs the joined path and drops into the new folder.
await _wait_until(lambda: len(create_dir_bodies) == 1)
assert create_dir_bodies[0]["path"] == "/home/e2e/projects/new-app", create_dir_bodies
# Filling the message closes the popover; the chip now shows the
# folder we just created.
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"new-app"
)
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/home/e2e/projects/new-app", body
finally:
await browser.close()
def test_start_session_add_worktree(seeded_session: tuple[str, str]) -> None:
"""Naming a branch attaches a git worktree spec to the create call.
Opening the worktree chip and entering a branch (plus a base branch)
must (a) surface in the chip label and (b) reach ``POST /v1/sessions``
as ``git: {branch_name, base_branch}``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_add_worktree(base_url, session_id))
async def _drive_add_worktree(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Open the worktree chip and name a branch + base branch.
await page.get_by_test_id("new-chat-landing-branch-chip").click()
await page.get_by_test_id("new-chat-landing-branch-input").fill("feature/login")
# The base-branch input only appears once a branch name is set.
await expect(page.get_by_test_id("new-chat-landing-base-branch-input")).to_be_visible()
await page.get_by_test_id("new-chat-landing-base-branch-input").fill("main")
# The chip label follows the branch name.
await expect(page.get_by_test_id("new-chat-landing-branch-chip")).to_contain_text(
"feature/login"
)
# Filling the message closes the popover, then send.
await page.get_by_test_id("new-chat-landing-input").fill("implement login")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/work/repo", body
assert body.get("git") == {"branch_name": "feature/login", "base_branch": "main"}, body
finally:
await browser.close()
# Session-bound agents the discovery scan returns. Both clone names below root
# to the built-in "claude-native-ui", so the picker must drop both; the fork of
# a fork (two nested suffixes) is the case a single-layer strip missed.
_SINGLE_FORK_NAME = "claude-native-ui (fork ag_aaa11111)"
_FORK_OF_FORK_NAME = "claude-native-ui (fork ag_aaa11111) (fork ag_bbb22222)"
def _fork_scan_body() -> str:
"""Stub body for the ``GET /v1/sessions?kind=any`` agent-discovery scan.
Returns four session-bound agents that exercise every branch of the
picker's shadow-dropping: the built-in's own row (dropped by id), a single
fork and a fork-of-fork of the built-in (both dropped by rooted name), and
one genuinely custom agent (must survive).
"""
return json.dumps(
{
"object": "list",
"data": [
# Binds the built-in's own agent row — dropped by id.
{
"id": "conv_native",
"agent_id": "ag_claude_e2e",
"agent_name": "claude-native-ui",
},
# Single fork of the built-in — dropped by name (one layer).
{"id": "conv_f1", "agent_id": "ag_fork1", "agent_name": _SINGLE_FORK_NAME},
# Fork of a fork — the regression: dropped only if EVERY clone
# layer is stripped before the built-in-name check.
{"id": "conv_ff", "agent_id": "ag_forkfork", "agent_name": _FORK_OF_FORK_NAME},
# A genuinely custom agent — must SURVIVE and be offered.
{"id": "conv_doc", "agent_id": "ag_doc", "agent_name": "doc-writer"},
],
"has_more": False,
}
)
def test_start_session_picker_drops_fork_of_fork_shadows(
seeded_session: tuple[str, str],
) -> None:
"""The landing picker hides fork-of-fork clones of a built-in agent.
The picker (``useAvailableAgents``) merges the built-in list
(``GET /v1/agents``) with session-scoped agents discovered by scanning the
caller's sessions (``GET /v1/sessions?kind=any``), dropping any discovered
agent whose clone name roots back to a built-in. A fork of a fork nests two
clone suffixes — ``"claude-native-ui (fork …) (fork …)"`` — so a single-
layer strip leaves ``"claude-native-ui (fork …)"``, which is not a built-in
name, and the clone leaked into the picker as a SECOND "Claude Code" row.
This drives that regression end to end against the rendered picker: only
the real built-in Claude Code and a genuinely custom agent are offered;
both the single-fork and the fork-of-fork clones are dropped.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_fork_of_fork_dedup(base_url, session_id))
async def _drive_fork_of_fork_dedup(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
async def handle_hosts(route: Route) -> None:
await route.fulfill(
status=200, content_type="application/json", body=_hosts_body()
)
async def handle_agents(route: Route) -> None:
# Sole built-in: claude-native-ui, display "Claude Code".
await route.fulfill(
status=200, content_type="application/json", body=_agents_body()
)
async def handle_scan(route: Route) -> None:
await route.fulfill(
status=200, content_type="application/json", body=_fork_scan_body()
)
async def handle_enrich(route: Route) -> None:
# Only the surviving custom agent reaches the per-agent enrich
# fetch — the dropped shadows never get here.
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"id": "ag_doc",
"object": "agent",
"name": "doc-writer",
"description": "Documentation specialist",
"harness": "claude-sdk",
"skills": [],
}
),
)
await page.route("**/v1/hosts", handle_hosts)
await page.route("**/v1/agents", handle_agents)
# kind=any returns the fork + custom session-bound agents; the bare
# conversation-list GET still falls through to the real server.
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_scan)
# Per-agent enrich fetch for whichever agent survives the dedup.
await page.route(re.compile(r"/v1/sessions/[^/]+/agent$"), handle_enrich)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Open the agent picker dropdown.
await page.get_by_test_id("new-chat-landing-agent-select").click()
# The real built-in Claude Code is offered...
await expect(
page.get_by_test_id("new-chat-landing-agent-ag_claude_e2e")
).to_be_visible(timeout=30_000)
# ...the genuinely custom agent survives...
await expect(page.get_by_test_id("new-chat-landing-agent-ag_doc")).to_be_visible()
# ...and BOTH fork clones of the built-in are dropped. Pre-fix the
# fork-of-fork (ag_forkfork) rendered as a duplicate "Claude Code".
await expect(page.get_by_test_id("new-chat-landing-agent-ag_fork1")).to_have_count(0)
await expect(page.get_by_test_id("new-chat-landing-agent-ag_forkfork")).to_have_count(
0
)
# Exactly two options total: the built-in + the one custom agent —
# no duplicate "Claude Code" sneaks in via a leaked clone.
await expect(page.get_by_role("menuitem")).to_have_count(2)
finally:
await browser.close()