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>
This commit is contained in:
Dhruv Gupta
2026-06-23 19:04:37 -07:00
committed by GitHub
parent 9e30a96d42
commit bf2e1e9454
74 changed files with 9063 additions and 76 deletions
+2
View File
@@ -20,6 +20,7 @@ vi.mock("@/components/icons/CodexIcon", () => ({ CodexIcon: stub("codex") }));
vi.mock("@/components/icons/CursorIcon", () => ({ CursorIcon: stub("cursor") }));
vi.mock("@/components/icons/GooseIcon", () => ({ GooseIcon: stub("goose") }));
vi.mock("@/components/icons/NessieIcon", () => ({ NessieIcon: stub("nessie") }));
vi.mock("@/components/icons/OpenCodeIcon", () => ({ OpenCodeIcon: stub("opencode") }));
vi.mock("@/components/icons/PiIcon", () => ({ PiIcon: stub("pi") }));
vi.mock("lucide-react", () => ({ BotIcon: stub("bot") }));
@@ -48,6 +49,7 @@ describe("AgentCard icon selection", () => {
// "design-reviewer" must still read as Codex, not fall back to bot.
{ name: "design-reviewer", harness: "codex", expected: "codex" },
{ name: "codex-native-ui", harness: "codex-native", expected: "codex" },
{ name: "opencode-native-ui", harness: "opencode-native", expected: "opencode" },
{ name: "claude-native-ui", harness: "claude-native", expected: "claude" },
{ name: "pi-native-ui", harness: "pi-native", expected: "pi" },
{ name: "cursor-native-ui", harness: "cursor-native", expected: "cursor" },
+2
View File
@@ -4,6 +4,7 @@ import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { GooseIcon } from "@/components/icons/GooseIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
import { OpenCodeIcon } from "@/components/icons/OpenCodeIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import type { ComponentType, SVGProps } from "react";
import type { AvailableAgent } from "@/hooks/useAvailableAgents";
@@ -28,6 +29,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
const nativeAgent = nativeCodingAgentForAvailableAgent(agent);
if (nativeAgent?.iconKind === "claude") return ClaudeIcon;
if (nativeAgent?.iconKind === "codex") return CodexIcon;
if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon;
if (nativeAgent?.iconKind === "pi") return PiIcon;
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
if (nativeAgent?.iconKind === "goose") return GooseIcon;
@@ -0,0 +1,3 @@
import OpenCode from "@lobehub/icons/es/OpenCode";
export const OpenCodeIcon = OpenCode;
@@ -107,6 +107,12 @@ describe("useAvailableAgents", () => {
description: null,
harness: "pi-native",
},
{
id: "ag_opencode_native",
name: "opencode-native-ui",
description: null,
harness: "opencode-native",
},
{
id: "ag_nessie",
name: "nessie",
@@ -162,6 +168,14 @@ describe("useAvailableAgents", () => {
harness: "pi-native",
skills: [],
},
{
id: "ag_opencode_native",
name: "opencode-native-ui",
display_name: "OpenCode",
description: null,
harness: "opencode-native",
skills: [],
},
{
id: "ag_nessie",
name: "nessie",
+1
View File
@@ -553,6 +553,7 @@ describe("isAgentTerminalKey", () => {
expect(isAgentTerminalKey("terminal:terminal_tui_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_claude_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_codex_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_opencode_main")).toBe(true);
// pi-native: missing here is what hid the Chat/Terminal pill in
// Terminal view (isShellView wrongly true) for Pi sessions.
expect(isAgentTerminalKey("terminal:terminal_pi_main")).toBe(true);
+1
View File
@@ -63,6 +63,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
"terminal_tui_main",
"terminal_claude_main",
"terminal_codex_main",
"terminal_opencode_main",
"terminal_pi_main",
"terminal_cursor_main",
"terminal_goose_main",
+13
View File
@@ -12,6 +12,10 @@ describe("nativeCodingAgentForHarness", () => {
expect(nativeCodingAgentForHarness("pi-native")?.key).toBe("pi");
});
it("resolves the canonical opencode-native harness", () => {
expect(nativeCodingAgentForHarness("opencode-native")?.key).toBe("opencode");
});
// The server's harness_kind returns the raw executor.config.harness, so a
// `native-pi` agent must fold to the same spec — else fork/switch into it
// would miss the terminal-first wrapper labels and render as chat.
@@ -33,4 +37,13 @@ describe("nativeWrapperLabelsForAgent", () => {
[WRAPPER_LABEL_KEY]: "pi-native-ui",
});
});
it("stamps terminal-first labels for an opencode-native agent", () => {
expect(
nativeWrapperLabelsForAgent({ name: "my-opencode", harness: "opencode-native" }),
).toEqual({
[UI_MODE_LABEL_KEY]: UI_MODE_TERMINAL_VALUE,
[WRAPPER_LABEL_KEY]: "opencode-native-ui",
});
});
});
+11 -1
View File
@@ -4,7 +4,7 @@ export const WRAPPER_LABEL_KEY = "omnigent.wrapper";
export const UI_MODE_LABEL_KEY = "omnigent.ui";
export const UI_MODE_TERMINAL_VALUE = "terminal";
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor" | "goose";
export type NativeCodingAgentIconKind = "claude" | "codex" | "opencode" | "pi" | "cursor" | "goose";
export type NativeCodingAgentCapability = "permissionMode" | "approvalMode";
export interface NativeCodingAgentSpec {
@@ -39,6 +39,16 @@ export const NATIVE_CODING_AGENTS = [
sortRank: 20,
capabilities: ["approvalMode"],
},
{
key: "opencode",
agentName: "opencode-native-ui",
harness: "opencode-native",
wrapperLabel: "opencode-native-ui",
displayName: "OpenCode",
iconKind: "opencode",
sortRank: 25,
capabilities: ["approvalMode"],
},
{
key: "cursor",
agentName: "cursor-native-ui",
+2 -1
View File
@@ -71,7 +71,7 @@ import { AgentRowTooltip } from "@/components/AgentHoverCard";
// returns agents newest-registered first (agent_store.list sorts by
// created_at desc), so pin the order users expect; any agent not listed
// here falls after, in server order.
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "Debby"];
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "OpenCode", "Cursor", "Pi", "Polly", "Debby"];
// Built-in agents (by name slug) — the long-lived agents the server
// ships out of the box. The picker groups these first, then a divider,
@@ -80,6 +80,7 @@ const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "D
const BUILTIN_AGENTS = new Set([
"claude-native-ui", // Claude Code
"codex-native-ui", // Codex
"opencode-native-ui", // OpenCode
"pi-native-ui", // Pi
"cursor-native-ui", // Cursor
"goose-native-ui", // Goose
+20 -1
View File
@@ -35,6 +35,9 @@ vi.mock("@/components/icons/ClaudeIcon", () => ({
vi.mock("@/components/icons/CodexIcon", () => ({
CodexIcon: (props: Record<string, unknown>) => <svg {...props} data-icon="codex" />,
}));
vi.mock("@/components/icons/OpenCodeIcon", () => ({
OpenCodeIcon: (props: Record<string, unknown>) => <svg {...props} data-icon="opencode" />,
}));
// Same marker treatment for the local pi glyph so selection assertions stay uniform.
vi.mock("@/components/icons/PiIcon", () => ({
PiIcon: (props: Record<string, unknown>) => <svg {...props} data-icon="pi" />,
@@ -331,6 +334,11 @@ describe("SubagentsPanel", () => {
labels: { "omnigent.wrapper": "codex-native-ui" },
expectedKind: "codex-native",
},
{
name: "opencode-native wrapper → opencode-native marker",
labels: { "omnigent.wrapper": "opencode-native-ui" },
expectedKind: "opencode-native",
},
{
name: "pi-native wrapper → pi-native marker",
labels: { "omnigent.wrapper": "pi-native-ui" },
@@ -505,7 +513,7 @@ describe("SubagentsPanel", () => {
expect(within(row).queryByText("thread_child_alpha")).toBeNull();
});
it("uses native logos for Claude Code and Codex child rows", () => {
it("uses native logos for Claude Code, Codex, and OpenCode child rows", () => {
mockChildTree({
conv_root: [
childInfo({
@@ -515,6 +523,13 @@ describe("SubagentsPanel", () => {
session_name: "auth-refactor",
labels: { "omnigent.wrapper": "codex-native-ui" },
}),
childInfo({
id: "conv_opencode",
title: "opencode:port-auth-refactor",
tool: "opencode",
session_name: "port-auth-refactor",
labels: { "omnigent.wrapper": "opencode-native-ui" },
}),
childInfo({
id: "conv_claude",
title: "claude_code:review-auth-refactor",
@@ -531,6 +546,10 @@ describe("SubagentsPanel", () => {
expect(codexRow.querySelector('[data-icon="codex"]')).not.toBeNull();
expect(codexRow.querySelector(".lucide-code-2")).toBeNull();
const opencodeRow = childRow(container, "conv_opencode");
expect(opencodeRow.querySelector('[data-icon="opencode"]')).not.toBeNull();
expect(opencodeRow.querySelector(".lucide-code-2")).toBeNull();
const claudeRow = childRow(container, "conv_claude");
expect(claudeRow.querySelector('[data-icon="claude"]')).not.toBeNull();
expect(claudeRow.querySelector(".lucide-code-2")).toBeNull();
+19 -11
View File
@@ -35,6 +35,7 @@ import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { GooseIcon } from "@/components/icons/GooseIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
import { OpenCodeIcon } from "@/components/icons/OpenCodeIcon";
import { OttoIcon } from "@/components/icons/OttoIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import { RunningDot } from "@/components/RunningDot";
@@ -53,6 +54,7 @@ import { AddAgentDialog } from "./AddAgentDialog";
// global and must be preserved across navigation.
const SESSION_SCOPED_PARAMS = ["file", "diff", "comment", "view"] as const;
const CODEX_NATIVE_SUBAGENT_WRAPPER = "codex-native-ui-subagent";
const OPENCODE_NATIVE_SUBAGENT_WRAPPER = "opencode-native-ui-subagent";
// Pi children are scaffold (no wrapper label); the spawn title's agent-type head (``tool``) is the signal.
const PI_AGENT_NAME = "pi";
type AgentRowIcon = ComponentType<SVGProps<SVGSVGElement>>;
@@ -305,6 +307,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
const nativeAgent = nativeCodingAgentForWrapper(wrapper);
if (nativeAgent?.iconKind === "claude") return ClaudeIcon;
if (nativeAgent?.iconKind === "codex") return CodexIcon;
if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon;
if (nativeAgent?.iconKind === "pi") return PiIcon;
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
if (nativeAgent?.iconKind === "goose") return GooseIcon;
@@ -385,8 +388,11 @@ function childPrimaryLabel(child: ChildSessionInfo): string {
// LLM-spawned titles cannot start with "ui:" because the spec validator
// rejects "ui" as a sub-agent name.
const isUserAdded = child.title?.startsWith("ui:") ?? false;
const isCodexNativeSubagent = child.labels?.[WRAPPER_LABEL_KEY] === CODEX_NATIVE_SUBAGENT_WRAPPER;
if (isCodexNativeSubagent && !isUserAdded) {
const childWrapper = child.labels?.[WRAPPER_LABEL_KEY];
const isNativeSubagent =
childWrapper === CODEX_NATIVE_SUBAGENT_WRAPPER ||
childWrapper === OPENCODE_NATIVE_SUBAGENT_WRAPPER;
if (isNativeSubagent && !isUserAdded) {
return child.tool ?? child.title ?? child.id;
}
let titleTask: string | null = null;
@@ -464,15 +470,17 @@ function MainRow({ rootSessionId, isActive }: { rootSessionId: string; isActive:
? ClaudeIcon
: nativeAgent?.iconKind === "codex"
? CodexIcon
: nativeAgent?.iconKind === "pi"
? PiIcon
: nativeAgent?.iconKind === "cursor"
? CursorIcon
: nativeAgent?.iconKind === "goose"
? GooseIcon
: isNessie
? NessieIcon
: BotIcon;
: nativeAgent?.iconKind === "opencode"
? OpenCodeIcon
: nativeAgent?.iconKind === "pi"
? PiIcon
: nativeAgent?.iconKind === "cursor"
? CursorIcon
: nativeAgent?.iconKind === "goose"
? GooseIcon
: isNessie
? NessieIcon
: BotIcon;
// Native wrappers show the product name (mirroring the sidebar) instead
// of the spec's YAML name (e.g. "claude-native-ui"); other agents show
// their agent name, with "main" only while the session loads or when it
+7
View File
@@ -252,6 +252,13 @@ describe("getConversationIconKind", () => {
}),
),
).toBe("codex");
expect(
getConversationIconKind(
conversation("conv_opencode", null, new Date(2026, 4, 14, 9), {
labels: { "omnigent.wrapper": "opencode-native-ui" },
}),
),
).toBe("opencode");
expect(
getConversationIconKind(
conversation("conv_pi", null, new Date(2026, 4, 14, 9), {
+9 -1
View File
@@ -21,7 +21,15 @@ export const CLAUDE_NATIVE_DEFAULT_LABEL = "Claude Code";
export const CODEX_NATIVE_DEFAULT_LABEL = "Codex";
export const PI_NATIVE_DEFAULT_LABEL = "Pi";
export type ConversationIconKind = "claude" | "codex" | "pi" | "cursor" | "goose" | "nessie" | null;
export type ConversationIconKind =
| "claude"
| "codex"
| "opencode"
| "pi"
| "cursor"
| "goose"
| "nessie"
| null;
// Display label for a session with no title and no native-wrapper name —
// shown in the sidebar row and as the browser tab title fallback.
+3
View File
@@ -10,6 +10,9 @@ vi.mock("@/components/icons/ClaudeIcon", () => ({
vi.mock("@/components/icons/CodexIcon", () => ({
CodexIcon: () => null,
}));
vi.mock("@/components/icons/OpenCodeIcon", () => ({
OpenCodeIcon: () => null,
}));
vi.mock("@/components/icons/CursorIcon", () => ({
CursorIcon: () => null,
}));
@@ -0,0 +1,53 @@
spec_version: 1
name: opencode
description: >-
Optional OpenCode native coding-agent perspective for Debby — a
third, implementation-grounded voice used only on explicit request
(OpenCode / coding perspective / three-way debate). Not part of the
default Claude + GPT fanout.
# Native OpenCode harness (opencode-native). Only usable when the `opencode`
# CLI is installed; Debby's prompt keeps this perspective opt-in. No model is
# pinned, so it runs on whatever provider OpenCode resolves.
executor:
type: omnigent
config:
harness: opencode-native
os_env:
type: caller_process
cwd: .
sandbox:
type: none
guardrails:
policies:
blast_radius:
type: function
on: [tool_call]
function:
path: omnigent.inner.nessie.policies.blast_radius
arguments:
gate_pushes: false
prompt: |
You are the OpenCode perspective of Debby, a brainstorming partner. You are a
native coding agent, so your distinctive value is an implementation-grounded
view: what would actually be involved in building or changing this, where the
real friction is, and what a hands-on engineer would notice. Use your tools to
read reference material as needed; don't create or write files unless the user
explicitly asks — your answer is the deliverable.
You are dispatched in one of two modes; the message you receive makes which
one clear:
- ANSWER — you are given a question. Answer it directly and well, leaning on
your implementation-grounded perspective. Be concrete and specific.
- CRITIQUE (debate) — you are given your own previous answer (or the
question) plus another partner's answer. Engage honestly: name what it gets
right, where it is weak or impractical to build, and what you would change.
Then give your own updated answer. Converge toward what is actually correct.
Always return a clear, self-contained response. You are one of the voices the
user sees side by side, so make your reasoning legible on its own.
+26 -3
View File
@@ -39,11 +39,17 @@ executor:
prompt: |
You are Debby, a brainstorming partner with two heads. You never answer a
question from a single model's point of view. You have exactly two plain
sub-agents — neither is a coding agent; each is a pure responder:
question from a single model's point of view. Your two default sub-agents are
plain responders — neither is a coding agent:
- `claude` — a Claude responder (claude-sdk harness).
- `gpt` — a GPT responder (codex harness).
You also have ONE optional, non-default perspective:
- `opencode` — an OpenCode native coding-agent perspective
(opencode-native harness). Only usable when the `opencode` CLI is
installed. Do NOT dispatch it by default; see "Optional OpenCode
perspective" below.
## Your default behavior — always fan out to both
For EVERY substantive question the user asks, dispatch it to BOTH `claude`
@@ -96,12 +102,26 @@ prompt: |
Attribute every view to its source. Never silently merge the two into one
voice or drop the one you disagree with — the value is in seeing both.
## Optional OpenCode perspective
You also have `opencode`, an OPTIONAL OpenCode native coding-agent
perspective. Debby's default product promise stays Claude + GPT — do NOT
dispatch `opencode` by default, and never silently replace `claude` or `gpt`
with it. Use it ONLY when the user explicitly asks for OpenCode, a
coding-agent perspective, a three-way debate, or an implementation-grounded
critique. When you do include it, present it as a third attributed voice
(e.g. `## 🟢 OpenCode`) alongside the other two — never folded into one. If
the user asks for OpenCode but the `opencode` CLI is not installed, say so and
fall back to the default two heads.
## The `debate` skill
When the user asks the partners to debate, argue, critique, stress-test, or
converge — or types `/debate` — load and follow the `debate` skill. It has
you relay each partner's answer to the other for criticism across a
configurable number of rounds (default 1) before converging on a synthesis.
The default debate stays two-way (Claude + GPT). Only include `opencode` as a
third debater when the user explicitly asks for a three-way / OpenCode debate.
## Style
@@ -142,7 +162,10 @@ guardrails:
tools:
# The two brainstorming partners — see agents/<name>/. Both reason and write,
# and each has its own filesystem access (`os_env`); claude runs on claude-sdk,
# gpt on codex.
# gpt on codex. `opencode` is an OPTIONAL third perspective (native
# opencode-native harness); it is declared so the orchestrator CAN reach it on
# explicit request, but the prompt keeps the default fanout to claude + gpt.
agents:
- claude
- gpt
- opencode
+8
View File
@@ -10,6 +10,14 @@ executor:
# with full bypass. The server translates this into
# ``--dangerously-bypass-approvals-and-sandbox`` (native sub-agents only).
yolo: true
# Opt this worker into a runtime harness override: the orchestrator may
# pass ``args.harness: opencode-native`` to sys_session_send to run this
# same scoped-task worker on OpenCode instead of Codex when a different
# vendor is wanted for cross-review. Only harnesses listed here are
# accepted; both runner dispatch and the server create route enforce it.
allowed_harnesses:
- codex-native
- opencode-native
prompt: |
You are Codex, a coding sub-agent dispatched by the polly
@@ -0,0 +1,61 @@
spec_version: 1
name: opencode
description: OpenCode coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree. Optional; only available when the `opencode` CLI is installed.
executor:
type: omnigent
config:
harness: opencode-native
# OpenCode runs its own tools inside the native server. Headless workers
# can't answer approval cards, so the runner-side forwarder admits
# permission requests by policy (allow when no policy is configured),
# matching the codex headless worker posture — there is no `yolo` flag to
# translate for opencode-native.
prompt: |
You are OpenCode, a coding sub-agent dispatched by the polly orchestrator for
a single scoped task in a dedicated git worktree. Your task prompt names its
purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one thing; don't
refactor or wander unprompted.
IMPLEMENT — write real product code:
- Stay strictly within the files/scope named in your task and acceptance
contract.
- Make the change, then drive it to green: run the relevant tests, lint, and
typecheck for the code you touched.
- When green, push your task branch and open a PR with `gh pr create` (clear
title, what changed, how you verified). Never push to a protected branch
(e.g. main) or force-push — open a PR and let it be reviewed/merged.
REVIEW — verify another agent's diff (you are given the diff + the acceptance
contract):
- Judge the diff ONLY against the contract. Do NOT edit code — surface issues
for the orchestrator to route.
- Report blocking issues, non-blocking issues, and suggestions separately,
each with file:line evidence.
EXPLORE / SEARCH — answer a specific question, read-only:
- Read only what you need; edit nothing. Answer with file:line evidence.
Always return a clear, structured result: for IMPLEMENT, what you changed
(file:line) and how you verified it; for REVIEW / EXPLORE, your findings.
Note anything that did not fit the task.
os_env:
type: caller_process
cwd: .
sandbox:
type: none
# Implementers open their own PRs, so push / gh pr create are allowed
# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /,
# hard-reset to a remote ref) is still denied.
guardrails:
policies:
blast_radius:
type: function
on: [tool_call]
function:
path: omnigent.inner.nessie.policies.blast_radius
arguments:
gate_pushes: false
+40 -19
View File
@@ -2,7 +2,8 @@ spec_version: 1
name: polly
description: >-
A coding orchestrator that breaks your goal into pieces and hands them
to a team of Claude Code, Codex, and Pi sub-agents to build. Polly
to a team of Claude Code, Codex, Pi, and (optionally) OpenCode sub-agents
to build. Polly
writes no code itself — it plans and splits up the work, delegates all
of it (investigation / implementation / review), then has a separate
independent different-model reviewer double-check the work before
@@ -56,21 +57,28 @@ prompt: |
a "docs" task starts requiring code changes or real code investigation, STOP
and delegate that part.
You have exactly THREE sub-agents. `claude_code` and `codex` are real CLI
coding harnesses that run in their own terminal — the human can open either
in the UI's Subagents panel and watch or TAKE OVER. `pi` runs headless (no
terminal to open):
You have up to FOUR sub-agents. `claude_code`, `codex`, and `opencode` are
real CLI coding harnesses that run in their own terminal — the human can open
any of them in the UI's Subagents panel and watch or TAKE OVER. `pi` runs
headless (no terminal to open):
- `claude_code` — Claude Code (`claude-native` harness).
- `codex` — Codex (`codex-native` harness).
- `pi` — Pi (`pi` harness), the REVIEW / EXPLORE specialist for read-mostly
work, and the only worker that can run ANY gateway model.
- `opencode` — OpenCode (`opencode-native` harness), a native coding harness
with terminal-takeover support. OPTIONAL: only usable when the `opencode`
CLI is installed (see the roster preflight). Useful as a fourth vendor for
cross-review.
Roster preflight (FIRST turn, before any dispatch). Each worker needs its own
CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `pi` for `pi`.
Before delegating anything, run exactly ONE
`sys_os_shell("command -v claude codex pi || true")`. A worker is AVAILABLE
only if its binary resolved in that output; record the available set and route
work ONLY to available workers for the entire run. Do this in the same first
CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `pi` for `pi`,
`opencode` for `opencode`. Before delegating anything, run exactly ONE
`sys_os_shell("command -v claude codex pi opencode || true")`. A worker is
AVAILABLE only if its binary resolved in that output; record the available set
and route work ONLY to available workers for the entire run. `opencode` is
optional — if it is absent, do not mention it (it is not part of the default
promise); only name a MISSING `claude_code` / `codex` / `pi`. Do this in the
same first
turn you start planning (the preflight `sys_os_shell` call counts as acting —
don't end the turn on the preflight alone). The preflight is SILENT internal
plumbing: do not announce it, explain it, or report its result in chat — when
@@ -92,7 +100,7 @@ prompt: |
turn runs on; it does NOT constrain how many sub-agents you spawn, which
workers, or which models they get. Those choices stay entirely yours.
Delegate ALL coding work through these three via `sys_session_send`, each
Delegate ALL coding work through these workers via `sys_session_send`, each
launched in its OWN git worktree. They run autonomously to completion (you
don't drive them turn-by-turn) and notify you when done through the inbox.
Collect finished worker results with `sys_read_inbox`; use
@@ -145,12 +153,22 @@ prompt: |
wanted.
Cross-vendor verification is the point: review is ALWAYS done by a DIFFERENT
vendor than the implementer — `claude_code`'s PR is reviewed by `codex` or
`pi`, `codex`'s by `claude_code` or `pi`. Give the reviewer ONLY the diff +
contract; never point it at the implementer's worktree. Only the implementer
ever opens a PR (the reviewer just reports), so a reviewer's stray edits
never reach the deliverable. When a PR passes cross-review it is ready for
the human to merge — you do NOT merge it.
vendor than the implementer — `claude_code`'s PR is reviewed by `codex`,
`pi`, or `opencode`; `codex`'s by `claude_code`, `pi`, or `opencode`. Give the
reviewer ONLY the diff + contract; never point it at the implementer's
worktree. Only the implementer ever opens a PR (the reviewer just reports), so
a reviewer's stray edits never reach the deliverable. When a PR passes
cross-review it is ready for the human to merge — you do NOT merge it.
Track BOTH the harness AND the resolved model provider when picking a
reviewer. A reviewer is strongest when it differs from the implementer in
model provider, not just harness. `opencode` adds a fourth harness, but a
harness alone is not a vendor: `opencode` driving an OpenAI model is NOT an
independent cross-vendor reviewer for `codex` (also OpenAI), and `opencode`
driving Anthropic is NOT independent of `claude_code` (also Anthropic). Prefer
a different model provider when one is available; if you only have a different
harness on the SAME provider, say so and treat the review as weaker than a
full cross-vendor review.
Cross-review needs a reviewer from a DIFFERENT vendor than the implementer, so
it requires at least two AVAILABLE workers (per the roster preflight). If
@@ -173,7 +191,7 @@ prompt: |
terminal via `sys_terminal_launch` (it accepts a `cwd` override so you can run
it inside a per-task worktree). NEVER use this terminal to launch coding
agents / sub-agents — all delegation goes through `sys_session_send` to
`claude_code` / `codex` / `pi`.
`claude_code` / `codex` / `pi` / `opencode`.
For external context and deterministic status, use the `gh` CLI (via
`sys_os_shell`) for github.com. Reach for such tools sparingly — only to
@@ -276,12 +294,15 @@ terminals:
tools:
# Coding sub-agents — see agents/<name>/. claude_code and codex are real CLI
# coding harnesses; pi is a headless multi-model worker. Each implements,
# coding harnesses; pi is a headless multi-model worker; opencode is an
# optional fourth native CLI harness (only usable when the `opencode` CLI is
# installed — the prompt's roster preflight gates it). Each implements,
# reviews (cross-vendor), and explores.
agents:
- claude_code
- codex
- pi
- opencode
# Mechanism-layer enforcement — runner-side tool gate, no server change.
guardrails:
+4
View File
@@ -50,6 +50,10 @@ CODEX_NATIVE_WRAPPER_VALUE = "codex-native-ui"
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
PI_NATIVE_WRAPPER_VALUE = "pi-native-ui"
# Value the ``omnigent opencode`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
OPENCODE_NATIVE_WRAPPER_VALUE = "opencode-native-ui"
# Value the ``omnigent cursor`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
CURSOR_NATIVE_WRAPPER_VALUE = "cursor-native-ui"
+382
View File
@@ -189,6 +189,9 @@ _GLOBAL_CONFIG_KEYS: frozenset[str] = frozenset(
"default_agent",
"harness",
"model",
# OpenCode-specific default model (``provider/model``) the native
# ``omni opencode`` TUI launches on; set via `omni setup` → OpenCode.
"opencode_model",
"server",
_AUTO_OPEN_CONVERSATION_CONFIG_KEY,
}
@@ -1171,6 +1174,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
"host",
"lakebox",
"login",
"opencode",
"pane-picker",
"pane-split",
"pi",
@@ -4308,6 +4312,105 @@ def codex(
)
@cli.command(
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
}
)
@click.option(
"--server",
default=None,
help=(
"Remote omnigent URL. Ensures the host daemon, asks the "
"daemon-spawned runner to launch OpenCode, and attaches this TTY. "
'Pass --server "" to auto-spawn a persistent local server in the '
"background and use that instead of a remote one."
),
)
@click.option(
"-r",
"--resume",
"resume",
is_flag=False,
flag_value=_RESUME_PICKER_SENTINEL,
default=None,
help=(
"Resume a prior Omnigent conversation. With a conversation id "
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
"opens an interactive picker scoped to opencode-native sessions."
),
)
@click.option(
"--session",
"session_id",
metavar="SESSION_ID",
default=None,
hidden=True,
help="Deprecated alias for ``--resume <id>``; kept for one release.",
)
@click.option("--model", default=None, help="OpenCode model to use for the native session.")
@click.argument("opencode_args", nargs=-1, type=click.UNPROCESSED)
def opencode(
server: str | None,
resume: str | None,
session_id: str | None,
model: str | None,
opencode_args: tuple[str, ...],
) -> None:
# :param server: Remote Omnigent server URL, or None for local.
# :param resume: None, picker sentinel, or a conversation id.
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
# :param model: OpenCode model id pinned on the wrapper spec.
# :param opencode_args: Pass-through args persisted for the ``opencode attach`` TUI.
"""Launch OpenCode TUI in an Omnigent terminal.
\b
Examples:
omnigent opencode
omnigent opencode --resume conv_abc123
omnigent opencode --resume # interactive picker
omnigent opencode --server https://<app>.databricksapps.com
"""
from omnigent.opencode_native import run_opencode_native
cfg = _load_effective_config()
if server is None:
server = cfg.get("server")
if model is None:
# Prefer the OpenCode-specific default (set in `omni setup` → OpenCode →
# "Set default model"); fall back to the shared `model` key for back-compat.
model = cfg.get("opencode_model") or cfg.get("model")
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
# Validate option combinations before any side effects (see the codex
# command): _ensure_backend can spawn the daemon and take the full
# local-server-discover timeout, which would mask a bad arg pair as an
# outage instead of a usage error.
choice = _split_resume_value(resume)
if session_id is not None and (choice.picker or choice.conversation_id is not None):
raise click.UsageError(
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
# Ensure the host daemon (local when ``--server`` is omitted/empty, remote
# otherwise); the daemon-spawned runner owns ``opencode serve`` + the TUI,
# and this CLI attaches to the tmux terminal.
server = _ensure_backend(server)
resolved_session_id = (
choice.conversation_id if choice.conversation_id is not None else session_id
)
run_opencode_native(
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
opencode_args=opencode_args,
model=model,
auto_open_conversation=auto_open_conversation,
)
@cli.command(
context_settings={
"ignore_unknown_options": True,
@@ -5153,6 +5256,12 @@ def _dispatch_native_terminal_harness(
from omnigent.cursor_native import run_cursor_native
run_cursor_native(cursor_args=passthrough, **common)
elif native_agent.key == "opencode":
from omnigent.opencode_native import run_opencode_native
# OpenCode pins its model on the wrapper spec (like Codex), so it takes
# ``model`` first-class rather than via a ``--model`` passthrough arg.
run_opencode_native(opencode_args=(), model=model, **common)
else: # pragma: no cover - new native agent added without a dispatch arm
raise click.ClickException(f"No native terminal launcher wired for harness {harness!r}.")
return True
@@ -9542,6 +9651,245 @@ def _remove_credential(provider: str) -> str | None:
return f"✓ Removed {label}"
def _launch_opencode_auth_login() -> str | None:
"""Launch interactive ``opencode auth login``; return a post-login status.
``opencode auth login`` is interactive (pick a provider, sign in), so this
hands the terminal to ``opencode`` and re-reads the credential state on
return. Mirrors :func:`_launch_goose_configure`.
"""
from omnigent.onboarding.harness_install import (
OPENCODE_KEY,
harness_cli_installed,
harness_install_spec,
)
from omnigent.onboarding.interactive import console
from omnigent.onboarding.opencode_auth import opencode_auth_summary
if not harness_cli_installed(OPENCODE_KEY):
return "✗ opencode CLI not found"
spec = harness_install_spec(OPENCODE_KEY)
assert spec is not None
console.print(
" [dim]Launching [bold]opencode auth login[/bold] — pick a provider and "
"sign in, then return.[/dim]"
)
with contextlib.suppress(OSError, KeyboardInterrupt):
subprocess.run([spec.binary, "auth", "login"], check=False)
summary = opencode_auth_summary()
if summary.has_provider:
return f"✓ providers: {summary.describe()}"
return "No provider detected yet"
def _run_opencode_auth_list() -> None:
"""Show ``opencode auth list`` (stored credentials + detected env providers)."""
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec
spec = harness_install_spec(OPENCODE_KEY)
if spec is None:
return
with contextlib.suppress(OSError, KeyboardInterrupt):
subprocess.run([spec.binary, "auth", "list"], check=False)
def _list_opencode_models() -> list[str]:
"""Return the ``provider/model`` ids OpenCode can launch (``opencode models``).
Best-effort: an absent CLI or a failed/empty invocation yields ``[]`` (the
caller then tells the user to sign a provider in first).
"""
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec
spec = harness_install_spec(OPENCODE_KEY)
if spec is None:
return []
try:
result = subprocess.run(
[spec.binary, "models"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError):
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def _set_opencode_default_model(current: str | None) -> str | None:
"""Pick OpenCode's default model and persist it as ``opencode_model``.
The choice is what ``omni opencode`` launches on when no ``--model`` is
given written into the per-session ``opencode.json`` at spawn so the TUI
starts on it instead of ``opencode/big-pickle``. Returns a status line for
the drill-in, or ``None`` when cancelled.
:param current: The currently-persisted default model, or ``None``.
"""
from omnigent.onboarding.interactive import console, select
from omnigent.onboarding.opencode_auth import reachable_provider_ids
models = _list_opencode_models()
if not models:
return "✗ no models — sign in to a provider first (opencode auth login)"
# `opencode models` can list hundreds of `provider/model` ids across every
# provider on models.dev — too long for the picker (it overflows the
# viewport and flickers). Narrow to the providers the user can actually
# authenticate (stored auth.json + env keys); fall back to the full list
# only if that filter would hide everything.
reachable = reachable_provider_ids()
if reachable:
scoped = [m for m in models if m.split("/", 1)[0] in reachable]
models = scoped or models
options = list(models)
clear_index = -1
if current is not None:
clear_index = len(options)
options.append("Clear default (use OpenCode's own default)")
default = models.index(current) if current in models else 0
# Even filtered to reachable providers the list can exceed the screen, so
# bound the picker to a scrolling viewport sized to the terminal (leaving
# room for the title / status / footer / "N more" markers).
rows = shutil.get_terminal_size(fallback=(80, 24)).lines
idx = select(
"Pick OpenCode's default model",
options,
default=default,
clear_on_exit=True,
status=f"current: {current}" if current else None,
max_visible=max(5, rows - 8),
)
if idx < 0:
return None
if idx == clear_index:
_save_global_config({}, unset_keys=("opencode_model",))
console.print(" [green]✓ default model cleared[/green]")
return "✓ default model cleared"
chosen = models[idx]
_save_global_config({"opencode_model": chosen})
console.print(f" [green]✓ default model set to[/green] [bold]{chosen}[/bold]")
return f"✓ default model: {chosen}"
def _print_opencode_auth_help() -> None:
"""Explain where OpenCode's model credentials come from."""
from omnigent.onboarding.interactive import console
console.print(
" OpenCode resolves a model from the provider its agent uses:\n"
" • [bold]opencode auth login[/bold] — sign in to a provider (OpenAI, Anthropic, …);\n"
" stored in ~/.local/share/opencode/auth.json.\n"
" • Provider env vars (OPENAI_API_KEY / ANTHROPIC_API_KEY / …) are auto-detected.\n"
" • Databricks gateway: set an agent ``profile`` (configured under Claude / Codex);\n"
" Omnigent synthesizes opencode's per-session provider config from it.\n"
" Omnigent stores no OpenCode credential of its own.\n"
" [dim]Tip:[/dim] 'Set default model' picks which model `omni opencode` launches on\n"
" (otherwise OpenCode uses its built-in default, opencode/big-pickle)."
)
def _manage_opencode_harness() -> None:
"""Run the level-2 drill-in for OpenCode: ensure the CLI, then manage providers.
OpenCode owns its own provider auth ``opencode auth login`` (stored in
``~/.local/share/opencode/auth.json``) or ambient provider env vars so,
like the Goose / Qwen drill-ins, this reports which providers OpenCode can
reach and offers to launch its native login; it never stores a key through
Omnigent. (For the Databricks-gateway path the agent's ``profile`` is
synthesized into opencode's per-session config instead — set under
Claude / Codex.)
OpenCode is npm-installable, so a missing CLI gates the drill-in with an
install offer.
:returns: None. Side effect: may ``npm install`` the opencode CLI.
"""
from omnigent.onboarding.harness_install import (
OPENCODE_KEY,
harness_cli_installed,
harness_install_command,
install_harness_cli,
)
from omnigent.onboarding.interactive import console, select
if not harness_cli_installed(OPENCODE_KEY):
cmd = " ".join(harness_install_command(OPENCODE_KEY))
choice = select(
"OpenCode's CLI isn't installed. Install it now?",
[
f"Yes — install ({cmd})",
"No — back to harnesses",
"I'll run it myself (show the command)",
],
descriptions=[
f"Runs `{cmd}` (needs npm).",
"Return to the harness picker without installing.",
"Print the command so you can install it yourself, then return.",
],
default=0,
clear_on_exit=True,
)
if choice == 0:
console.print(f" [dim]Installing OpenCode — running `{cmd}`…[/dim]")
if install_harness_cli(OPENCODE_KEY):
console.print(" [green]✓ OpenCode installed[/green]")
else:
console.print(
f" [red]Install failed.[/red] Run it manually, then re-open: "
f"[bold]{cmd}[/bold]"
)
return
elif choice == 2: # run it yourself
console.print(f" Install OpenCode with:\n [bold]{cmd}[/bold]")
return
else:
return
# OpenCode owns its provider auth (``opencode auth login`` → auth.json) or
# ambient env keys; Omnigent stores nothing. Report what's reachable and
# offer to run its native login — like the Goose/Qwen drill-ins.
status: str | None = None
while True:
from omnigent.onboarding.opencode_auth import opencode_auth_summary
summary = opencode_auth_summary()
default_model = _load_effective_config().get("opencode_model")
header = (
f"OpenCode — providers: {summary.describe()}"
if summary.has_provider
else "OpenCode — no provider configured yet"
)
model_label = (
f"Set default model (current: {default_model})"
if default_model
else "Set default model"
)
rows: list[_HarnessMenuRow] = [
_HarnessMenuRow("Run opencode auth login", action="login"),
_HarnessMenuRow(model_label, action="model"),
_HarnessMenuRow("List providers & credentials", action="list"),
_HarnessMenuRow("Show provider options", action="help"),
_HarnessMenuRow("← Back", action="back"),
]
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
if idx < 0: # Esc / q
return
action = rows[idx].action
if action == "back":
return
if action == "login":
status = _launch_opencode_auth_login()
elif action == "model":
status = _set_opencode_default_model(default_model)
elif action == "list":
_run_opencode_auth_list()
status = None
elif action == "help":
_print_opencode_auth_help()
status = None
def _run_configure_harnesses_interactive() -> None:
"""Run the interactive model/credential three-level picker.
@@ -9573,6 +9921,7 @@ def _run_configure_harnesses_interactive() -> None:
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
GOOSE_KEY,
OPENCODE_KEY,
QWEN_KEY,
harness_cli_installed,
harness_install_command,
@@ -9620,6 +9969,11 @@ def _run_configure_harnesses_interactive() -> None:
# provider family (its v1 auth is the CLI's own env vars / ``/auth`` flow,
# not an Omnigent credential), so it dispatches to its own drill-in.
_QWEN = "\x00qwen"
# Sentinel marking the OpenCode row — native-server harness with no Omnigent
# credential of its own (it routes through the bound agent's Databricks
# gateway profile or ambient provider env), so it dispatches to its own
# binary-install/info drill-in.
_OPENCODE = "\x00opencode"
# Sentinel marking the Goose row — like Qwen/Antigravity/Cursor it is not a
# provider family (Goose owns its own auth via ``goose configure``, not an
# Omnigent credential), so it dispatches to its own drill-in.
@@ -9754,6 +10108,32 @@ def _run_configure_harnesses_interactive() -> None:
options.append(f" {qwen_sub}")
selectable.append(False)
row_target.append(None)
# OpenCode (native-server harness): readiness is just whether the
# ``opencode`` CLI is installed — it has no Omnigent-stored credential,
# routing through the bound agent's Databricks gateway profile or
# ambient provider env. Its drill-in installs the CLI and explains that.
# OpenCode: ready = CLI installed AND a provider reachable (a stored
# ``opencode auth login`` credential or a provider env key). Drill-in
# manages its native login. (Gateway path uses the agent profile.)
from omnigent.onboarding.opencode_auth import opencode_auth_summary
opencode_summary = opencode_auth_summary()
opencode_ready = opencode_summary.ready
options.append(f"{' ' if opencode_ready else '[red]✗[/] '}OpenCode")
selectable.append(True)
row_target.append(_OPENCODE)
if not opencode_summary.installed:
from rich.markup import escape as _rich_escape
opencode_cmd = _rich_escape(" ".join(harness_install_command(OPENCODE_KEY)))
opencode_sub = f"[dim]not installed — open to install ({opencode_cmd})[/]"
elif opencode_ready:
opencode_sub = f"[green]✓[/] {opencode_summary.describe()}"
else:
opencode_sub = "[dim]installed — open to sign in (opencode auth login)[/]"
options.append(f" {opencode_sub}")
selectable.append(False)
row_target.append(None)
# Goose (its own provider config — no provider family, like Cursor /
# Antigravity / Qwen). Goose owns its auth via ``goose configure``
# (keyring / ~/.config/goose/config.yaml); Omnigent stores no key, so
@@ -9805,6 +10185,8 @@ def _run_configure_harnesses_interactive() -> None:
_manage_antigravity_harness()
elif target == _QWEN:
_manage_qwen_harness()
elif target == _OPENCODE:
_manage_opencode_harness()
elif target == _GOOSE:
_manage_goose_harness()
else: # Quit row (or, defensively, a non-family row)
+7 -3
View File
@@ -121,9 +121,13 @@ class Conversation:
first turn, so a later switch would orphan the running
process. Only valid for ``executor.type: omnigent`` agents;
the create route validates against ``OMNIGENT_HARNESSES``.
Sub-agent sessions never inherit it (their own rows stay
``None``), so e.g. polly's workers keep their declared
harnesses when the brain is overridden.
Sub-agent sessions never *inherit* the parent brain's override,
so e.g. polly's workers keep their declared harnesses when the
brain is overridden. A sub-agent session MAY, however, carry its
own create-time override when ``sys_session_send`` supplied an
allowlisted ``args.harness`` (gated by the sub-agent spec's
``executor.config.allowed_harnesses``); that value is set on the
child's own row, not inherited.
:param sub_agent_name: For sub-agent sessions (``kind="sub_agent"``),
the sub-agent type name within the parent's spec tree,
e.g. ``"summarizer"``. The runner uses this to resolve the
+7
View File
@@ -20,6 +20,11 @@ HARNESS_ALIASES: dict[str, str] = {
"native-goose": "goose-native",
# Qwen Code harness alias.
"qwen-code": "qwen",
# OpenCode native-server harness: the bare ``opencode`` name and the
# reversed ``native-opencode`` spelling both fold to ``opencode-native``
# (there is no separate SDK ``opencode`` harness, so the bare name is free).
"opencode": "opencode-native",
"native-opencode": "opencode-native",
}
# Canonical native-CLI harness spellings. These harnesses type messages into
@@ -40,6 +45,8 @@ NATIVE_HARNESSES: frozenset[str] = frozenset(
"native-cursor",
"goose-native",
"native-goose",
"opencode-native",
"native-opencode",
}
)
+167
View File
@@ -0,0 +1,167 @@
"""Executor that bridges Omnigent web turns into a native OpenCode session.
Built on :class:`omnigent.native_server_harness.NativeServerHarness`: the
runner owns the ``opencode serve`` process + SSE forwarder, and this
executor injects the latest web turn over the
:class:`omnigent.opencode_http_transport.OpenCodeHttpTransport` using the
loopback URL + auth secret published in the bridge state. Output is
streamed back by the runner-side forwarder, so ``run_turn`` only admits the
prompt and yields ``TurnComplete`` — the same injection/completion split as
codex-native.
"""
from __future__ import annotations
import dataclasses
import json
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from omnigent.native_server_harness import NativeServerHarness
from omnigent.native_server_transport import NativePrompt
from omnigent.opencode_http_transport import OpenCodeHttpTransport
from omnigent.opencode_native_bridge import (
OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR,
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR,
read_bridge_state,
)
# Canonical harness id, surfaced in harness error messages.
OPENCODE_NATIVE_HARNESS_ID = "opencode-native"
class OpenCodeNativeExecutor(NativeServerHarness):
"""
Harness-side executor for ``omnigent opencode`` web UI turns.
:param bridge_dir: Optional bridge directory override. ``None`` reads
:data:`OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR`.
"""
def __init__(self, bridge_dir: Path | None = None) -> None:
self._bridge_dir = bridge_dir or _bridge_dir_from_env()
self._request_session_id = _request_session_id_from_env()
super().__init__(
harness_id=OPENCODE_NATIVE_HARNESS_ID,
# OpenCode has no live-steer endpoint, so a mid-turn message is
# admitted as a new prompt and the native server's own queue
# promotes it when the active turn finishes.
supports_enqueue=True,
transport=OpenCodeHttpTransport(bridge_dir=self._bridge_dir),
resolve_session_id=self._resolve_opencode_session_id,
build_prompt=self._build_prompt_with_model_override,
)
def _build_prompt_with_model_override(self, content: Any) -> NativePrompt | None:
"""
Build a prompt, pinning the resolved model so it governs from turn one.
OpenCode's ``POST /session`` create body does NOT accept a model
(verified against the OpenCode SDK ``SessionCreateData``); the model
is a per-prompt field (``{"providerID", "modelID"}``). So the
session's ``model_override`` is applied to EVERY injected prompt
here. Because OpenCode persists the last-used model as the session
default, pinning the first injected turn also governs subsequent
TUI-typed turns — the override controls the run from the start, not
just a later web turn. A per-turn ``config.model`` (if any) still
wins: the base ``run_turn`` only fills the model when the prompt
leaves it unset, so it skips a prompt this method already pinned.
:param content: Executor message content (string or content blocks).
:returns: The prompt with the resolved model applied, or ``None``
when there is nothing to send.
"""
prompt = _content_to_native_prompt(content)
if prompt is None or prompt.model:
return prompt
state = read_bridge_state(self._bridge_dir)
model = state.model_override if state is not None else None
if not model:
return prompt
return dataclasses.replace(prompt, model=model)
async def _resolve_opencode_session_id(self) -> str | None:
"""
Resolve the OpenCode session id from bridge state.
:returns: The OpenCode session id when this harness may inject into
it, else ``None``.
"""
state = read_bridge_state(self._bridge_dir)
if state is None:
return None
if not _session_is_active(state.session_id, self._request_session_id):
return None
return state.opencode_session_id
def _bridge_dir_from_env() -> Path:
"""
Resolve the native OpenCode bridge directory from harness spawn env.
:returns: Bridge directory path.
:raises RuntimeError: If the env var is missing.
"""
raw = os.environ.get(OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, "").strip()
if not raw:
raise RuntimeError(f"{OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR} is required")
return Path(raw)
def _request_session_id_from_env() -> str | None:
"""
Resolve the Omnigent session id that requested this harness process.
:returns: Omnigent session id, e.g. ``"conv_abc123"``, or ``None``.
"""
raw = os.environ.get(OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, "").strip()
return raw or None
def _session_is_active(session_id: str, request_session_id: str | None) -> bool:
"""
Return whether this harness may inject into the native session.
:param session_id: Session id from bridge state.
:param request_session_id: Session id from harness spawn env.
:returns: ``True`` when injection is allowed.
"""
return request_session_id is None or request_session_id == session_id
def _content_to_native_prompt(content: Any) -> NativePrompt | None:
"""
Normalize executor message content into a :class:`NativePrompt`.
Text blocks are concatenated; image/file blocks pass through as
attachments (the transport renders them as OpenCode file parts using
their data URIs, so there is no socket-size limit to work around).
:param content: Message content — a string or a list of content blocks
such as ``{"type": "input_text", "text": "..."}`` and
``{"type": "input_image", "image_url": "data:image/png;base64,..."}``.
:returns: The prompt, or ``None`` when there is nothing to send.
"""
if isinstance(content, str):
return NativePrompt(text=content) if content else None
if isinstance(content, list):
texts: list[str] = []
attachments: list[Mapping[str, Any]] = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type in {"input_text", "text"}:
text = block.get("text")
if isinstance(text, str) and text:
texts.append(text)
elif block_type in {"input_image", "input_file"}:
attachments.append(block)
if not texts and not attachments:
return None
return NativePrompt(text="\n".join(texts), attachments=tuple(attachments))
if content is None:
return None
return NativePrompt(text=json.dumps(content, ensure_ascii=True))
+29
View File
@@ -0,0 +1,29 @@
"""``harness: opencode-native`` wrap for the native OpenCode server."""
from __future__ import annotations
from fastapi import FastAPI
from omnigent.inner.executor import Executor
from omnigent.inner.opencode_native_executor import OpenCodeNativeExecutor
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
def _build_opencode_native_executor() -> Executor:
"""
Construct the native OpenCode bridge executor.
:returns: An :class:`OpenCodeNativeExecutor` configured from the
harness spawn environment.
"""
return OpenCodeNativeExecutor()
def create_app() -> FastAPI:
"""
Build the ``opencode-native`` harness FastAPI app.
:returns: The FastAPI app from :class:`ExecutorAdapter`.
"""
adapter = ExecutorAdapter(executor_factory=_build_opencode_native_executor)
return adapter.build()
+12
View File
@@ -9,6 +9,7 @@ from omnigent._wrapper_labels import (
CODEX_NATIVE_WRAPPER_VALUE,
CURSOR_NATIVE_WRAPPER_VALUE,
GOOSE_NATIVE_WRAPPER_VALUE,
OPENCODE_NATIVE_WRAPPER_VALUE,
PI_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
@@ -67,6 +68,16 @@ PI_NATIVE_CODING_AGENT = NativeCodingAgent(
terminal_name="pi",
)
OPENCODE_NATIVE_CODING_AGENT = NativeCodingAgent(
key="opencode",
display_name="OpenCode",
agent_name="opencode-native-ui",
harness="opencode-native",
wrapper_label=OPENCODE_NATIVE_WRAPPER_VALUE,
terminal_name="opencode",
subagent_wrapper_label="opencode-native-ui-subagent",
)
CURSOR_NATIVE_CODING_AGENT = NativeCodingAgent(
key="cursor",
display_name="Cursor",
@@ -89,6 +100,7 @@ NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
PI_NATIVE_CODING_AGENT,
OPENCODE_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
GOOSE_NATIVE_CODING_AGENT,
)
+225
View File
@@ -0,0 +1,225 @@
"""Shared :class:`Executor` base for native-server harnesses.
The runner owns the native server + SSE/WS forwarder; this executor is the
harness-side seam that injects web turns over a
:class:`~omnigent.native_server_transport.NativeServerTransport`. It is
deliberately thin and transport-agnostic — the same orchestration drives
both codex-native (WS JSON-RPC) and opencode-native (HTTP + SSE):
- ``run_turn`` resolves the native session id from bridge state (briefly
polling on first turn while the runner boots the server), builds a
:class:`NativePrompt` from the latest user message, injects it via the
transport, and yields ``TurnComplete`` — streaming is the forwarder's
job, matching codex-native's injection/completion split.
- ``interrupt_session`` and ``enqueue_session_message`` route through the
transport's ``abort`` / ``send_prompt``.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from omnigent.inner.executor import (
EnqueuedContent,
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
ToolSpec,
TurnComplete,
)
from omnigent.native_server_transport import NativePrompt, NativeServerTransport
_logger = logging.getLogger(__name__)
# Resolve the native session id from bridge state (``None`` until ready).
SessionResolver = Callable[[], Awaitable[str | None]]
# Build a :class:`NativePrompt` from message content.
PromptBuilder = Callable[[Any], NativePrompt | None]
class NativeServerHarness(Executor):
"""
Transport-driven executor for native-server harnesses.
:param harness_id: Canonical harness id, e.g. ``"opencode-native"`` (used
in harness error messages).
:param supports_enqueue: Whether the harness supports mid-turn enqueue
(steer-or-queue); drives :meth:`supports_live_message_queue`.
:param transport: The transport to inject turns over.
:param resolve_session_id: Async callable returning the native session
id (or ``None`` until the runner has booted the server).
:param build_prompt: Callable turning message content into a
:class:`NativePrompt` (``None`` when there is nothing to send).
:param boot_poll_attempts: Times to poll for the session id on the
first turn before giving up.
:param boot_poll_delay: Seconds between boot polls.
"""
def __init__(
self,
*,
harness_id: str,
supports_enqueue: bool,
transport: NativeServerTransport,
resolve_session_id: SessionResolver,
build_prompt: PromptBuilder,
boot_poll_attempts: int = 60,
boot_poll_delay: float = 1.0,
) -> None:
self._harness_id = harness_id
self._supports_enqueue = supports_enqueue
self.transport = transport
self._resolve_session_id = resolve_session_id
self._build_prompt = build_prompt
self._boot_poll_attempts = boot_poll_attempts
self._boot_poll_delay = boot_poll_delay
# Serialize injection (run_turn vs enqueue) against the one cached
# executor instance, mirroring the codex-native inject lock.
self._inject_lock = asyncio.Lock()
def supports_streaming(self) -> bool:
""":returns: ``False`` — the runner-side forwarder emits output."""
return False
def handles_tools_internally(self) -> bool:
""":returns: ``True`` — the native server runs its own tools."""
return True
def supports_live_message_queue(self) -> bool:
""":returns: Whether the harness supports mid-turn enqueue."""
return self._supports_enqueue
async def _await_session_id(self) -> str | None:
"""
Resolve the native session id, polling while the server boots.
:returns: The native session id, or ``None`` if it never appears.
"""
session_id = await self._resolve_session_id()
if session_id is not None:
return session_id
for _ in range(self._boot_poll_attempts):
await asyncio.sleep(self._boot_poll_delay)
session_id = await self._resolve_session_id()
if session_id is not None:
return session_id
return None
async def run_turn(
self,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str,
config: ExecutorConfig | None = None,
) -> AsyncIterator[ExecutorEvent]:
"""
Inject the latest user message into the native session.
:param messages: Conversation history; the latest user message is
delivered.
:param tools: Omnigent tool schemas (ignored — native owns tools).
:param system_prompt: Agent system prompt (ignored — set at
session creation).
:param config: Per-turn config (model override applied if present).
:returns: Async iterator yielding one terminal event.
"""
del tools, system_prompt
prompt = _latest_user_prompt(messages, self._build_prompt)
if prompt is None or prompt.is_empty():
yield ExecutorError(message=f"{self._harness_id} turn had no user input to send")
return
if config is not None and config.model and not prompt.model:
prompt = _with_model(prompt, config.model)
error_msg: str | None = None
async with self._inject_lock:
session_id = await self._await_session_id()
if session_id is None:
error_msg = f"{self._harness_id} bridge state is missing"
else:
try:
await self.transport.send_prompt(session_id, prompt)
except Exception as exc: # noqa: BLE001 - converted to a harness error event.
error_msg = f"{self._harness_id} executor error: {exc}"
if error_msg is not None:
yield ExecutorError(message=error_msg)
else:
yield TurnComplete(response=None)
async def interrupt_session(self, session_key: str) -> bool:
"""
Abort the active native turn.
:param session_key: Adapter session key (unused; bridge is
per-conversation).
:returns: ``True`` when an abort was issued.
"""
del session_key
session_id = await self._resolve_session_id()
if session_id is None:
return False
try:
return await self.transport.abort(session_id)
except Exception: # noqa: BLE001 - interruption is best effort.
_logger.warning("%s abort failed", self._harness_id, exc_info=True)
return False
async def enqueue_session_message(self, session_key: str, content: EnqueuedContent) -> bool:
"""
Inject a mid-session message (steer-or-queue).
OpenCode has no live-steer endpoint, so the message is admitted as
a new prompt; the native server's own queue promotes it when the
active turn finishes.
:param session_key: Adapter session key (unused).
:param content: User-supplied content.
:returns: ``True`` when the message was admitted.
"""
del session_key
prompt = self._build_prompt(content)
if prompt is None or prompt.is_empty():
return False
async with self._inject_lock:
session_id = await self._resolve_session_id()
if session_id is None:
return False
try:
await self.transport.send_prompt(session_id, prompt)
except Exception: # noqa: BLE001 - enqueue is best effort.
_logger.warning("%s enqueue failed", self._harness_id, exc_info=True)
return False
return True
def _latest_user_prompt(
messages: list[Message], build_prompt: PromptBuilder
) -> NativePrompt | None:
"""
Build a :class:`NativePrompt` from the latest user message.
:param messages: Executor message list.
:param build_prompt: Content → prompt builder.
:returns: The prompt, or ``None`` when there is no user content.
"""
for message in reversed(messages):
if message.get("role") == "user":
return build_prompt(message.get("content"))
return None
def _with_model(prompt: NativePrompt, model: str) -> NativePrompt:
"""
Return a copy of *prompt* with *model* applied.
:param prompt: The prompt to copy.
:param model: Model id to pin.
:returns: A new prompt carrying the model.
"""
import dataclasses
return dataclasses.replace(prompt, model=model)
+170
View File
@@ -0,0 +1,170 @@
"""Transport abstraction for native-server harnesses.
A *native-server* harness drives a per-conversation server process the
runner owns: the runner starts the server + an event forwarder, and the
harness injects web turns over this transport. OpenCode speaks HTTP + SSE
(:class:`omnigent.opencode_http_transport.OpenCodeHttpTransport`);
:class:`NativeServerTransport` is the seam so the orchestration in
:class:`omnigent.native_server_harness.NativeServerHarness` stays
protocol-agnostic.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol, runtime_checkable
@dataclass(frozen=True)
class NativeLaunchConfig:
"""
Inputs needed to start/resume a native server for one conversation.
:param omnigent_session_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param workspace: Working directory the server runs in.
:param model_override: Persisted model override, or ``None``.
:param terminal_launch_args: Pass-through CLI args for the TUI/server.
:param external_session_id: Native session id to resume, or ``None``.
:param server_url: Existing server URL when reusing one, or ``None``.
:param auth_headers: Auth headers for the native server.
"""
omnigent_session_id: str
workspace: str
model_override: str | None = None
terminal_launch_args: tuple[str, ...] = ()
external_session_id: str | None = None
server_url: str | None = None
auth_headers: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class NativeServerHandle:
"""
A running native server's connection coordinates.
:param base_url: Server base URL / transport endpoint.
:param env: Environment used to launch the server.
:param bridge_dir: The per-session bridge directory.
:param process_id: OS pid of the server process, when known.
"""
base_url: str
env: Mapping[str, str]
bridge_dir: Path
process_id: int | None = None
@dataclass(frozen=True)
class NativePrompt:
"""
A normalized prompt to inject into a native session.
:param text: The user text.
:param attachments: Attachment descriptors (image/file blocks).
:param system_prompt: Optional per-prompt system override.
:param model: Optional per-prompt model id.
:param metadata: Transport-specific extras.
"""
text: str
attachments: tuple[Mapping[str, Any], ...] = ()
system_prompt: str | None = None
model: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def is_empty(self) -> bool:
""":returns: ``True`` when there is nothing to send."""
return not self.text and not self.attachments
@dataclass(frozen=True)
class NativeEvent:
"""
A transport-neutral native server event.
:param id: Optional event id.
:param type: Event discriminator.
:param payload: The event's properties/params.
:param raw: The full raw envelope.
"""
id: str | None
type: str
payload: Mapping[str, Any]
raw: Mapping[str, Any]
@dataclass(frozen=True)
class NativePermissionDecision:
"""
A permission decision to relay to the native server.
:param request_id: Native permission request id.
:param decision: Normalized decision.
:param message: Optional human-readable note.
"""
request_id: str
decision: Literal["allow_once", "allow_always", "reject"]
message: str | None = None
@runtime_checkable
class NativeServerTransport(Protocol):
"""
Protocol every native-server transport implements.
Implementations encapsulate all wire details (process launch, session
lifecycle, prompt injection, abort, event stream, fork, permission
replies, TUI attach). The shared
:class:`~omnigent.native_server_harness.NativeServerHarness` calls only
these methods.
"""
descriptor_id: str
async def start_server(self, launch: NativeLaunchConfig) -> NativeServerHandle:
"""Start (or attach to) the native server; return its handle."""
raise NotImplementedError
async def stop_server(self) -> None:
"""Stop the native server process this transport started."""
raise NotImplementedError
async def create_or_resume_session(self, launch: NativeLaunchConfig) -> str:
"""Resume ``launch.external_session_id`` or create a new session id."""
raise NotImplementedError
async def send_prompt(self, session_id: str, prompt: NativePrompt) -> Mapping[str, Any]:
"""Inject a prompt into the native session."""
raise NotImplementedError
async def abort(self, session_id: str) -> bool:
"""Abort the native session's active work."""
raise NotImplementedError
def events(self, session_id: str) -> AsyncIterator[NativeEvent]:
"""Stream native events for *session_id*."""
raise NotImplementedError
async def list_history(self, session_id: str) -> list[Mapping[str, Any]]:
"""Return the native session's message history."""
raise NotImplementedError
async def fork(self, session_id: str, *, at_message_id: str | None = None) -> str:
"""Fork the native session; return the new session id."""
raise NotImplementedError
async def reply_permission(self, decision: NativePermissionDecision) -> None:
"""Relay a permission decision to the native server."""
raise NotImplementedError
def build_tui_attach_command(
self, launch: NativeLaunchConfig, session_id: str
) -> tuple[list[str], Mapping[str, str]]:
"""Build the ``(argv, env)`` for a terminal TUI takeover."""
raise NotImplementedError
+19 -4
View File
@@ -56,6 +56,11 @@ QWEN_KEY = "qwen"
# installer rather than npm — so it carries an ``install_hint``, not a ``package``.
CURSOR_KEY = "cursor"
# OpenCode native harness CLI (``opencode serve`` / ``opencode attach``),
# installed via the ``opencode-ai`` npm package. No login/logout/status argv
# is wired yet — readiness is binary-only until an auth check exists.
OPENCODE_KEY = "opencode"
# Goose authenticates against its own config (``goose configure`` → keyring /
# ``~/.config/goose/config.yaml``) with no Omnigent-managed credential, and ships
# via Homebrew / a curl installer rather than npm — so it carries an
@@ -124,6 +129,11 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
status_args=("login", "status"),
),
PI_KEY: HarnessInstallSpec("Pi", "pi", "@earendil-works/pi-coding-agent"),
# Pin the install to the supported 1.17.x range: opencode-ai's npm ``latest``
# is a ``0.0.0-beta-*`` pre-release, so a bare ``opencode-ai`` would install a
# version the runtime version-check (``check_opencode_version``,
# >=1.17.7,<1.18.0) then rejects. ``~1.17.7`` mirrors that exact range.
OPENCODE_KEY: HarnessInstallSpec("OpenCode", "opencode", "opencode-ai@~1.17.7"),
QWEN_KEY: HarnessInstallSpec(
"Qwen Code",
"qwen",
@@ -161,10 +171,11 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
# :data:`_HARNESS_INSTALL` family key. Only the CLI-backed harnesses appear
# here — the ones that cannot launch without a binary on ``PATH``:
# ``claude-native`` wraps the ``claude`` CLI, ``codex-native`` the ``codex``
# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``qwen`` / ``qwen-code`` the
# ``qwen`` CLI, and ``cursor-native`` / ``native-cursor`` the ``cursor-agent``
# CLI (the native Cursor TUI, installed via Cursor's curl installer rather than
# npm — see its ``install_hint``).
# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``opencode-native`` the
# ``opencode`` CLI, ``qwen`` / ``qwen-code`` the ``qwen`` CLI, and
# ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` CLI (the native
# Cursor TUI, installed via Cursor's curl installer rather than npm — see its
# ``install_hint``).
# SDK-based harnesses run in-process and are deliberately absent, so they
# resolve to "no CLI required": ``claude-sdk``, ``codex``, ``openai-agents-sdk``,
# and the SDK ``cursor`` harness (which drives the ``cursor-sdk`` Python package
@@ -183,6 +194,10 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
GOOSE_KEY: GOOSE_KEY,
QWEN_KEY: QWEN_KEY,
"qwen-code": QWEN_KEY,
# Native OpenCode (``opencode-native``) wraps the ``opencode`` CLI; its
# ``native-opencode`` reversed spelling gates on the same binary.
"opencode-native": OPENCODE_KEY,
"native-opencode": OPENCODE_KEY,
}
+14 -2
View File
@@ -30,6 +30,7 @@ from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
GOOSE_KEY,
OPENCODE_KEY,
PI_KEY,
QWEN_KEY,
harness_cli_installed,
@@ -56,6 +57,11 @@ _SDK_HARNESSES: frozenset[str] = frozenset(
# be gated explicitly or they fail open like an unknown harness.
_PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
# Native OpenCode harness. Like pi, it wraps a CLI (``opencode``) with no
# ``_HARNESS_FAMILY`` entry, so it must be gated explicitly or it would fail
# open like an unknown harness.
_OPENCODE_HARNESSES: frozenset[str] = frozenset({"opencode-native"})
# Native Cursor harnesses. These boot the ``cursor-agent`` TUI (``omni cursor``)
# and so, like the other native CLI harnesses, can't launch without that binary
# on ``PATH`` — gate them on it. Distinct from the SDK ``cursor`` harness
@@ -100,9 +106,13 @@ def _install_key(canonical: str) -> str:
:param canonical: A canonical CLI-wrapping harness id keyed in
``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), or ``"pi"``.
:returns: ``"anthropic"`` / ``"openai"`` for the claude/codex CLIs,
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi, or
:data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen.
:data:`~omnigent.onboarding.harness_install.OPENCODE_KEY` for
opencode-native,
:data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen, or
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi.
"""
if canonical in _OPENCODE_HARNESSES:
return OPENCODE_KEY
if canonical in _QWEN_HARNESSES:
return QWEN_KEY
return _HARNESS_FAMILY.get(canonical) or PI_KEY
@@ -162,6 +172,7 @@ def harness_is_configured(harness: str) -> bool:
if (
canonical not in _HARNESS_FAMILY
and canonical not in _PI_HARNESSES
and canonical not in _OPENCODE_HARNESSES
and canonical not in _QWEN_HARNESSES
):
# Unknown harness — the daemon has no install metadata for it, so
@@ -188,6 +199,7 @@ def configured_harness_map() -> dict[str, bool]:
spellings.update(_EXECUTOR_TYPE_HARNESS_ALIASES)
spellings.update(HARNESS_ALIASES)
spellings.update(_PI_HARNESSES)
spellings.update(_OPENCODE_HARNESSES)
spellings.update(_CURSOR_NATIVE_HARNESSES)
spellings.update(_GOOSE_NATIVE_HARNESSES)
spellings.update(_QWEN_HARNESSES)
+40 -1
View File
@@ -81,6 +81,8 @@ def _render_menu(
width: int,
selectable: list[bool],
status: str | None = None,
max_visible: int | None = None,
window_start: int = 0,
) -> str:
"""Render the menu frame to an ANSI string for the termios redraw.
@@ -120,8 +122,23 @@ def _render_menu(
render_console.print(Text.from_markup(f" [bold {ACCENT}]{title}[/]"))
render_console.print()
# Optional scrolling viewport: when *max_visible* is set and the list is
# longer, render only ``options[window_start : window_start + max_visible]``
# (the caller keeps the selected row inside this window) plus dim "N more"
# markers, so a long flat list fits one screen instead of overflowing and
# flickering. ``None`` (the default) renders every row, unchanged.
n_options = len(options)
if max_visible is not None and n_options > max_visible:
win_start = max(0, min(window_start, n_options - max_visible))
win_end = win_start + max_visible
else:
win_start, win_end = 0, n_options
if win_start > 0:
render_console.print(Text.from_markup(f" [{MUTED}]↑ {win_start} more[/]"))
last_choice = -1 # index of the most recent selectable (group-owning) row
for i, label in enumerate(options):
for i in range(win_start, win_end):
label = options[i]
if not selectable[i]:
# Sub-line(s) under the preceding choice (a harness's default +
# "+N more" summary): indented, no pointer. ↑/↓ skip them. Their
@@ -149,6 +166,9 @@ def _render_menu(
last_choice = i
render_console.print(Text.from_markup(f" {label}"))
if win_end < n_options:
render_console.print(Text.from_markup(f" [{MUTED}]↓ {n_options - win_end} more[/]"))
if descriptions is not None and descriptions[selected]:
render_console.print()
render_console.print(Text.from_markup(f" [dim italic]{descriptions[selected]}[/]"))
@@ -280,6 +300,7 @@ def select(
selectable: list[bool] | None = None,
clear_on_exit: bool = False,
status: str | None = None,
max_visible: int | None = None,
) -> int:
"""Show a theme-picker-styled arrow-key menu and return the choice.
@@ -320,6 +341,11 @@ def select(
title (part of the frame, so it clears with ``clear_on_exit``).
Pass the prior action's result so a re-rendering loop shows only
the latest, never an accumulating stack. No-op on the fallback.
:param max_visible: Optional cap on visible rows. When set and the list
is longer, the menu shows a scrolling viewport that follows the
cursor (with "N more" markers) so a long flat list fits one screen
instead of overflowing and flickering. ``None`` renders every row.
No-op on the numbered fallback.
:returns: The chosen zero-based index into *options* (always a
selectable row), or ``-1`` when the user aborts — Esc / Ctrl-C /
Ctrl-D on the TTY, or ``q`` on the numbered fallback.
@@ -347,9 +373,20 @@ def select(
# occupied so the next redraw can move up and overwrite it
# (the ``_theme_picker`` redraw idiom).
prev_lines = [0]
# Scrolling-viewport start index (mutable for the redraw closure); only
# used when ``max_visible`` bounds a long list.
window_start = [0]
def _redraw() -> None:
"""Clear the prior frame region and reprint the menu in place."""
if max_visible is not None and len(options) > max_visible:
# Keep the selected row inside the [start, start+max_visible) window,
# scrolling the window just enough to follow the cursor.
if selected < window_start[0]:
window_start[0] = selected
elif selected >= window_start[0] + max_visible:
window_start[0] = selected - max_visible + 1
window_start[0] = max(0, min(window_start[0], len(options) - max_visible))
rendered = _render_menu(
title,
options,
@@ -358,6 +395,8 @@ def select(
width=width,
selectable=mask,
status=status,
max_visible=max_visible,
window_start=window_start[0],
)
if prev_lines[0] > 0:
sys.stdout.write(f"\033[{prev_lines[0]}A")
+136
View File
@@ -0,0 +1,136 @@
"""OpenCode readiness + credential reporting for ``omnigent setup``.
Like :mod:`omnigent.onboarding.goose_auth`, Omnigent stores **no** OpenCode
credentials: OpenCode owns its own provider auth via ``opencode auth login``
(stored in ``~/.local/share/opencode/auth.json``) or ambient provider env vars
(``OPENAI_API_KEY`` / ``ANTHROPIC_API_KEY`` / …). This module is a thin,
read-only reporter so ``omnigent setup`` can show which providers OpenCode can
reach and offer to run its native login — without ever touching its secrets.
It reads ``auth.json`` directly (a JSON object keyed by provider id — see
``packages/opencode/src/auth`` in the OpenCode source) rather than scraping
``opencode auth list`` output, and checks a curated set of common provider env
vars. Both are best-effort: a missing/unreadable file or unknown env var simply
reports "nothing configured", never raises.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_cli_installed
# Common OpenCode providers → (provider id, display label, env var). The
# provider id matches OpenCode's own id (the ``auth.json`` key and the
# ``provider/model`` prefix in ``opencode models``). Not exhaustive (OpenCode
# resolves many providers from models.dev); this is the set worth surfacing in
# setup, including the ``OPENAI_*`` pair the Databricks-gateway path uses.
_ENV_PROVIDER_VARS: tuple[tuple[str, str, str], ...] = (
("openai", "OpenAI", "OPENAI_API_KEY"),
("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
("google", "Google Gemini", "GEMINI_API_KEY"),
("google", "Google Gemini", "GOOGLE_GENERATIVE_AI_API_KEY"),
("groq", "Groq", "GROQ_API_KEY"),
("openrouter", "OpenRouter", "OPENROUTER_API_KEY"),
("xai", "xAI", "XAI_API_KEY"),
("mistral", "Mistral", "MISTRAL_API_KEY"),
("deepseek", "DeepSeek", "DEEPSEEK_API_KEY"),
)
def opencode_auth_path() -> Path:
"""Return OpenCode's ``auth.json`` path for this process's HOME.
Honors ``XDG_DATA_HOME``; defaults to ``~/.local/share/opencode/auth.json``
(OpenCode's ``Global.Path.data``).
"""
xdg = os.environ.get("XDG_DATA_HOME", "").strip()
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
return base / "opencode" / "auth.json"
def _stored_providers() -> tuple[str, ...]:
"""Return provider ids with stored credentials in ``auth.json``.
Best-effort: a missing/unreadable/non-object file yields ``()``.
"""
try:
data = json.loads(opencode_auth_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return ()
if not isinstance(data, dict):
return ()
return tuple(str(k) for k in data)
def _env_providers(environ: dict[str, str] | None = None) -> tuple[str, ...]:
"""Return provider labels whose API-key env var is present."""
env = os.environ if environ is None else environ
seen: list[str] = []
for _provider_id, label, var in _ENV_PROVIDER_VARS:
if env.get(var, "").strip() and label not in seen:
seen.append(label)
return tuple(seen)
def reachable_provider_ids(environ: dict[str, str] | None = None) -> frozenset[str]:
"""Return OpenCode provider ids reachable from stored auth + env keys.
Ids match OpenCode's own (the ``provider/model`` prefix), so callers can
filter a model list down to what the user can actually authenticate.
"""
env = os.environ if environ is None else environ
ids = set(_stored_providers())
for provider_id, _label, var in _ENV_PROVIDER_VARS:
if env.get(var, "").strip():
ids.add(provider_id)
return frozenset(ids)
@dataclass(frozen=True)
class OpenCodeAuthSummary:
"""What setup needs to know about the local OpenCode credentials.
:param installed: ``opencode`` binary present on ``PATH``.
:param stored_providers: Provider ids with credentials in ``auth.json``.
:param env_providers: Provider labels whose API-key env var is set.
"""
installed: bool
stored_providers: tuple[str, ...]
env_providers: tuple[str, ...]
@property
def has_provider(self) -> bool:
"""Whether any provider is reachable (stored credential or env key)."""
return bool(self.stored_providers or self.env_providers)
@property
def ready(self) -> bool:
"""Launchable when the CLI is installed AND a provider is configured."""
return self.installed and self.has_provider
def describe(self) -> str:
"""A short human summary of configured providers, e.g.
``"2 stored (anthropic, openai) + env: OpenAI"``.
"""
parts: list[str] = []
if self.stored_providers:
parts.append(
f"{len(self.stored_providers)} stored ({', '.join(sorted(self.stored_providers))})"
)
if self.env_providers:
parts.append(f"env: {', '.join(self.env_providers)}")
return " · ".join(parts) if parts else "no provider configured yet"
def opencode_auth_summary() -> OpenCodeAuthSummary:
"""Summarize the local OpenCode credential state for setup display."""
return OpenCodeAuthSummary(
installed=harness_cli_installed(OPENCODE_KEY),
stored_providers=_stored_providers(),
env_providers=_env_providers(),
)
+284
View File
@@ -0,0 +1,284 @@
"""OpenCode HTTP + SSE implementation of :class:`NativeServerTransport`.
All OpenCode wire details live here;
:class:`omnigent.native_server_harness.NativeServerHarness` drives it through
the transport protocol only.
The transport can build its client from three sources, in priority order:
an injected ``client_factory`` (tests), a running
:class:`OpenCodeNativeServer` (runner-side), or the persisted bridge state
(harness-side, where only the URL + auth secret are known).
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator, Callable, Mapping
from pathlib import Path
from typing import Any
from omnigent.native_server_transport import (
NativeEvent,
NativeLaunchConfig,
NativePermissionDecision,
NativePrompt,
NativeServerHandle,
)
from omnigent.opencode_native_app_server import (
OpenCodeNativeServer,
build_opencode_attach_args,
client_for_state,
opencode_terminal_env,
)
from omnigent.opencode_native_bridge import read_bridge_state
from omnigent.opencode_native_client import OpenCodeClient
_logger = logging.getLogger(__name__)
ClientFactory = Callable[[], OpenCodeClient]
# Public surface of this transport module. ``ClientFactory`` is the documented
# annotation for ``OpenCodeHttpTransport(client_factory=...)``; export it so the
# alias reads as intended public API (its only other use is a PEP 563 stringified
# annotation, which static analysis can't see as a load).
__all__ = ["ClientFactory", "OpenCodeHttpTransport", "build_prompt_payload"]
def build_prompt_payload(prompt: NativePrompt) -> dict[str, Any]:
"""
Build an OpenCode prompt request body from a :class:`NativePrompt`.
:param prompt: The normalized prompt.
:returns: A ``{"parts": [...], ...}`` body for ``POST
/session/{id}/message`` or ``/prompt_async``.
"""
parts: list[dict[str, Any]] = []
if prompt.text:
parts.append({"type": "text", "text": prompt.text})
for attachment in prompt.attachments:
part = _attachment_to_part(attachment)
if part is not None:
parts.append(part)
payload: dict[str, Any] = {"parts": parts}
if prompt.system_prompt:
payload["system"] = prompt.system_prompt
model = _split_model(prompt.model)
if model is not None:
payload["model"] = model
return payload
def _attachment_to_part(attachment: Mapping[str, Any]) -> dict[str, Any] | None:
"""
Convert an Omnigent attachment block into an OpenCode file part.
:param attachment: An ``input_image`` / ``input_file`` content block.
:returns: A ``FilePartInput`` dict, or ``None`` when unconvertible.
"""
block_type = attachment.get("type")
if block_type == "input_image":
url = attachment.get("image_url")
if isinstance(url, str) and url:
mime = _mime_from_data_uri(url) or "image/png"
return {"type": "file", "mime": mime, "url": url}
if block_type == "input_file":
url = attachment.get("file_data") or attachment.get("url")
if isinstance(url, str) and url:
mime = _mime_from_data_uri(url) or "application/octet-stream"
part: dict[str, Any] = {"type": "file", "mime": mime, "url": url}
filename = attachment.get("filename")
if isinstance(filename, str) and filename:
part["filename"] = filename
return part
return None
def _mime_from_data_uri(uri: str) -> str | None:
"""
Extract the MIME type from a ``data:`` URI.
:param uri: A data URI, e.g. ``"data:image/png;base64,..."``.
:returns: The MIME type, or ``None``.
"""
if not uri.startswith("data:"):
return None
head = uri[len("data:") :].split(",", 1)[0]
mime = head.split(";", 1)[0]
return mime or None
def _split_model(model: str | None) -> dict[str, str] | None:
"""
Split a ``provider/model`` id into the OpenCode prompt model object.
:param model: A model id, e.g. ``"anthropic/claude-opus-4"``; ``None``
means no pin.
:returns: ``{"providerID": ..., "modelID": ...}`` or ``None``.
"""
if not model:
return None
provider, sep, model_id = model.partition("/")
if sep and provider and model_id:
return {"providerID": provider, "modelID": model_id}
return None
class OpenCodeHttpTransport:
"""
HTTP + SSE transport for opencode-native.
:param bridge_dir: Bridge dir to read server URL + auth from when no
server/client is injected (harness-side).
:param server: A running :class:`OpenCodeNativeServer` (runner-side).
:param client_factory: Optional client builder (tests).
:param directory: Workspace directory routing header.
"""
descriptor_id = "opencode-native"
def __init__(
self,
*,
bridge_dir: Path | None = None,
server: OpenCodeNativeServer | None = None,
client_factory: ClientFactory | None = None,
directory: str | None = None,
) -> None:
self._bridge_dir = bridge_dir
self._server = server
self._client_factory = client_factory
self._directory = directory
def _client(self) -> OpenCodeClient:
"""
Build a client from the injected factory, server, or bridge state.
:returns: A fresh :class:`OpenCodeClient` (caller closes it).
:raises RuntimeError: When no connection coordinates are available.
"""
if self._client_factory is not None:
return self._client_factory()
if self._server is not None:
return self._server.client(directory=self._directory)
if self._bridge_dir is not None:
state = read_bridge_state(self._bridge_dir)
if state is not None:
return client_for_state(
base_url=state.server_base_url,
auth_secret=state.auth_secret,
directory=self._directory or state.workspace,
)
raise RuntimeError("OpenCodeHttpTransport has no server/client/bridge state")
async def start_server(self, launch: NativeLaunchConfig) -> NativeServerHandle:
"""Start the OpenCode server and return its handle."""
if self._server is None:
self._server = OpenCodeNativeServer(
bridge_dir=self._bridge_dir or Path(launch.workspace),
workspace=Path(launch.workspace),
)
await self._server.start()
pid = self._server.process.pid if self._server.process is not None else None
return NativeServerHandle(
base_url=self._server.base_url,
env=self._server.env,
bridge_dir=self._server.bridge_dir,
process_id=pid,
)
async def stop_server(self) -> None:
"""Stop the OpenCode server, if this transport started one."""
if self._server is not None:
await self._server.close()
async def create_or_resume_session(self, launch: NativeLaunchConfig) -> str:
"""Resume the external session id, or create a new OpenCode session."""
client = self._client()
try:
if launch.external_session_id:
existing = await client.get_session(launch.external_session_id)
if existing is not None:
return existing.id
created = await client.create_session(
{"title": f"omnigent:{launch.omnigent_session_id}"}
)
return created.id
finally:
await client.aclose()
async def send_prompt(self, session_id: str, prompt: NativePrompt) -> Mapping[str, Any]:
"""Inject a prompt via ``POST /session/{id}/prompt_async``."""
client = self._client()
try:
return await client.prompt_async(session_id, build_prompt_payload(prompt))
finally:
await client.aclose()
async def abort(self, session_id: str) -> bool:
"""Abort active work via ``POST /session/{id}/abort``."""
client = self._client()
try:
return await client.abort(session_id)
finally:
await client.aclose()
async def events(self, session_id: str) -> AsyncIterator[NativeEvent]:
"""Stream native events, filtered to *session_id*."""
del session_id
client = self._client()
try:
async for event in client.events():
yield NativeEvent(
id=event.id,
type=event.type,
payload=event.properties,
raw=event.raw,
)
finally:
await client.aclose()
async def list_history(self, session_id: str) -> list[Mapping[str, Any]]:
"""Return the session's message history."""
client = self._client()
try:
return list(await client.list_messages(session_id))
finally:
await client.aclose()
async def fork(self, session_id: str, *, at_message_id: str | None = None) -> str:
"""Fork the session via ``POST /session/{id}/fork``."""
client = self._client()
try:
payload = {"messageID": at_message_id} if at_message_id else None
forked = await client.fork(session_id, payload)
return forked.id
finally:
await client.aclose()
async def reply_permission(self, decision: NativePermissionDecision) -> None:
"""Relay a permission decision via ``POST /permission/{id}/reply``."""
reply_map = {"allow_once": "once", "allow_always": "always", "reject": "reject"}
client = self._client()
try:
await client.reply_permission(
decision.request_id,
{"reply": reply_map[decision.decision], "message": decision.message or ""},
)
finally:
await client.aclose()
def build_tui_attach_command(
self, launch: NativeLaunchConfig, session_id: str
) -> tuple[list[str], Mapping[str, str]]:
"""Build the ``opencode attach`` argv + env for a TUI takeover."""
server_url = launch.server_url or (self._server.base_url if self._server else "")
argv = build_opencode_attach_args(
server_url=server_url,
workspace=launch.workspace,
session_id=session_id,
opencode_args=launch.terminal_launch_args,
)
env: Mapping[str, str] = (
opencode_terminal_env(self._server) if self._server is not None else {}
)
return argv, env
+560
View File
@@ -0,0 +1,560 @@
"""Native OpenCode wrapper agent spec for ``opencode-native-ui``.
Materializes the terminal-first built-in agent the server seeds (parallel
to ``omnigent.codex_native._materialize_codex_agent_spec``). The runner
owns the ``opencode serve`` process and SSE forwarder; this spec just binds
the ``opencode-native`` harness and declares the spawn/terminal surface so
the web UI renders the session terminal-first.
This module also hosts the interactive local ``omnigent opencode`` CLI wrapper
(:func:`run_opencode_native`, the analog of ``omnigent codex`` / ``omnigent pi``):
it ensures a local daemon + runner, creates-or-resumes the ``opencode-native-ui``
session (whose runner auto-creates the ``opencode serve`` + ``opencode attach``
terminal), and attaches this TTY directly to that runner-owned tmux pane — the
same web-UI takeover path, driven from the CLI. The provider/gateway comes from
the runner's ambient env / ``omnigent setup`` config (a profile-bound spec routes
through the Databricks gateway; otherwise OpenAI-/Anthropic-compatible env vars).
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
import click
import httpx
import yaml
from omnigent._native_resume_hint import echo_native_resume_hint
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
from omnigent._wrapper_labels import OPENCODE_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.host.daemon_launch import (
error_text,
launch_or_reuse_daemon_runner,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import url_component
# Built-in native-UI agent name (matches the descriptor's
# ``wrapper_agent_name`` and the ap-web native registry).
_AGENT_NAME = "opencode-native-ui"
def _materialize_opencode_agent_spec(
tmpdir: Path,
*,
model: str | None = None,
) -> Path:
"""
Write the terminal-first agent spec used by the OpenCode native UI.
:param tmpdir: Temporary directory for the generated YAML file.
:param model: Optional model id, e.g. ``"anthropic/claude-opus-4"``.
:returns: Path to the generated YAML spec.
"""
yaml_path = tmpdir / "opencode-native-ui.yaml"
executor: dict[str, str] = {"harness": "opencode-native"}
if model is not None:
executor["model"] = model
raw: dict[str, Any] = {
"name": _AGENT_NAME,
"prompt": (
"OpenCode is running in the session terminal. Web UI messages are "
"forwarded into the same native OpenCode server session."
),
"executor": executor,
# Opt the native session into the child-session spawn writes
# (sys_session_create / sys_session_send / sys_session_close) so the
# wrapped opencode can author agent configs and launch them as
# sub-agent sessions. The relay derives its advertised tool set from
# this spec via ToolManager.
"spawn": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
# Declare a default shell terminal so the relay advertises the
# ``sys_terminal_*`` family to the wrapped opencode (the relay's gate
# is a non-empty ``terminals:`` block on this spec).
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
_TERMINAL_NAME = "opencode"
_TERMINAL_SESSION_KEY = "main"
_SESSION_LABELS = {
"omnigent.ui": "terminal",
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
}
@dataclass(frozen=True)
class LaunchedOpenCodeTerminal:
"""Terminal resource returned by the Omnigent runner launch path."""
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
@dataclass(frozen=True)
class PreparedOpenCodeTerminal:
"""Prepared native OpenCode terminal attachment details."""
session_id: str
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
reattached: bool
def opencode_terminal_resource_id() -> str:
""":returns: The deterministic terminal resource id for OpenCode."""
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
def _preflight_local_tools() -> None:
"""Verify local executables the native OpenCode wrapper needs."""
if shutil.which("tmux") is None:
raise click.ClickException(
"tmux was not found on local PATH. The native OpenCode wrapper "
"attaches to the runner-owned OpenCode tmux terminal."
)
def run_opencode_native( # pragma: no cover
*,
server: str | None,
session_id: str | None,
opencode_args: tuple[str, ...],
resume_picker: bool = False,
model: str | None = None,
auto_open_conversation: bool = False,
) -> None:
"""
Launch the OpenCode TUI in an Omnigent terminal (the ``omnigent opencode`` path).
Mirrors ``omnigent codex`` / ``omnigent pi``: ensure a local daemon + runner,
create-or-resume the ``opencode-native-ui`` session (the runner auto-creates
the ``opencode serve`` + ``opencode attach`` terminal), then attach this TTY
to that runner-owned tmux pane.
:param server: Resolved Omnigent server URL. ``None`` is an error (the CLI
must resolve a backend first).
:param session_id: Optional existing Omnigent conversation id to resume.
:param opencode_args: Raw ``opencode`` CLI args to persist for the TUI.
:param resume_picker: When ``True``, run the opencode-native resume picker.
:param model: Optional model id pinned on the materialized wrapper spec.
:param auto_open_conversation: Open the browser conversation URL on launch.
:returns: None after the terminal attach session ends.
"""
_preflight_local_tools()
if server is None:
raise click.ClickException(
"OpenCode requires a resolved Omnigent server URL. The CLI should resolve "
"a backend before run_opencode_native."
)
with TemporaryDirectory(prefix="omnigent-opencode-native-") as tmpdir:
spec_path = _materialize_opencode_agent_spec(Path(tmpdir), model=model)
_run_with_remote_server(
server.rstrip("/"),
spec_path,
session_id=session_id,
resume_picker=resume_picker,
opencode_args=opencode_args,
auto_open_conversation=auto_open_conversation,
)
def _run_with_remote_server( # pragma: no cover
base_url: str,
spec_path: Path,
*,
session_id: str | None,
resume_picker: bool,
opencode_args: tuple[str, ...],
auto_open_conversation: bool = False,
) -> None:
"""Launch OpenCode on an Omnigent server via a daemon-spawned runner."""
from omnigent.chat import _bundle_agent, _remote_headers
from omnigent.cli import _ensure_host_daemon
from omnigent.host.identity import load_or_create_host_identity
headers = _remote_headers(server_url=base_url)
try:
resolved_session_id = _resolve_session_id_for_resume(
base_url=base_url,
headers=headers,
session_id=session_id,
resume_picker=resume_picker,
)
if resolved_session_id is None and resume_picker and session_id is None:
return
async def _drive() -> None:
with runner_startup_progress(initial_message="Preparing OpenCode...") as progress:
_update_startup_progress(progress, "Connecting to local daemon...")
_ensure_host_daemon(base_url)
host_id = load_or_create_host_identity().host_id
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
prepared = await _prepare_opencode_terminal_via_daemon(
base_url=base_url,
headers=headers,
session_id=resolved_session_id,
session_bundle=bundle,
opencode_args=opencode_args,
host_id=host_id,
workspace=str(Path.cwd().resolve()),
startup_progress=progress,
)
click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True)
open_conversation_link_if_enabled(
base_url=base_url,
conversation_id=prepared.session_id,
enabled=auto_open_conversation,
warn=lambda message: click.echo(message, err=True),
)
await _attach_terminal_resource(prepared)
if resolved_session_id is None:
echo_native_resume_hint(
native_command="opencode",
session_id=prepared.session_id,
server=base_url,
)
asyncio.run(_drive())
except httpx.ConnectError as exc:
raise click.ClickException(
f"Could not reach the omnigent server at {base_url}. "
"Confirm the server is running and reachable from here "
f"(e.g. `curl {base_url}/health`), and that --server is correct."
) from exc
async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
session_bundle: bytes | None,
opencode_args: tuple[str, ...],
host_id: str,
workspace: str,
startup_progress: RunnerStartupProgress | None = None,
) -> PreparedOpenCodeTerminal:
"""Create or resume an opencode-native session through a daemon runner."""
persist_args = list(opencode_args)
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
reattached = session_id is not None
if session_id is None:
if session_bundle is None:
raise click.ClickException(
"Creating an OpenCode session requires a session bundle."
)
_update_startup_progress(startup_progress, "Creating OpenCode session...")
session_id = await _create_opencode_session(
client, session_bundle, terminal_launch_args=persist_args or None
)
else:
_update_startup_progress(startup_progress, "Loading OpenCode session...")
payload = await _fetch_opencode_session(client, session_id)
labels = payload.get("labels") if isinstance(payload, dict) else None
if (
not isinstance(labels, dict)
or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE
):
raise click.ClickException(
f"Conversation {session_id!r} is not an opencode-native session."
)
existing_terminal = await _find_running_opencode_terminal(client, session_id)
if existing_terminal is not None:
if persist_args:
click.echo(
"Ignoring OpenCode launch args for an already-running terminal; "
"restart the session terminal to apply them.",
err=True,
)
_update_startup_progress(startup_progress, "OpenCode terminal ready.")
return PreparedOpenCodeTerminal(
session_id=session_id,
terminal_id=existing_terminal.terminal_id,
tmux_socket=existing_terminal.tmux_socket,
tmux_target=existing_terminal.tmux_target,
reattached=True,
)
if persist_args:
_update_startup_progress(startup_progress, "Updating OpenCode session...")
resp = await client.patch(
f"/v1/sessions/{url_component(session_id)}",
json={"terminal_launch_args": persist_args},
)
if resp.status_code >= 400:
raise click.ClickException(
f"OpenCode session launch config update failed "
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client, host_id=host_id, session_id=session_id, workspace=workspace
)
_update_startup_progress(startup_progress, "Waiting for runner...")
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
await _bind_session_runner(client, session_id, runner_id)
_update_startup_progress(startup_progress, "Starting OpenCode terminal...")
await _ensure_opencode_terminal_on_runner(client, session_id)
terminal = await _wait_for_opencode_terminal_ready(
client, session_id, timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S
)
_update_startup_progress(startup_progress, "OpenCode terminal ready.")
return PreparedOpenCodeTerminal(
session_id=session_id,
terminal_id=terminal.terminal_id,
tmux_socket=terminal.tmux_socket,
tmux_target=terminal.tmux_target,
reattached=reattached,
)
async def _create_opencode_session(
client: httpx.AsyncClient,
bundle: bytes,
*,
terminal_launch_args: list[str] | None = None,
) -> str:
"""Create a bundled terminal-first opencode-native session."""
metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)}
if terminal_launch_args:
metadata["terminal_launch_args"] = terminal_launch_args
resp = await client.post(
"/v1/sessions",
data={"metadata": json.dumps(metadata)},
files={"bundle": ("opencode-native-ui.tar.gz", bundle, "application/gzip")},
timeout=120.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"OpenCode session creation failed ({resp.status_code}): {error_text(resp)}"
)
body = resp.json()
new_session_id = body.get("session_id")
if not isinstance(new_session_id, str) or not new_session_id:
raise click.ClickException(
"OpenCode session creation response did not include session_id."
)
return new_session_id
async def _fetch_opencode_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""Fetch an existing Omnigent session."""
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
if resp.status_code == 404:
raise click.ClickException(f"Conversation {session_id!r} not found on the server.")
if resp.status_code >= 400:
raise click.ClickException(
f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}"
)
payload = resp.json()
if not isinstance(payload, dict):
raise click.ClickException("Conversation fetch returned non-object JSON.")
return payload
async def _ensure_opencode_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None:
"""Ask the bound runner to ensure the OpenCode terminal exists (idempotent)."""
resp = await client.post(
f"/v1/sessions/{url_component(session_id)}/resources/terminals",
json={
"terminal": _TERMINAL_NAME,
"session_key": _TERMINAL_SESSION_KEY,
"ensure_native_terminal": True,
},
timeout=60.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"OpenCode terminal ensure failed ({resp.status_code}): {error_text(resp)}"
)
async def _wait_for_opencode_terminal_ready(
client: httpx.AsyncClient,
session_id: str,
*,
timeout_s: float,
) -> LaunchedOpenCodeTerminal:
"""Wait until the runner exposes the OpenCode terminal resource."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
terminal = await _find_running_opencode_terminal(client, session_id)
if terminal is not None:
return terminal
await asyncio.sleep(0.2)
raise click.ClickException(
f"The runner did not create the OpenCode terminal for {session_id!r} "
f"within {timeout_s:.0f}s."
)
async def _find_running_opencode_terminal(
client: httpx.AsyncClient,
session_id: str,
) -> LaunchedOpenCodeTerminal | None:
"""Return the existing running OpenCode terminal if present."""
terminal_id = opencode_terminal_resource_id()
resp = await client.get(
f"/v1/sessions/{url_component(session_id)}"
f"/resources/terminals/{url_component(terminal_id)}"
)
if resp.status_code == 404:
return None
if resp.status_code >= 400:
text = error_text(resp)
if resp.status_code in {409, 503} and (
"not bound to a runner" in text or "offline" in text
):
return None
raise click.ClickException(
f"Failed to fetch OpenCode terminal ({resp.status_code}): {text}"
)
payload = resp.json()
metadata = payload.get("metadata") if isinstance(payload, dict) else None
if isinstance(metadata, dict) and metadata.get("running") is False:
return None
return _launched_opencode_terminal_from_payload(payload)
def _launched_opencode_terminal_from_payload(payload: object) -> LaunchedOpenCodeTerminal:
"""Decode terminal launch metadata returned by the runner."""
if not isinstance(payload, dict):
raise click.ClickException("OpenCode terminal launch returned non-object JSON.")
terminal_id = payload.get("id")
if not isinstance(terminal_id, str) or not terminal_id:
raise click.ClickException(
"OpenCode terminal launch response did not include terminal id."
)
metadata = payload.get("metadata")
tmux_socket: Path | None = None
tmux_target: str | None = None
if isinstance(metadata, dict):
raw_socket = metadata.get("tmux_socket")
raw_target = metadata.get("tmux_target")
if isinstance(raw_socket, str) and raw_socket:
tmux_socket = Path(raw_socket)
if isinstance(raw_target, str) and raw_target:
tmux_target = raw_target
return LaunchedOpenCodeTerminal(
terminal_id=terminal_id,
tmux_socket=tmux_socket,
tmux_target=tmux_target,
)
async def _attach_terminal_resource( # pragma: no cover
prepared: PreparedOpenCodeTerminal,
) -> None:
"""Attach the current terminal to the prepared OpenCode terminal resource."""
reason = _direct_tmux_unavailable_reason(prepared)
if reason is not None:
raise click.ClickException(
f"Runner-owned OpenCode terminal requires direct tmux attach, but {reason}"
)
assert prepared.tmux_socket is not None and prepared.tmux_target is not None
await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None: # pragma: no cover
"""Attach the current terminal directly to the runner-owned tmux pane."""
env = dict(os.environ)
env.pop("TMUX", None)
process = await asyncio.create_subprocess_exec(
"tmux", "-S", str(socket_path), "-f", os.devnull, "attach", "-t", tmux_target, env=env
)
await process.wait()
def _direct_tmux_unavailable_reason(prepared: PreparedOpenCodeTerminal) -> str | None:
"""Explain why direct tmux attach is unavailable."""
if prepared.tmux_socket is None:
return "the terminal resource did not include a tmux socket path."
if prepared.tmux_target is None:
return "the terminal resource did not include a tmux target."
if not prepared.tmux_socket.exists():
return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process."
if shutil.which("tmux") is None:
return "tmux is not available on PATH."
return None
def _resolve_session_id_for_resume(
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
resume_picker: bool,
) -> str | None:
"""Translate resume inputs into a concrete opencode-native session id."""
if session_id is not None:
return session_id
if not resume_picker:
return None
# Interactive SDK resume picker — exercised manually / via the live host
# e2e, not unit tests (it opens an OmnigentClient and an arrow-key picker).
from omnigent_client import OmnigentClient # pragma: no cover
from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk
async def _drive() -> str | None: # pragma: no cover
async with OmnigentClient(
base_url=base_url, headers=headers if headers else None
) as client:
return await pick_conversation_by_wrapper_label_from_sdk(
client, wrapper_value=_WRAPPER_LABEL_VALUE, agent_name=_AGENT_NAME
)
return asyncio.run(_drive()) # pragma: no cover
def _update_startup_progress(
startup_progress: RunnerStartupProgress | None,
message: str,
) -> None:
"""Show one concise OpenCode startup milestone when a renderer is active."""
if startup_progress is not None:
startup_progress.update(message)
+493
View File
@@ -0,0 +1,493 @@
"""Process manager for a per-conversation ``opencode serve`` server.
Mirrors :mod:`omnigent.codex_native_app_server` but for OpenCode's HTTP +
SSE transport. The runner owns this server (and the SSE forwarder); the
harness-side executor injects web turns over REST using the loopback URL
and auth secret published in the bridge state.
Responsibilities:
- Resolve and version-check the ``opencode`` CLI.
- Allocate a loopback port and per-session XDG data/config roots.
- Launch ``opencode serve --hostname 127.0.0.1 --port <port>`` with a
random ``OPENCODE_SERVER_PASSWORD`` and the per-session XDG dirs.
- Poll the HTTP API for readiness.
- Expose ``base_url``, ``auth_headers``, ``xdg_data_home`` /
``xdg_config_home``, and a process handle.
- Build the ``opencode attach`` argv + env for the terminal takeover (the
Codex ``--remote`` analog).
- Terminate the process on session close / runner shutdown.
Security posture: bind to ``127.0.0.1`` only, random per-session password,
per-session XDG dirs (never the user's global OpenCode state). The server
is runner-internal — the web UI attaches to Omnigent terminal resources,
never to the OpenCode HTTP port.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import shutil
import socket
import subprocess
from collections.abc import Mapping, Sequence
from pathlib import Path
import httpx
from packaging.version import InvalidVersion, Version
from omnigent.opencode_native_bridge import (
OPENCODE_DEFAULT_USERNAME,
OPENCODE_SERVER_PASSWORD_ENV_VAR,
OPENCODE_SERVER_USERNAME_ENV_VAR,
auth_headers_for_secret,
ensure_auth_secret,
xdg_config_home_for_bridge_dir,
xdg_data_home_for_bridge_dir,
)
from omnigent.opencode_native_client import (
OPENCODE_MAX_VERSION_EXCLUSIVE,
OPENCODE_MIN_VERSION,
OpenCodeClient,
)
_logger = logging.getLogger(__name__)
# Env vars the OpenCode server inherits from the parent that are safe and
# useful (provider creds + proxy). Everything else is filtered out so the
# server runs against a clean, per-session environment.
_ENV_PASSTHROUGH_PREFIXES = (
"OPENAI_",
"ANTHROPIC_",
"OPENCODE_",
"DATABRICKS_",
"GEMINI_",
"GOOGLE_",
"HTTP_",
"HTTPS_",
"NO_PROXY",
"ALL_PROXY",
)
_ENV_PASSTHROUGH_KEYS = (
"PATH",
"HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"no_proxy",
"http_proxy",
"https_proxy",
)
# OpenCode env vars that point the server at the user's GLOBAL config — they
# would defeat the per-session XDG isolation by re-introducing whatever
# config/model/permission settings the parent shell has set. Dropped from
# the passthrough even though they match the ``OPENCODE_`` prefix, so an
# isolated session never inherits unrelated global OpenCode config.
_ENV_OPENCODE_CONFIG_DENYLIST = frozenset(
{
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_CONTENT",
}
)
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+)*)")
class OpenCodeVersionError(RuntimeError):
"""Raised when the installed ``opencode`` CLI is an unsupported version."""
class OpenCodeCliNotFoundError(RuntimeError):
"""Raised when no ``opencode`` executable can be resolved on ``PATH``."""
def find_opencode_cli(opencode_path: str | None = None) -> str:
"""
Resolve the ``opencode`` executable.
:param opencode_path: Explicit path override; ``None`` searches ``PATH``.
:returns: Absolute path to the ``opencode`` binary.
:raises OpenCodeCliNotFoundError: When no binary can be resolved.
"""
if opencode_path:
if os.path.isabs(opencode_path) and os.access(opencode_path, os.X_OK):
return opencode_path
resolved = shutil.which(opencode_path)
if resolved:
return resolved
raise OpenCodeCliNotFoundError(f"opencode executable not found: {opencode_path!r}")
resolved = shutil.which("opencode")
if not resolved:
raise OpenCodeCliNotFoundError(
"opencode CLI not found on PATH; install the 'opencode-ai' npm package"
)
return resolved
def parse_opencode_version(text: str) -> str | None:
"""
Extract a semver string from ``opencode --version`` output.
:param text: Raw CLI output, e.g. ``"opencode 1.17.7"`` or ``"1.17.7"``.
:returns: The parsed version, e.g. ``"1.17.7"``, or ``None``.
"""
match = _VERSION_RE.search(text or "")
return match.group(1) if match else None
def check_opencode_version(
version: str,
*,
minimum: str = OPENCODE_MIN_VERSION,
maximum_exclusive: str = OPENCODE_MAX_VERSION_EXCLUSIVE,
) -> None:
"""
Validate an OpenCode version against the supported range.
:param version: Version string, e.g. ``"1.17.7"``.
:param minimum: Inclusive lower bound.
:param maximum_exclusive: Exclusive upper bound.
:raises OpenCodeVersionError: When *version* is unparsable or outside
``[minimum, maximum_exclusive)``.
"""
try:
parsed = Version(version)
low = Version(minimum)
high = Version(maximum_exclusive)
except InvalidVersion as exc:
raise OpenCodeVersionError(f"Unparsable OpenCode version {version!r}: {exc}") from exc
if parsed < low or parsed >= high:
raise OpenCodeVersionError(
f"Unsupported OpenCode version {version}: requires >={minimum},<{maximum_exclusive}. "
"Install a pinned 'opencode-ai' release."
)
def resolve_opencode_version(opencode_path: str) -> str:
"""
Run ``opencode --version`` and return the parsed version.
:param opencode_path: Path to the ``opencode`` binary.
:returns: Parsed version string, e.g. ``"1.17.7"``.
:raises OpenCodeVersionError: When the version cannot be determined.
"""
try:
completed = subprocess.run(
[opencode_path, "--version"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise OpenCodeVersionError(f"Could not run 'opencode --version': {exc}") from exc
output = f"{completed.stdout}\n{completed.stderr}"
version = parse_opencode_version(output)
if version is None:
raise OpenCodeVersionError(f"Could not parse OpenCode version from: {output!r}")
return version
def allocate_loopback_port() -> int:
"""
Allocate an ephemeral loopback TCP port.
:returns: A free port number on ``127.0.0.1``.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def build_opencode_serve_args(
*,
hostname: str,
port: int,
opencode_args: Sequence[str] = (),
) -> list[str]:
"""
Build the ``opencode serve`` argv tail (after the executable).
Always passes explicit ``--hostname``/``--port`` so config can't
override them (the source default port is ``0``).
:param hostname: Bind hostname, e.g. ``"127.0.0.1"``.
:param port: Bind port.
:param opencode_args: Extra pass-through args.
:returns: Argv tail, e.g. ``["serve", "--hostname", "127.0.0.1",
"--port", "49231"]``.
"""
return ["serve", "--hostname", hostname, "--port", str(port), *opencode_args]
def build_opencode_attach_args(
*,
server_url: str,
workspace: str,
session_id: str | None,
opencode_args: Sequence[str] = (),
) -> list[str]:
"""
Build the ``opencode attach`` argv for a terminal takeover.
Mirrors codex's ``--remote`` attach: the TUI attaches to the
already-running server so the terminal, forwarder, and web-UI bridge
all drive the same OpenCode session.
:param server_url: The server URL, e.g. ``"http://127.0.0.1:49231"``.
:param workspace: Directory the TUI runs in (``--dir``).
:param session_id: OpenCode session id to attach (``--session``), or
``None`` to let the TUI choose.
:param opencode_args: Extra pass-through args appended last.
:returns: Argv tail after the executable.
"""
args = ["attach", server_url, "--dir", workspace]
if session_id:
args.extend(["--session", session_id])
args.extend(opencode_args)
return args
def filtered_server_env(
*,
bridge_dir: Path,
auth_secret: str,
extra_env: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""
Build the launch environment for ``opencode serve``.
Per-session XDG dirs isolate OpenCode's state from the user's global
config; ``OPENCODE_SERVER_PASSWORD`` secures the loopback server. Only
provider/proxy env from the parent is passed through.
:param bridge_dir: Native OpenCode bridge directory.
:param auth_secret: Server password for basic auth.
:param extra_env: Additional provider env (e.g. from Omnigent setup).
:returns: The environment mapping for the server subprocess.
"""
env: dict[str, str] = {}
for key, value in os.environ.items():
if key in _ENV_OPENCODE_CONFIG_DENYLIST:
# Never inherit the parent's global OpenCode config — the
# per-session XDG dirs are the only config source.
continue
if key in _ENV_PASSTHROUGH_KEYS or key.startswith(_ENV_PASSTHROUGH_PREFIXES):
env[key] = value
env.update(extra_env or {})
env["XDG_DATA_HOME"] = str(xdg_data_home_for_bridge_dir(bridge_dir))
env["XDG_CONFIG_HOME"] = str(xdg_config_home_for_bridge_dir(bridge_dir))
env[OPENCODE_SERVER_PASSWORD_ENV_VAR] = auth_secret
env[OPENCODE_SERVER_USERNAME_ENV_VAR] = OPENCODE_DEFAULT_USERNAME
return env
def opencode_terminal_env(server: OpenCodeNativeServer) -> dict[str, str]:
"""
Build terminal-process env for the native OpenCode TUI (``attach``).
Keeping the password in the environment avoids leaking it on argv
(``--password`` defaults to ``OPENCODE_SERVER_PASSWORD``).
:param server: The running server wrapper.
:returns: Environment variables for the attach terminal process.
"""
return {
OPENCODE_SERVER_PASSWORD_ENV_VAR: server.auth_secret,
OPENCODE_SERVER_USERNAME_ENV_VAR: OPENCODE_DEFAULT_USERNAME,
"XDG_DATA_HOME": str(server.xdg_data_home),
"XDG_CONFIG_HOME": str(server.xdg_config_home),
}
class OpenCodeNativeServer:
"""
A managed ``opencode serve`` subprocess bound to one conversation.
:param bridge_dir: Native OpenCode bridge directory.
:param workspace: Working directory for the server.
:param opencode_path: Path to the ``opencode`` binary; ``None``
searches ``PATH``.
:param hostname: Bind hostname (always loopback).
:param port: Explicit port; ``None`` allocates an ephemeral one.
:param extra_env: Provider env merged into the launch environment.
:param opencode_args: Extra ``serve`` pass-through args.
:param verify_version: Whether to version-check the CLI on start.
"""
def __init__(
self,
*,
bridge_dir: Path,
workspace: Path,
opencode_path: str | None = None,
hostname: str = "127.0.0.1",
port: int | None = None,
extra_env: Mapping[str, str] | None = None,
opencode_args: Sequence[str] = (),
verify_version: bool = True,
) -> None:
self.bridge_dir = bridge_dir
self.workspace = workspace
self.hostname = hostname
self._explicit_port = port
self._extra_env = dict(extra_env or {})
self._opencode_args = tuple(opencode_args)
self._verify_version = verify_version
self.opencode_path = find_opencode_cli(opencode_path)
self.auth_secret = ensure_auth_secret(bridge_dir)
self.xdg_data_home = xdg_data_home_for_bridge_dir(bridge_dir)
self.xdg_config_home = xdg_config_home_for_bridge_dir(bridge_dir)
self.port: int | None = port
self.process: subprocess.Popen[bytes] | None = None
self.version: str | None = None
@property
def base_url(self) -> str:
""":returns: The server base URL once a port is bound."""
if self.port is None:
raise RuntimeError("OpenCode server has no port yet; call start() first")
return f"http://{self.hostname}:{self.port}"
@property
def auth_headers(self) -> dict[str, str]:
""":returns: Basic-auth headers for the server."""
return auth_headers_for_secret(self.auth_secret)
@property
def env(self) -> dict[str, str]:
""":returns: The launch environment for the server process."""
return filtered_server_env(
bridge_dir=self.bridge_dir,
auth_secret=self.auth_secret,
extra_env=self._extra_env,
)
def build_argv(self) -> list[str]:
"""
Build the full server argv for the resolved port.
:returns: ``[opencode, serve, --hostname, ..., --port, ...]``.
:raises RuntimeError: When no port has been allocated.
"""
if self.port is None:
raise RuntimeError("OpenCode server port not allocated")
return [
self.opencode_path,
*build_opencode_serve_args(
hostname=self.hostname,
port=self.port,
opencode_args=self._opencode_args,
),
]
async def start(self) -> None:
"""
Launch the server subprocess and wait until it is ready.
:raises OpenCodeVersionError: When the CLI version is unsupported.
:raises RuntimeError: When the server does not become ready.
"""
if self._verify_version:
self.version = resolve_opencode_version(self.opencode_path)
check_opencode_version(self.version)
if self.port is None:
self.port = self._explicit_port or allocate_loopback_port()
argv = self.build_argv()
_logger.info(
"Launching opencode serve: port=%s workspace=%s xdg_data=%s",
self.port,
self.workspace,
self.xdg_data_home,
)
self.process = subprocess.Popen(
argv,
cwd=str(self.workspace),
env=self.env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
await self._wait_until_ready()
async def _wait_until_ready(self, *, attempts: int = 60, delay: float = 0.5) -> None:
"""
Poll the HTTP API until the server answers or attempts run out.
:param attempts: Maximum readiness polls.
:param delay: Seconds between polls.
:raises RuntimeError: When the server never becomes ready (or the
process died early).
"""
last_error: Exception | None = None
async with httpx.AsyncClient(
base_url=self.base_url,
headers=self.auth_headers,
timeout=httpx.Timeout(5.0, connect=2.0),
) as client:
for _ in range(attempts):
if self.process is not None and self.process.poll() is not None:
raise RuntimeError(
f"opencode serve exited early with code {self.process.returncode}"
)
try:
response = await client.get("/session")
if response.status_code < 500:
return
except httpx.HTTPError as exc:
last_error = exc
await asyncio.sleep(delay)
raise RuntimeError(f"opencode serve did not become ready: {last_error!r}")
def client(self, *, directory: str | None = None) -> OpenCodeClient:
"""
Build an :class:`OpenCodeClient` bound to this server.
:param directory: Optional workspace directory routing header.
:returns: A new client (caller owns closing it).
"""
return OpenCodeClient(
self.base_url,
headers=self.auth_headers,
directory=directory or str(self.workspace),
)
async def close(self) -> None: # pragma: no cover
"""Terminate the server subprocess if running."""
process = self.process
if process is None:
return
if process.poll() is None:
process.terminate()
try:
await asyncio.to_thread(process.wait, 10)
except subprocess.TimeoutExpired:
process.kill()
await asyncio.to_thread(process.wait)
self.process = None
def client_for_state(
*,
base_url: str,
auth_secret: str | None,
directory: str | None = None,
) -> OpenCodeClient:
"""
Build an :class:`OpenCodeClient` from persisted bridge state.
Used by the harness-side executor, which never owns the server process
— it only has the URL + auth secret from bridge state.
:param base_url: Server base URL.
:param auth_secret: Server password, or ``None``.
:param directory: Optional workspace routing header.
:returns: A new client.
"""
return OpenCodeClient(
base_url,
headers=auth_headers_for_secret(auth_secret),
directory=directory,
)
+443
View File
@@ -0,0 +1,443 @@
"""Bridge state for native OpenCode (``opencode serve``) sessions.
The OpenCode native harness mirrors the Codex native bridge, but the
transport is HTTP + SSE instead of WebSocket JSON-RPC. The runner owns
the ``opencode serve`` process and the SSE forwarder; the harness-side
executor (spawned as a separate FastAPI process) reads this bridge state
to learn the loopback server URL, auth secret, and OpenCode session id so
it can inject web turns over REST.
Layout (per bridge id):
~/.omnigent/opencode-native/<sha256(bridge_id)[:32]>/
state.json # runtime state (mutates each turn)
auth.secret # OPENCODE_SERVER_PASSWORD for this server
xdg-data/ # XDG_DATA_HOME for the per-session opencode
xdg-config/ # XDG_CONFIG_HOME for the per-session opencode
State (server URL, opencode session id, active message) is written by the
runner-owned server manager / forwarder and read by the harness executor;
the XDG dirs are preserved across runner restarts so a local resume keeps
OpenCode's persisted session history.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
# Env var the runner stamps on the harness process so the executor can
# locate its bridge directory. Mirrors ``HARNESS_CODEX_NATIVE_BRIDGE_DIR``.
OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"
# Label key recording the bridge id on the conversation, mirroring the
# codex-native ``omnigent.codex_native.bridge_id`` label.
OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY = "omnigent.opencode_native.bridge_id"
# OpenCode server basic-auth env vars (see opencode ``attach``/``serve``).
OPENCODE_SERVER_PASSWORD_ENV_VAR = "OPENCODE_SERVER_PASSWORD"
OPENCODE_SERVER_USERNAME_ENV_VAR = "OPENCODE_SERVER_USERNAME"
# Default basic-auth username opencode falls back to when unset.
OPENCODE_DEFAULT_USERNAME = "opencode"
_STATE_FILE = "state.json"
_AUTH_SECRET_FILE = "auth.secret"
_XDG_DATA_DIR = "xdg-data"
_XDG_CONFIG_DIR = "xdg-config"
_STATE_VERSION = 1
_BRIDGE_ROOT = Path.home() / ".omnigent" / "opencode-native"
_ID_HASH_CHARS = 32
def bridge_root() -> Path:
"""
Return the configured OpenCode-native bridge root.
Tests may monkeypatch :data:`_BRIDGE_ROOT` to isolate bridge files.
:returns: Absolute root for OpenCode-native bridge directories, e.g.
``Path("~/.omnigent/opencode-native")``.
"""
return _BRIDGE_ROOT
@dataclass(frozen=True)
class OpenCodeNativeBridgeState:
"""
Runtime state shared by the native OpenCode wrapper and harness.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param server_base_url: Loopback base URL of ``opencode serve``, e.g.
``"http://127.0.0.1:49231"``.
:param opencode_session_id: OpenCode session id, e.g. ``"ses_abc123"``.
:param auth_secret: ``OPENCODE_SERVER_PASSWORD`` for basic auth, or
``None`` when the server runs without auth.
:param xdg_data_home: ``XDG_DATA_HOME`` the server runs with.
:param xdg_config_home: ``XDG_CONFIG_HOME`` the server runs with.
:param active_message_id: OpenCode assistant message id of the active
turn, or ``None`` when idle.
:param status: Coarse status, ``"idle"`` or ``"busy"``.
:param model_override: Persisted model override, e.g.
``"anthropic/claude-opus-4"``, or ``None``.
:param workspace: Workspace cwd the session runs in.
:param last_event_id: Last SSE event id seen, for resume/debug.
"""
session_id: str
server_base_url: str
opencode_session_id: str
auth_secret: str | None = None
xdg_data_home: str | None = None
xdg_config_home: str | None = None
active_message_id: str | None = None
status: str = "idle"
model_override: str | None = None
workspace: str | None = None
last_event_id: str | None = None
def auth_headers(self) -> dict[str, str]:
"""
Build basic-auth headers for the OpenCode server.
:returns: ``{"Authorization": "Basic ..."}`` when an auth secret
is set, otherwise an empty dict.
"""
return auth_headers_for_secret(self.auth_secret)
def auth_headers_for_secret(secret: str | None) -> dict[str, str]:
"""
Build OpenCode basic-auth headers for a server password.
:param secret: The ``OPENCODE_SERVER_PASSWORD`` value, or ``None``.
:returns: ``{"Authorization": "Basic <b64(user:secret)>"}`` or ``{}``.
"""
if not secret:
return {}
raw = f"{OPENCODE_DEFAULT_USERNAME}:{secret}".encode()
token = base64.b64encode(raw).decode("ascii")
return {"Authorization": f"Basic {token}"}
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
"""
Return the bridge directory for an OpenCode-native bridge id.
:param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``.
:returns: Absolute bridge directory under
``~/.omnigent/opencode-native``.
"""
digest = hashlib.sha256(bridge_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
return _BRIDGE_ROOT / digest
def build_opencode_native_spawn_env(
conversation_id: str,
*,
bridge_id: str | None = None,
) -> dict[str, str]:
"""
Build spawn env for the ``opencode-native`` harness process.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param bridge_id: Opaque bridge id; ``None`` uses *conversation_id*.
:returns: Environment variables the OpenCode-native executor needs.
"""
resolved_bridge_id = bridge_id or conversation_id
return {
OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir_for_bridge_id(resolved_bridge_id)),
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR: conversation_id,
}
def prepare_bridge_dir(bridge_id: str) -> Path:
"""
Create the bridge directory (and XDG roots) for *bridge_id*.
:param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``.
:returns: Prepared absolute bridge directory.
"""
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(bridge_dir, 0o700)
xdg_data_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True)
xdg_config_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True)
return bridge_dir
def xdg_data_home_for_bridge_dir(bridge_dir: Path) -> Path:
"""
Return the per-session ``XDG_DATA_HOME`` for *bridge_dir*.
:param bridge_dir: Native OpenCode bridge directory.
:returns: Absolute ``XDG_DATA_HOME`` directory.
"""
return bridge_dir / _XDG_DATA_DIR
def xdg_config_home_for_bridge_dir(bridge_dir: Path) -> Path:
"""
Return the per-session ``XDG_CONFIG_HOME`` for *bridge_dir*.
:param bridge_dir: Native OpenCode bridge directory.
:returns: Absolute ``XDG_CONFIG_HOME`` directory.
"""
return bridge_dir / _XDG_CONFIG_DIR
def user_opencode_auth_path() -> Path:
"""
Return the user's real OpenCode ``auth.json`` path (not the per-session one).
Honors ``XDG_DATA_HOME`` (the runner's own env, which is the user's real
data home — the per-session override is set only on the spawned server),
defaulting to ``~/.local/share/opencode/auth.json``.
"""
xdg = os.environ.get("XDG_DATA_HOME", "").strip()
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
return base / "opencode" / "auth.json"
def seed_opencode_auth(bridge_dir: Path) -> Path | None:
"""
Copy the user's OpenCode ``auth.json`` into the per-session ``XDG_DATA_HOME``.
The runner spawns ``opencode serve`` with a per-session ``XDG_DATA_HOME``
that isolates session state — but it also hides the user's
``opencode auth login`` credentials (in their real
``~/.local/share/opencode/auth.json``). Without those, the server can only
reach OpenCode's no-auth default model (``opencode/big-pickle``), so a
user-selected provider/model never takes effect. Copy the credentials in
(best-effort, ``0600``) so the user's providers — and any pinned model that
needs them — work. Refreshed on every spawn so re-logins propagate.
:param bridge_dir: Native OpenCode bridge directory.
:returns: The destination path written, or ``None`` when there is no
source ``auth.json`` or the copy fails.
"""
src = user_opencode_auth_path()
if not src.is_file():
return None
dest_dir = xdg_data_home_for_bridge_dir(bridge_dir) / "opencode"
try:
dest_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
dest = dest_dir / "auth.json"
shutil.copyfile(src, dest)
os.chmod(dest, 0o600)
except OSError:
return None
return dest
def auth_secret_path(bridge_dir: Path) -> Path:
"""
Return the auth-secret file path for *bridge_dir*.
:param bridge_dir: Native OpenCode bridge directory.
:returns: Absolute path of the ``auth.secret`` file.
"""
return bridge_dir / _AUTH_SECRET_FILE
def ensure_auth_secret(bridge_dir: Path) -> str:
"""
Read or mint the per-session OpenCode server password.
The secret is reused across server restarts for one bridge dir so a
resumed server keeps the same basic-auth credential the TUI/executor
were configured with. Written ``0600``.
:param bridge_dir: Native OpenCode bridge directory.
:returns: The server password (``OPENCODE_SERVER_PASSWORD``).
"""
path = auth_secret_path(bridge_dir)
try:
existing = path.read_text(encoding="utf-8").strip()
if existing:
return existing
except FileNotFoundError:
# No secret on disk yet: fall through to mint a fresh one below.
pass
except OSError:
# Secret exists but is unreadable: ignore and regenerate it below.
pass
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
secret = secrets.token_urlsafe(32)
fd, tmp_name = tempfile.mkstemp(prefix=f"{_AUTH_SECRET_FILE}.", dir=str(bridge_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(secret)
handle.write("\n")
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return secret
def state_path(bridge_dir: Path) -> Path:
"""
Return the bridge state file path for *bridge_dir*.
:param bridge_dir: Native OpenCode bridge directory.
:returns: Absolute path of the ``state.json`` file.
"""
return bridge_dir / _STATE_FILE
def write_bridge_state(bridge_dir: Path, state: OpenCodeNativeBridgeState) -> None:
"""
Persist shared native OpenCode state atomically.
:param bridge_dir: Native OpenCode bridge directory.
:param state: State payload to persist.
:returns: None.
"""
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
path = state_path(bridge_dir)
fd, tmp_name = tempfile.mkstemp(prefix=f"{_STATE_FILE}.", dir=str(bridge_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(
{
"version": _STATE_VERSION,
"session_id": state.session_id,
"server_base_url": state.server_base_url,
"opencode_session_id": state.opencode_session_id,
"auth_secret": state.auth_secret,
"xdg_data_home": state.xdg_data_home,
"xdg_config_home": state.xdg_config_home,
"active_message_id": state.active_message_id,
"status": state.status,
"model_override": state.model_override,
"workspace": state.workspace,
"last_event_id": state.last_event_id,
},
handle,
sort_keys=True,
)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
def clear_bridge_state(bridge_dir: Path) -> None:
"""
Remove stale native OpenCode runtime state for a bridge directory.
New server launches reuse the same bridge directory for a conversation
id, but the old ``state.json`` may point at a server URL from a
previous process. Clear it before starting the new server so web
message forwarding waits for the new launch to publish its current URL
and session instead of injecting into stale state.
:param bridge_dir: Native OpenCode bridge directory.
:returns: None.
"""
try:
state_path(bridge_dir).unlink()
except FileNotFoundError:
return
def read_bridge_state(bridge_dir: Path) -> OpenCodeNativeBridgeState | None:
"""
Read shared native OpenCode bridge state.
Corrupt / partial JSON is treated as absent (returns ``None``) so a
half-written file never crashes a turn.
:param bridge_dir: Native OpenCode bridge directory.
:returns: Parsed state, or ``None`` when no valid state exists.
"""
path = state_path(bridge_dir)
if not path.is_file():
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw, dict):
return None
session_id = raw.get("session_id")
server_base_url = raw.get("server_base_url")
opencode_session_id = raw.get("opencode_session_id")
required = (session_id, server_base_url, opencode_session_id)
if not all(isinstance(value, str) and value for value in required):
return None
def _opt_str(key: str) -> str | None:
value = raw.get(key)
return value if isinstance(value, str) and value else None
status = raw.get("status")
return OpenCodeNativeBridgeState(
session_id=session_id,
server_base_url=server_base_url,
opencode_session_id=opencode_session_id,
auth_secret=_opt_str("auth_secret"),
xdg_data_home=_opt_str("xdg_data_home"),
xdg_config_home=_opt_str("xdg_config_home"),
active_message_id=_opt_str("active_message_id"),
status=status if isinstance(status, str) and status else "idle",
model_override=_opt_str("model_override"),
workspace=_opt_str("workspace"),
last_event_id=_opt_str("last_event_id"),
)
def update_active_message_id(
bridge_dir: Path,
active_message_id: str | None,
*,
status: str | None = None,
) -> None:
"""
Update the active OpenCode message id (and optionally status).
:param bridge_dir: Native OpenCode bridge directory.
:param active_message_id: Active assistant message id, or ``None``.
:param status: New coarse status (``"idle"`` / ``"busy"``); ``None``
leaves the existing status untouched.
:returns: None.
"""
state = read_bridge_state(bridge_dir)
if state is None:
return
import dataclasses
write_bridge_state(
bridge_dir,
dataclasses.replace(
state,
active_message_id=active_message_id,
status=status if status is not None else state.status,
),
)
def update_last_event_id(bridge_dir: Path, last_event_id: str) -> None:
"""
Record the last SSE event id seen by the forwarder.
:param bridge_dir: Native OpenCode bridge directory.
:param last_event_id: Last SSE event id, e.g. ``"evt_..."``.
:returns: None.
"""
state = read_bridge_state(bridge_dir)
if state is None:
return
import dataclasses
write_bridge_state(bridge_dir, dataclasses.replace(state, last_event_id=last_event_id))
+434
View File
@@ -0,0 +1,434 @@
"""Typed HTTP + SSE client for an ``opencode serve`` native server.
Shaped from the pinned OpenCode OpenAPI (``opencode`` 1.17.x,
``packages/sdk/openapi.json``). This is a thin typed wrapper over the v1
REST endpoints the Omnigent OpenCode-native harness needs plus the SSE
``GET /event`` stream — not a full generated SDK. Unknown response fields
are preserved under ``raw`` for forward-compatible logging and fixtures.
Transport notes:
- REST + SSE over ``httpx.AsyncClient``; the server binds loopback only.
- Basic auth headers (``OPENCODE_SERVER_PASSWORD``) are attached per
request when provided.
- SSE is parsed with standard ``event:`` / ``data:`` framing; each event
payload is OpenCode's ``{id?, type, properties}`` envelope.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass, field
from typing import Any
import httpx
_logger = logging.getLogger(__name__)
# Pinned OpenCode CLI/API version range. The source monorepo reports
# 1.17.7; we accept 1.17.x and refuse 1.18+ until validated.
OPENCODE_MIN_VERSION = "1.17.7"
OPENCODE_MAX_VERSION_EXCLUSIVE = "1.18.0"
_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
@dataclass(frozen=True)
class OpenCodeSession:
"""
An OpenCode session as returned by the REST API.
:param id: OpenCode session id, e.g. ``"ses_abc123"``.
:param title: Optional human-readable title.
:param parent_id: Parent session id for forked/child sessions.
:param directory: Session working directory, when reported.
:param raw: The full server payload for forward-compatibility.
"""
id: str
title: str | None = None
parent_id: str | None = None
directory: str | None = None
raw: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> OpenCodeSession:
"""
Build an :class:`OpenCodeSession` from a raw server payload.
:param payload: Decoded JSON object from ``/session`` endpoints.
:returns: Parsed session.
:raises ValueError: When the payload has no string ``id``.
"""
session_id = payload.get("id")
if not isinstance(session_id, str) or not session_id:
raise ValueError("OpenCode session payload missing string 'id'")
title = payload.get("title")
parent_id = payload.get("parentID")
directory = payload.get("directory")
return cls(
id=session_id,
title=title if isinstance(title, str) else None,
parent_id=parent_id if isinstance(parent_id, str) else None,
directory=directory if isinstance(directory, str) else None,
raw=dict(payload),
)
@dataclass(frozen=True)
class OpenCodeEvent:
"""
One decoded OpenCode SSE event.
:param id: Optional SSE event id.
:param type: Event discriminator, e.g. ``"message.part.updated"`` or
``"session.next.text.delta"``.
:param properties: The event's ``properties`` object.
:param raw: The full decoded envelope for debugging/forward-compat.
"""
id: str | None
type: str
properties: dict[str, Any]
raw: dict[str, Any]
@classmethod
def from_envelope(
cls, envelope: Mapping[str, Any], *, event_id: str | None = None
) -> OpenCodeEvent:
"""
Build an :class:`OpenCodeEvent` from a decoded SSE data object.
:param envelope: Decoded JSON, e.g.
``{"type": "message.part.updated", "properties": {...}}``.
:param event_id: Optional SSE ``id:`` framing value.
:returns: Parsed event; unknown shapes get ``type=""``.
"""
type_value = envelope.get("type")
props = envelope.get("properties")
return cls(
id=envelope.get("id") if isinstance(envelope.get("id"), str) else event_id,
type=type_value if isinstance(type_value, str) else "",
properties=props if isinstance(props, dict) else {},
raw=dict(envelope),
)
class OpenCodeClientError(RuntimeError):
"""Raised when an OpenCode REST call returns a non-2xx response."""
class OpenCodeClient:
"""
Async HTTP + SSE client for one ``opencode serve`` server.
:param base_url: Server base URL, e.g. ``"http://127.0.0.1:49231"``.
:param headers: Optional default headers (e.g. basic auth).
:param directory: Optional workspace directory; sent as the
``x-opencode-directory`` header so ``serve`` routes per-request
instances to the right workspace.
:param client: Optional injected ``httpx.AsyncClient`` (tests pass a
client backed by ``httpx.MockTransport``).
"""
def __init__(
self,
base_url: str,
*,
headers: Mapping[str, str] | None = None,
directory: str | None = None,
client: httpx.AsyncClient | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
default_headers: dict[str, str] = dict(headers or {})
if directory:
default_headers.setdefault("x-opencode-directory", directory)
self._directory = directory
self._owns_client = client is None
self._client = client or httpx.AsyncClient(
base_url=self._base_url,
headers=default_headers,
timeout=_DEFAULT_TIMEOUT,
)
# When a client is injected (tests), still apply our headers so
# auth/directory routing is exercised.
if client is not None:
for key, value in default_headers.items():
self._client.headers.setdefault(key, value)
@property
def base_url(self) -> str:
""":returns: The server base URL this client targets."""
return self._base_url
async def aclose(self) -> None:
"""Close the underlying client when this wrapper owns it."""
if self._owns_client:
await self._client.aclose()
async def __aenter__(self) -> OpenCodeClient:
return self
async def __aexit__(self, *exc: object) -> None:
await self.aclose()
# --- helpers ---------------------------------------------------------
async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
"""
Issue a request and return decoded JSON, raising on HTTP errors.
:param method: HTTP method, e.g. ``"POST"``.
:param path: Path relative to ``base_url``, e.g. ``"/session"``.
:returns: Decoded JSON (object/array/scalar), or ``None`` for an
empty body.
:raises OpenCodeClientError: On a non-2xx status.
"""
response = await self._client.request(method, path, **kwargs)
if response.status_code >= 400:
raise OpenCodeClientError(
f"OpenCode {method} {path} failed: {response.status_code} {response.text[:500]}"
)
if not response.content:
return None
try:
return response.json()
except json.JSONDecodeError:
return None
# --- sessions --------------------------------------------------------
async def create_session(self, payload: Mapping[str, Any] | None = None) -> OpenCodeSession:
"""
Create an OpenCode session (``POST /session``).
:param payload: Optional create body, e.g. ``{"title": "..."}``.
Note: OpenCode's create body only accepts ``title`` / ``parentID``
— it does NOT accept a model (the model is a per-prompt field on
``POST /session/{id}/message``). Pin the model per prompt via
:func:`omnigent.opencode_http_transport.build_prompt_payload`.
:returns: The created session.
"""
data = await self._request_json("POST", "/session", json=dict(payload or {}))
if not isinstance(data, Mapping):
raise OpenCodeClientError("OpenCode create_session returned a non-object body")
return OpenCodeSession.from_payload(data)
async def get_session(self, session_id: str) -> OpenCodeSession | None:
"""
Fetch one session (``GET /session/{id}``).
:param session_id: OpenCode session id.
:returns: The session, or ``None`` when it does not exist.
"""
response = await self._client.request("GET", f"/session/{session_id}")
if response.status_code == 404:
return None
if response.status_code >= 400:
raise OpenCodeClientError(
f"OpenCode get_session failed: {response.status_code} {response.text[:500]}"
)
data = response.json()
if not isinstance(data, Mapping):
return None
return OpenCodeSession.from_payload(data)
async def list_messages(self, session_id: str) -> list[dict[str, Any]]:
"""
List a session's messages (``GET /session/{id}/message``).
:param session_id: OpenCode session id.
:returns: A list of message objects (each typically
``{"info": {...}, "parts": [...]}``).
"""
data = await self._request_json("GET", f"/session/{session_id}/message")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return []
async def get_message(self, session_id: str, message_id: str) -> dict[str, Any]:
"""
Fetch one message (``GET /session/{id}/message/{messageID}``).
:param session_id: OpenCode session id.
:param message_id: OpenCode message id.
:returns: The message object, or ``{}`` when absent.
"""
data = await self._request_json("GET", f"/session/{session_id}/message/{message_id}")
return data if isinstance(data, dict) else {}
async def prompt(self, session_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
"""
Send a (blocking) prompt (``POST /session/{id}/message``).
:param session_id: OpenCode session id.
:param payload: Prompt body, e.g. ``{"parts": [...]}``.
:returns: The server response object (often the assistant message).
"""
data = await self._request_json(
"POST", f"/session/{session_id}/message", json=dict(payload)
)
return data if isinstance(data, dict) else {}
async def prompt_async(self, session_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
"""
Admit a prompt without blocking (``POST /session/{id}/prompt_async``).
Preferred for native-server parity: the call returns once the
prompt is admitted; the assistant output streams over SSE.
:param session_id: OpenCode session id.
:param payload: Prompt body, e.g. ``{"parts": [...]}``.
:returns: The server response object (may be empty).
"""
data = await self._request_json(
"POST", f"/session/{session_id}/prompt_async", json=dict(payload)
)
return data if isinstance(data, dict) else {}
async def abort(self, session_id: str) -> bool:
"""
Abort active work (``POST /session/{id}/abort``).
:param session_id: OpenCode session id.
:returns: ``True`` when the server reports an abort happened.
"""
data = await self._request_json("POST", f"/session/{session_id}/abort")
return bool(data)
async def fork(
self, session_id: str, payload: Mapping[str, Any] | None = None
) -> OpenCodeSession:
"""
Fork a session (``POST /session/{id}/fork``).
:param session_id: Source OpenCode session id.
:param payload: Optional fork body, e.g. ``{"messageID": "msg_..."}``.
:returns: The new forked session.
"""
data = await self._request_json(
"POST", f"/session/{session_id}/fork", json=dict(payload or {})
)
if not isinstance(data, Mapping):
raise OpenCodeClientError("OpenCode fork returned a non-object body")
return OpenCodeSession.from_payload(data)
# --- permissions -----------------------------------------------------
async def list_permissions(self) -> list[dict[str, Any]]:
"""
List pending permission requests (``GET /permission``).
:returns: A list of permission request objects.
"""
data = await self._request_json("GET", "/permission")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return []
async def reply_permission(self, request_id: str, reply: Mapping[str, Any]) -> bool:
"""
Reply to a permission request (``POST /permission/{id}/reply``).
:param request_id: OpenCode permission request id.
:param reply: Reply body, e.g. ``{"reply": "once"}`` where reply is
one of ``once`` / ``always`` / ``reject``.
:returns: ``True`` on a 2xx response.
"""
response = await self._client.request(
"POST", f"/permission/{request_id}/reply", json=dict(reply)
)
if response.status_code >= 400:
raise OpenCodeClientError(
f"OpenCode reply_permission failed: {response.status_code} {response.text[:500]}"
)
return True
# --- events ----------------------------------------------------------
async def events(self) -> AsyncIterator[OpenCodeEvent]:
"""
Stream server events over SSE (``GET /event``).
Yields one :class:`OpenCodeEvent` per parsed SSE event. The
iterator ends when the server closes the stream; callers own
reconnect/backoff.
:returns: Async iterator of decoded events.
"""
async with self._client.stream("GET", "/event", timeout=None) as response:
if response.status_code >= 400:
body = await response.aread()
raise OpenCodeClientError(
f"OpenCode /event failed: {response.status_code} {body[:200]!r}"
)
async for event in _parse_sse(response.aiter_lines()):
yield event
async def _parse_sse(lines: AsyncIterator[str]) -> AsyncIterator[OpenCodeEvent]:
"""
Parse a stream of SSE lines into :class:`OpenCodeEvent` objects.
Implements the subset of the SSE spec OpenCode uses: ``id:``,
``event:`` and (possibly multi-line) ``data:`` fields, with a blank
line dispatching the accumulated event. ``data`` payloads are decoded
as JSON; non-JSON data blocks are skipped (logged at debug).
:param lines: Async iterator of decoded SSE text lines.
:returns: Async iterator of parsed events.
"""
event_id: str | None = None
data_lines: list[str] = []
async for raw_line in lines:
line = raw_line.rstrip("\n").rstrip("\r")
if line == "":
if data_lines:
payload = "\n".join(data_lines)
data_lines = []
current_id = event_id
event_id = None
parsed = _decode_event(payload, current_id)
if parsed is not None:
yield parsed
else:
event_id = None
continue
if line.startswith(":"):
# SSE comment / heartbeat.
continue
field_name, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field_name == "data":
data_lines.append(value)
elif field_name == "id":
event_id = value
# ``event:`` and ``retry:`` are accepted but unused; OpenCode
# encodes the discriminator inside the JSON ``type`` field.
# Flush a trailing event with no terminating blank line.
if data_lines:
parsed = _decode_event("\n".join(data_lines), event_id)
if parsed is not None:
yield parsed
def _decode_event(payload: str, event_id: str | None) -> OpenCodeEvent | None:
"""
Decode one SSE ``data`` payload into an :class:`OpenCodeEvent`.
:param payload: Raw JSON text from one or more ``data:`` lines.
:param event_id: Optional SSE ``id:`` value for the event.
:returns: Parsed event, or ``None`` when the payload is not a JSON
object.
"""
try:
decoded = json.loads(payload)
except json.JSONDecodeError:
_logger.debug("Skipping non-JSON OpenCode SSE data: %s", payload[:200])
return None
if not isinstance(decoded, dict):
return None
return OpenCodeEvent.from_envelope(decoded, event_id=event_id)
+636
View File
@@ -0,0 +1,636 @@
"""SSE consumer that mirrors OpenCode events into an Omnigent session.
The runner owns this forwarder (parallel to the codex-native forwarder).
It connects to the per-session ``opencode serve`` SSE stream (``GET
/event``), filters to the session's OpenCode session id, and translates
OpenCode events into Omnigent session-stream events posted to
``/v1/sessions/{id}/events`` — the same envelope the codex forwarder uses
(``external_conversation_item`` / ``external_session_status`` /
``external_output_text_delta``).
Design references: the SSE-event → Omnigent-event translation table in
``designs/opencode-harness-and-unified-interface.md`` §A.9. The forwarder
is tolerant of unknown events (logged, never fatal) and dedupes by stable
OpenCode message / part / tool-call ids so web and TUI driving the same
session never double-post.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
from omnigent.opencode_native_bridge import update_active_message_id, update_last_event_id
from omnigent.opencode_native_client import OpenCodeClient, OpenCodeEvent
from omnigent.opencode_native_permissions import (
PolicyDecision,
decision_to_reply,
map_verdict_to_decision,
normalize_for_policy,
parse_permission_request,
reply_body,
)
_logger = logging.getLogger(__name__)
_AGENT_NAME = "opencode"
# Omnigent session-event types (must match the server's ingestion route;
# shared with the codex-native forwarder).
_EXTERNAL_ITEM = "external_conversation_item"
_EXTERNAL_STATUS = "external_session_status"
_STATUS_RUNNING = "running"
_STATUS_IDLE = "idle"
# Bound the dedupe set so a long-lived session can't grow it without limit.
_MAX_DEDUPE_KEYS = 8192
# Policy verdict resolver: receives a normalized policy input and returns a
# verdict mapping (or None when no policy is configured / reachable).
PolicyEvaluator = Callable[[Mapping[str, Any]], Awaitable[Mapping[str, Any] | None]]
@dataclass
class OpenCodeForwarderState:
"""
Mutable per-run forwarder state.
:param seen: Bounded set of dedupe keys already posted.
:param turn_active: Whether a turn is currently streaming.
"""
seen: OrderedDict[str, None] = field(default_factory=OrderedDict)
turn_active: bool = False
def mark(self, key: str) -> bool:
"""
Record *key*; return ``True`` the first time it is seen.
:param key: Stable dedupe key, e.g. ``"opencode:ses:msg:prt"``.
:returns: ``True`` when newly seen, ``False`` for a duplicate.
"""
if key in self.seen:
return False
self.seen[key] = None
while len(self.seen) > _MAX_DEDUPE_KEYS:
self.seen.popitem(last=False)
return True
class OpenCodeNativeForwarder:
"""
Translate one OpenCode session's SSE stream into Omnigent events.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param opencode_session_id: OpenCode session id to filter on.
:param opencode_client: Client connected to the ``opencode serve``
server (for SSE + permission replies).
:param server_client: HTTP client for the Omnigent server (event posts).
:param bridge_dir: Native OpenCode bridge directory (status/active-id
persistence). ``None`` disables bridge writes (tests).
:param workspace: Session workspace, used for permission normalization.
:param policy_evaluator: Optional async policy resolver. Production
wires one that POSTs each request to
``/v1/sessions/{id}/policies/evaluate`` (see
``omnigent.runner.app._build_opencode_policy_evaluator``) — the SAME
server gate codex-native's policy hook uses, where an ``ask`` verdict
is parked as a human approval card and blocks until a human resolves
it. ``None`` uses *default_decision* for every request.
:param default_decision: Decision used when no evaluator is provided or
it returns ``None`` (evaluator unreachable / no verdict). Defaults to
``reject`` so an unconfigured or unreachable policy FAILS CLOSED — a
headless OpenCode turn must NEVER silently auto-approve a sensitive
operation. Only an explicit policy ``allow`` reaches
``once``/``always``.
"""
def __init__(
self,
*,
session_id: str,
opencode_session_id: str,
opencode_client: OpenCodeClient,
server_client: httpx.AsyncClient,
bridge_dir: Path | None = None,
workspace: str | None = None,
policy_evaluator: PolicyEvaluator | None = None,
default_decision: PolicyDecision = "reject",
) -> None:
self._session_id = session_id
self._opencode_session_id = opencode_session_id
self._opencode = opencode_client
self._server = server_client
self._bridge_dir = bridge_dir
self._workspace = workspace
self._policy_evaluator = policy_evaluator
self._default_decision = default_decision
self.state = OpenCodeForwarderState()
# messageID -> role ("user"/"assistant"), learned from
# ``message.updated``. Only assistant text parts become durable chat
# items (a user part is already echoed by the client).
self._msg_role: dict[str, str] = {}
# partID -> (assistant messageID, latest full-text snapshot) for
# in-flight assistant text parts, finalized (posted once) on
# ``step-finish`` / ``session.idle``. The messageID becomes the item's
# per-turn ``response_id``.
self._pending_text: dict[str, tuple[str | None, str]] = {}
async def seed_dedupe_from_history(self) -> None:
"""
Pre-seed dedupe state from existing OpenCode messages.
Prevents re-posting prior history on a resume/reconnect. Best
effort: a failure leaves the dedupe set empty (at worst a few
re-posts on resume).
"""
try:
messages = await self._opencode.list_messages(self._opencode_session_id)
except Exception: # noqa: BLE001 - seeding is best effort.
_logger.debug("OpenCode forwarder could not seed dedupe from history", exc_info=True)
return
for message in messages:
info = message.get("info") if isinstance(message, Mapping) else None
message_id = info.get("id") if isinstance(info, Mapping) else None
role = info.get("role") if isinstance(info, Mapping) else None
if isinstance(message_id, str) and isinstance(role, str):
self._msg_role[message_id] = role
parts = message.get("parts") if isinstance(message, Mapping) else None
if isinstance(parts, list):
for part in parts:
if not isinstance(part, Mapping):
continue
part_id = part.get("id")
if isinstance(part_id, str):
self.state.mark(self._key("part", part_id))
# Pre-mark the keys the live handlers check so a resume
# never re-posts already-finalized text / tool parts.
if part.get("type") == "text" and isinstance(part_id, str):
# Pre-mark both the assistant-finalize and user-message
# keys so a resume re-posts neither.
self.state.mark(self._key("text-final", part_id))
self.state.mark(self._key("user-text", part_id))
if part.get("type") == "tool":
call_id = part.get("callID")
if isinstance(call_id, str):
self.state.mark(self._key("tool-call", call_id))
self.state.mark(self._key("tool-out", call_id))
if isinstance(message_id, str):
self.state.mark(self._key("message", message_id))
async def run(self, *, max_reconnects: int | None = None) -> None:
"""
Run the SSE consume loop with reconnect/backoff.
:param max_reconnects: Reconnect cap (``None`` = unbounded); used
by tests to bound the loop.
"""
await self.seed_dedupe_from_history()
attempt = 0
backoff = 0.5
while True:
try:
await self._consume_once()
# Clean stream end (server closed): reconnect.
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - reconnect on any transient SSE failure.
_logger.warning(
"OpenCode forwarder SSE error for session=%s; reconnecting",
self._session_id,
exc_info=True,
)
attempt += 1
if max_reconnects is not None and attempt > max_reconnects:
return
await asyncio.sleep(min(backoff, 5.0))
backoff = min(backoff * 2, 5.0)
async def _consume_once(self) -> None:
"""Consume the SSE stream once, dispatching each event."""
async for event in self._opencode.events():
await self.handle_event(event)
async def handle_event(self, event: OpenCodeEvent) -> None:
"""
Translate one OpenCode event into Omnigent session events.
:param event: A decoded OpenCode SSE event.
"""
if not self._event_targets_session(event):
return
if event.id and self._bridge_dir is not None:
update_last_event_id(self._bridge_dir, event.id)
handler = _HANDLERS.get(event.type)
if handler is None:
_logger.debug(
"OpenCode forwarder ignoring event type=%s for session=%s",
event.type,
self._session_id,
)
return
await handler(self, event)
# --- filtering -------------------------------------------------------
def _event_targets_session(self, event: OpenCodeEvent) -> bool:
"""
Return whether *event* belongs to this forwarder's session.
Events without a session id (e.g. ``server.connected``) pass
through so readiness/global signals are not dropped.
:param event: A decoded OpenCode event.
:returns: ``True`` when the event should be handled.
"""
props = event.properties
session_id = props.get("sessionID") or props.get("session_id")
info = props.get("info")
if session_id is None and isinstance(info, Mapping):
session_id = info.get("id")
if session_id is None:
return True
return bool(session_id == self._opencode_session_id)
# --- dedupe / keys ---------------------------------------------------
def _key(self, *parts: str) -> str:
"""
Build a session-scoped dedupe key.
:param parts: Key segments, e.g. ``("text", "prt_1")``.
:returns: ``"opencode:<sessionID>:<part>:..."``.
"""
return "opencode:" + ":".join((self._opencode_session_id, *parts))
# --- posting helpers -------------------------------------------------
async def _post_event(self, event_type: str, data: dict[str, Any]) -> httpx.Response | None:
"""
POST one Omnigent session event with a single retry.
:param event_type: Omnigent event type, e.g.
``"external_session_status"``.
:param data: Event data payload.
:returns: The HTTP response, or ``None`` on transport failure.
"""
url = f"/v1/sessions/{quote(self._session_id, safe='')}/events"
payload = {"type": event_type, "data": data}
try:
return await self._server.post(url, json=payload)
except httpx.HTTPError:
_logger.warning(
"OpenCode forwarder failed to post %s for session=%s",
event_type,
self._session_id,
exc_info=True,
)
return None
async def _post_status(self, status: str) -> None:
"""Publish a coarse session status edge."""
await self._post_event(_EXTERNAL_STATUS, {"status": status})
def _response_id(self, message_id: str | None) -> str:
"""Map an opencode assistant messageID to a per-turn ``response_id``.
Items are grouped into a chat "response" by ``response_id``; a constant
value clusters every turn's assistant items into one block (breaking
ordering against the user messages). opencode's per-assistant-message id
is the natural per-turn key — fall back to the session id only when the
message id is unknown.
"""
return message_id or self._opencode_session_id
async def _post_assistant_text(self, text: str, *, message_id: str | None) -> None:
"""Persist a finalized assistant message under its per-turn response."""
await self._post_event(
_EXTERNAL_ITEM,
{
"item_type": "message",
"item_data": {
"role": "assistant",
"agent": _AGENT_NAME,
"content": [{"type": "output_text", "text": text}],
},
"response_id": self._response_id(message_id),
},
)
async def _post_user_text(self, text: str, *, message_id: str | None) -> None:
"""Persist a user message mirrored from the native transcript."""
await self._post_event(
_EXTERNAL_ITEM,
{
"item_type": "message",
"item_data": {
"role": "user",
"content": [{"type": "input_text", "text": text}],
},
"response_id": self._response_id(message_id),
},
)
async def _post_tool_call(
self, call_id: str, tool: str, arguments: dict[str, Any], *, message_id: str | None
) -> None:
"""Mirror a tool invocation as a function_call item."""
await self._post_event(
_EXTERNAL_ITEM,
{
"item_type": "function_call",
"item_data": {
"agent": _AGENT_NAME,
"name": tool,
"arguments": json.dumps(arguments, ensure_ascii=True),
"call_id": call_id,
},
"response_id": self._response_id(message_id),
},
)
async def _post_tool_output(
self, call_id: str, output: str, *, message_id: str | None
) -> None:
"""Mirror a tool result as a function_call_output item."""
await self._post_event(
_EXTERNAL_ITEM,
{
"item_type": "function_call_output",
"item_data": {"call_id": call_id, "output": output},
"response_id": self._response_id(message_id),
},
)
async def _begin_turn_if_needed(self) -> None:
"""Post a single ``running`` status at the start of a turn."""
if not self.state.turn_active:
self.state.turn_active = True
await self._post_status(_STATUS_RUNNING)
async def _end_turn(self) -> None:
"""Post ``idle`` and clear active state at turn end."""
self.state.turn_active = False
if self._bridge_dir is not None:
update_active_message_id(self._bridge_dir, None, status="idle")
await self._post_status(_STATUS_IDLE)
# --- per-event handlers ----------------------------------------------
async def _on_message_updated(self, event: OpenCodeEvent) -> None:
"""Handle ``message.updated`` — learn role; begin a turn for assistant.
opencode attaches text/tool parts to a message id; the role lives on
the message, not the part, so we cache it here to route parts.
"""
info = event.properties.get("info")
if not isinstance(info, Mapping):
return
message_id = info.get("id")
role = info.get("role")
if not isinstance(message_id, str) or not isinstance(role, str):
return
self._msg_role[message_id] = role
if role == "assistant":
if self._bridge_dir is not None:
update_active_message_id(self._bridge_dir, message_id, status="busy")
await self._begin_turn_if_needed()
async def _on_part_updated(self, event: OpenCodeEvent) -> None:
"""Handle ``message.part.updated`` — text / tool / step-boundary parts."""
part = event.properties.get("part")
if not isinstance(part, Mapping):
return
part_type = part.get("type")
if part_type == "text":
# A native-server forwarder is the SOLE source of the conversation
# transcript (omnigent persists no separate user item for these
# harnesses — mirrors codex-native). So the USER message must be
# posted here, BEFORE its assistant reply, or the chat shows the
# assistant turns with no/late user messages. Assistant text is
# accumulated and finalized on step/turn end.
if self._msg_role.get(str(part.get("messageID"))) == "user":
await self._post_user_text_part(part)
else:
self._accumulate_text_part(part)
elif part_type == "tool":
await self._handle_tool_part(part)
elif part_type == "step-start":
await self._begin_turn_if_needed()
elif part_type == "step-finish":
# A step's assistant text is complete once the step closes; flush
# it so text and tool items land in the chat in step order.
await self._flush_pending_text()
def _accumulate_text_part(self, part: Mapping[str, Any]) -> None:
"""Record the latest full-text snapshot for an assistant text part.
``message.part.updated`` carries the cumulative text each time, so we
keep the latest snapshot and finalize it once on step/turn end.
"""
part_id = part.get("id")
text = part.get("text")
message_id = part.get("messageID")
if not isinstance(part_id, str) or not isinstance(text, str):
return
# User-message text is echoed by the client; only assistant text
# becomes a durable chat item.
if self._msg_role.get(str(message_id)) != "assistant":
return
# Keep the owning messageID so the finalized item lands under the right
# per-turn response group (ordering vs the user messages).
self._pending_text[part_id] = (
message_id if isinstance(message_id, str) else None,
text,
)
async def _post_user_text_part(self, part: Mapping[str, Any]) -> None:
"""Post a user message immediately so it precedes its assistant reply.
Unlike assistant text (accumulated + flushed on step end), a user part
is complete on arrival and must land at its own earlier position, so we
post it eagerly, deduped by part id.
"""
part_id = part.get("id")
text = part.get("text")
message_id = part.get("messageID")
if not isinstance(part_id, str) or not isinstance(text, str) or not text:
return
if not self.state.mark(self._key("user-text", part_id)):
return
await self._post_user_text(
text, message_id=message_id if isinstance(message_id, str) else None
)
async def _flush_pending_text(self) -> None:
"""Finalize accumulated assistant text parts as durable chat items."""
for part_id, (message_id, text) in list(self._pending_text.items()):
self._pending_text.pop(part_id, None)
if not text:
continue
if not self.state.mark(self._key("text-final", part_id)):
continue
await self._post_assistant_text(text, message_id=message_id)
async def _handle_tool_part(self, part: Mapping[str, Any]) -> None:
"""Mirror an opencode tool part (call + result) as chat items.
opencode reports a tool as a single part whose ``state`` advances
``pending`` → ``running`` → ``completed`` / ``error`` with ``input``
then ``output``; we post the call once its input is populated and the
output once it completes (deduped by ``callID``).
"""
call_id = part.get("callID")
tool = part.get("tool")
state = part.get("state")
if not isinstance(call_id, str) or not isinstance(tool, str):
return
if not isinstance(state, Mapping):
return
message_id = part.get("messageID")
response_message_id = message_id if isinstance(message_id, str) else None
raw_input = state.get("input")
arguments = raw_input if isinstance(raw_input, dict) else {}
if arguments and self.state.mark(self._key("tool-call", call_id)):
await self._begin_turn_if_needed()
await self._post_tool_call(call_id, tool, arguments, message_id=response_message_id)
status = state.get("status")
if status == "completed" and self.state.mark(self._key("tool-out", call_id)):
await self._post_tool_output(
call_id, _tool_output_text(state), message_id=response_message_id
)
elif status == "error" and self.state.mark(self._key("tool-out", call_id)):
error = state.get("error")
await self._post_tool_output(
call_id, f"[error] {error}" if error else "[error]", message_id=response_message_id
)
async def _on_session_status(self, event: OpenCodeEvent) -> None:
"""Handle ``session.status`` — surface the running edge."""
status = event.properties.get("status")
status_type = status.get("type") if isinstance(status, Mapping) else status
if status_type == "busy":
await self._begin_turn_if_needed()
async def _on_session_idle(self, event: OpenCodeEvent) -> None:
"""Handle ``session.idle`` — finalize text and end the turn."""
del event
await self._flush_pending_text()
await self._end_turn()
async def _on_session_error(self, event: OpenCodeEvent) -> None:
"""Handle ``session.error`` — log, finalize, end turn."""
_logger.warning(
"OpenCode session error for session=%s: %s",
self._session_id,
event.properties.get("error"),
)
await self._flush_pending_text()
await self._end_turn()
async def _on_permission_asked(self, event: OpenCodeEvent) -> None:
"""Handle ``permission.v2.asked`` — evaluate policy and reply."""
request = parse_permission_request(event.properties)
if request is None:
return
if not self.state.mark(self._key("perm", request.request_id)):
return
decision = await self._resolve_permission(request_dict=request)
reply = decision_to_reply(decision)
if reply is None:
# Fail closed. ``decision_to_reply`` returns ``None`` only for
# ``ask``. The genuine human approval for an ``ask`` happens
# UPSTREAM inside the policy evaluator (the server parks an
# approval card on ``/policies/evaluate`` and returns a hard
# allow/deny). So an ``ask`` still reaching here means no human
# resolution was obtained — which must DENY, never auto-approve.
reply = "reject"
try:
await self._opencode.reply_permission(
request.request_id, reply_body(reply, message="omnigent-policy")
)
except Exception: # noqa: BLE001 - reply is best effort; log and move on.
_logger.warning(
"OpenCode permission reply failed for request=%s",
request.request_id,
exc_info=True,
)
async def _resolve_permission(self, *, request_dict: Any) -> PolicyDecision:
"""
Resolve a permission request to a normalized decision.
:param request_dict: The parsed permission request.
:returns: The normalized policy decision.
"""
if self._policy_evaluator is None:
# No policy gate wired → fail closed (default ``reject``). A
# forwarder with no evaluator must never auto-approve.
return self._default_decision
normalized = normalize_for_policy(
request_dict,
omnigent_session_id=self._session_id,
workspace=self._workspace,
)
try:
verdict = await self._policy_evaluator(normalized)
except Exception: # noqa: BLE001 - policy errors fail closed.
_logger.warning("OpenCode policy evaluation failed", exc_info=True)
return "ask"
if verdict is None:
return self._default_decision
return map_verdict_to_decision(verdict)
def _tool_output_text(state: Mapping[str, Any]) -> str:
"""
Extract a string tool output from a completed tool part's ``state``.
:param state: The opencode tool part ``state`` (``output`` /
``metadata.output``).
:returns: A string suitable for ``function_call_output``.
"""
output = state.get("output")
if isinstance(output, str) and output:
return output
metadata = state.get("metadata")
if isinstance(metadata, Mapping):
meta_out = metadata.get("output")
if isinstance(meta_out, str) and meta_out:
return meta_out
if output is not None and not isinstance(output, str):
return json.dumps(output, ensure_ascii=True)
return ""
# Event type → bound handler-name lookup. Built once; ``handle_event``
# resolves the method on the instance. Keys are OpenCode event ``type``
# discriminators (see openapi.json Event* schemas).
_HANDLERS: dict[str, Callable[[OpenCodeNativeForwarder, OpenCodeEvent], Awaitable[None]]] = {
# opencode 1.17.x is part-based: text/tool live on message PARTS, lifecycle
# on the message + session. (Verified against a real ``opencode serve``.)
"message.updated": OpenCodeNativeForwarder._on_message_updated,
"message.part.updated": OpenCodeNativeForwarder._on_part_updated,
# NB: ``message.part.delta`` (live token stream) is intentionally NOT
# forwarded. The web chat view reconciles live ``text_delta`` previews with
# the committed item via a finalize/retire protocol; emitting deltas without
# that handshake left an unreconciled streaming preview alongside the
# committed message (duplicated/garbled chat). We post only the durable
# assistant item (the codex-native finalized-message path) so the chat is
# correct; live token-streaming is a separate follow-up.
"session.status": OpenCodeNativeForwarder._on_session_status,
"session.idle": OpenCodeNativeForwarder._on_session_idle,
"session.error": OpenCodeNativeForwarder._on_session_error,
# Permission ask: 1.17.x emits ``permission.asked``; keep the ``v2`` spelling
# too so a point-release rename still routes through the policy gate.
"permission.asked": OpenCodeNativeForwarder._on_permission_asked,
"permission.v2.asked": OpenCodeNativeForwarder._on_permission_asked,
}
+204
View File
@@ -0,0 +1,204 @@
"""OpenCode permission normalization and policy/approval mapping.
OpenCode requests approval for sensitive actions via permission events
(``permission.v2.asked`` over SSE / ``GET /permission``) and accepts a
reply of ``once`` / ``always`` / ``reject`` (``POST
/permission/{requestID}/reply``). This module is the seam between
OpenCode's permission surface and Omnigent's policy/approval model:
1. Normalize a raw permission request into a flat policy-evaluation input.
2. Map an Omnigent policy verdict (allow / allow-always / deny / ask) onto
an OpenCode reply.
3. Fail closed: an unmapped verdict yields no auto-reply, so the caller
must obtain a human decision before answering.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Literal
OPENCODE_NATIVE_HARNESS = "opencode-native"
# OpenCode's accepted reply tokens.
OpenCodeReply = Literal["once", "always", "reject"]
# Omnigent-side normalized decisions used by the forwarder.
PolicyDecision = Literal["allow_once", "allow_always", "reject", "ask"]
@dataclass(frozen=True)
class OpenCodePermissionRequest:
"""
A normalized OpenCode permission request.
:param request_id: OpenCode permission request id, e.g. ``"per_..."``.
:param session_id: OpenCode session id the request belongs to.
:param action: The action/tool name needing approval, e.g.
``"bash"`` or ``"edit"``.
:param resources: Resource descriptors (command/path/url), as given.
:param metadata: Extra metadata supplied by OpenCode.
:param source: Where the request originated, when reported.
:param raw: The full raw payload for forward-compatibility.
"""
request_id: str
session_id: str | None
action: str | None
resources: list[Any] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
source: str | None = None
raw: dict[str, Any] = field(default_factory=dict)
def parse_permission_request(payload: Mapping[str, Any]) -> OpenCodePermissionRequest | None:
"""
Parse a raw permission payload into :class:`OpenCodePermissionRequest`.
Accepts both the ``permission.v2.asked`` event ``properties`` object
(keys ``id`` / ``sessionID`` / ``action`` / ``resources`` / ``metadata``
/ ``source``) and entries from ``GET /permission`` (which may use
``requestID`` / ``sessionID``).
:param payload: Raw permission object.
:returns: Parsed request, or ``None`` when no request id is present.
"""
request_id = payload.get("id") or payload.get("requestID") or payload.get("request_id")
if not isinstance(request_id, str) or not request_id:
return None
session_id = payload.get("sessionID") or payload.get("session_id")
action = payload.get("action") or payload.get("type")
resources = payload.get("resources")
metadata = payload.get("metadata")
source = payload.get("source")
return OpenCodePermissionRequest(
request_id=request_id,
session_id=session_id if isinstance(session_id, str) else None,
action=action if isinstance(action, str) else None,
resources=list(resources) if isinstance(resources, list) else [],
metadata=dict(metadata) if isinstance(metadata, Mapping) else {},
source=source if isinstance(source, str) else None,
raw=dict(payload),
)
def normalize_for_policy(
request: OpenCodePermissionRequest,
*,
omnigent_session_id: str,
workspace: str | None,
) -> dict[str, Any]:
"""
Build an Omnigent policy-evaluation input from a permission request.
The shape mirrors what the codex-native policy hook posts to
``/v1/sessions/{id}/policies/evaluate`` — an action name plus the
concrete command / path / url being acted on, so configured policies
can reason about the operation.
:param request: The normalized OpenCode permission request.
:param omnigent_session_id: Owning Omnigent conversation id.
:param workspace: Session working directory, when known.
:returns: A flat dict suitable for policy evaluation.
"""
command, path, url = _extract_resource_fields(request)
return {
"harness": OPENCODE_NATIVE_HARNESS,
"action": request.action,
"command": command,
"path": path,
"url": url,
"working_directory": workspace,
"opencode_session_id": request.session_id,
"omnigent_session_id": omnigent_session_id,
"request_id": request.request_id,
"metadata": request.metadata,
}
def _extract_resource_fields(
request: OpenCodePermissionRequest,
) -> tuple[str | None, str | None, str | None]:
"""
Pull command / path / url out of a permission request's resources.
:param request: The normalized permission request.
:returns: ``(command, path, url)``; each ``None`` when not present.
"""
command: str | None = None
path: str | None = None
url: str | None = None
candidates: list[Mapping[str, Any]] = []
if isinstance(request.metadata, Mapping):
candidates.append(request.metadata)
for resource in request.resources:
if isinstance(resource, Mapping):
candidates.append(resource)
for source in candidates:
if command is None:
value = source.get("command")
command = value if isinstance(value, str) and value else command
if path is None:
value = source.get("path") or source.get("filePath") or source.get("file")
path = value if isinstance(value, str) and value else path
if url is None:
value = source.get("url")
url = value if isinstance(value, str) and value else url
return command, path, url
def map_verdict_to_decision(verdict: Mapping[str, Any] | None) -> PolicyDecision:
"""
Map an Omnigent policy verdict onto a normalized decision.
Recognizes both ``{"decision": "..."}`` and ``{"action": "..."}``
verdict shapes. Anything unrecognized maps to ``"ask"`` (fail closed:
the caller must obtain a human decision before replying).
:param verdict: The policy verdict object, or ``None``.
:returns: One of ``allow_once`` / ``allow_always`` / ``reject`` / ``ask``.
"""
if not isinstance(verdict, Mapping):
return "ask"
raw = verdict.get("decision") or verdict.get("action") or verdict.get("verdict")
token = str(raw).strip().lower() if raw is not None else ""
if token in {"allow_always", "always", "allow-always"}:
return "allow_always"
if token in {"allow", "allow_once", "approve", "allowed", "accept"}:
return "allow_once"
if token in {"deny", "reject", "block", "blocked", "denied"}:
return "reject"
return "ask"
def decision_to_reply(decision: PolicyDecision) -> OpenCodeReply | None:
"""
Map a normalized decision onto an OpenCode reply token.
:param decision: One of ``allow_once`` / ``allow_always`` / ``reject``
/ ``ask``.
:returns: ``"once"`` / ``"always"`` / ``"reject"``, or ``None`` for
``ask`` (no automatic reply — needs a human).
"""
if decision == "allow_once":
return "once"
if decision == "allow_always":
return "always"
if decision == "reject":
return "reject"
return None
def reply_body(reply: OpenCodeReply, *, message: str | None = None) -> dict[str, Any]:
"""
Build the JSON body for ``POST /permission/{requestID}/reply``.
:param reply: ``once`` / ``always`` / ``reject``.
:param message: Optional human-readable note attached to the reply.
:returns: The reply request body.
"""
body: dict[str, Any] = {"reply": reply}
if message is not None:
body["message"] = message
return body
+194
View File
@@ -0,0 +1,194 @@
"""Synthesize OpenCode provider config for the native-server harness.
Unlike codex/claude/pi — which consume ``HARNESS_*_GATEWAY_*`` env vars that
their CLIs translate into provider config — OpenCode reads its provider/auth
from its own config file under the per-session ``XDG_CONFIG_HOME``. So routing
opencode-native through the Databricks AI gateway (or any OpenAI-compatible
endpoint) means writing an ``opencode.json`` into the runner-owned
``opencode serve``'s config dir at spawn, declaring a custom
``@ai-sdk/openai-compatible`` provider pointed at ``{host}/serving-endpoints``.
The model is then referenced as ``<provider_id>/<endpoint>`` per prompt.
Security: the file carries a bearer token, so it is written ``0600`` into the
per-session XDG dir (never the user's global ``~/.config/opencode``). The token
is resolved at spawn; a resumed session re-spawns the server and re-resolves, so
short-lived gateway tokens refresh on resume (documented limitation: a token
that expires mid-session is not refreshed in place).
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
_logger = logging.getLogger(__name__)
# Provider id used in the synthesized opencode.json for the Databricks gateway.
# The per-prompt model is pinned as ``{DATABRICKS_GATEWAY_PROVIDER_ID}/<endpoint>``.
DATABRICKS_GATEWAY_PROVIDER_ID = "databricks-gateway"
DATABRICKS_GATEWAY_PROVIDER_NAME = "Databricks AI Gateway"
# Endpoint that exposes the workspace's OpenAI-compatible chat completions.
_SERVING_ENDPOINTS_PATH = "serving-endpoints"
# Fallback chat model when neither the spec nor config names one.
DEFAULT_DATABRICKS_GATEWAY_MODEL = "databricks-claude-sonnet-4-6"
@dataclass(frozen=True)
class OpenCodeGatewayResolution:
"""A resolved OpenAI-compatible gateway for the opencode-native harness.
:param base_url: OpenAI-compatible base URL, e.g.
``"https://ws.cloud.databricks.com/serving-endpoints"``.
:param api_key: Bearer token / API key for the gateway.
:param model_id: The endpoint/model id, e.g. ``"databricks-claude-sonnet-4-6"``.
:param provider_id: opencode provider id, e.g. ``"databricks-gateway"``.
:param provider_name: Human label for the opencode provider block.
"""
base_url: str
api_key: str
model_id: str
provider_id: str = DATABRICKS_GATEWAY_PROVIDER_ID
provider_name: str = DATABRICKS_GATEWAY_PROVIDER_NAME
@property
def qualified_model(self) -> str:
""":returns: The per-prompt ``provider/model`` id opencode expects."""
return f"{self.provider_id}/{self.model_id}"
def build_opencode_model_default_config(model: str) -> dict[str, object]:
"""
Build a minimal ``opencode.json`` that only pins the default model.
Used when the user's own provider auth (``opencode auth login`` /
provider env keys) already supplies credentials, but a default model has
been chosen — via ``omni opencode --model`` or the ``omni setup`` OpenCode
default — so the per-session TUI (and the first turn) launch on that model
instead of OpenCode's built-in default (``opencode/big-pickle``). No
provider block: OpenCode resolves the provider from the model id's prefix
against its own ``auth.json``.
:param model: A ``provider/model`` id, e.g. ``"anthropic/claude-sonnet-4-5"``.
:returns: A config dict ready to serialize to ``opencode.json``.
"""
return {"$schema": "https://opencode.ai/config.json", "model": model}
def build_opencode_provider_config(resolution: OpenCodeGatewayResolution) -> dict[str, object]:
"""
Build the ``opencode.json`` declaring a custom OpenAI-compatible provider.
:param resolution: The resolved gateway (base URL + key + model).
:returns: A config dict ready to serialize to ``opencode.json``.
"""
return {
"$schema": "https://opencode.ai/config.json",
"provider": {
resolution.provider_id: {
"npm": "@ai-sdk/openai-compatible",
"name": resolution.provider_name,
"options": {
"baseURL": resolution.base_url,
"apiKey": resolution.api_key,
},
"models": {resolution.model_id: {"name": resolution.model_id}},
}
},
}
def write_opencode_provider_config(xdg_config_home: Path, config: Mapping[str, object]) -> Path:
"""
Atomically write ``<xdg_config_home>/opencode/opencode.json`` (``0600``).
:param xdg_config_home: The per-session ``XDG_CONFIG_HOME`` the server uses.
:param config: The provider config dict (see
:func:`build_opencode_provider_config`).
:returns: The path written.
"""
cfg_dir = xdg_config_home / "opencode"
cfg_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
path = cfg_dir / "opencode.json"
payload = json.dumps(config, indent=2, sort_keys=True) + "\n"
fd, tmp_name = tempfile.mkstemp(prefix="opencode.json.", dir=str(cfg_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload)
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return path
def resolve_databricks_gateway(
profile: str | None,
*,
model_id: str | None = None,
) -> OpenCodeGatewayResolution | None:
"""
Resolve a Databricks AI gateway for opencode from a ``~/.databrickscfg`` profile.
Uses ``databricks-sdk`` (the ``databricks`` extra) to obtain the workspace
host + a bearer token for *profile*, then targets the workspace's
OpenAI-compatible ``/serving-endpoints``. Best-effort: returns ``None`` when
the SDK is absent, the profile is unknown, or auth fails — the caller then
leaves opencode on its ambient provider config.
:param profile: A ``~/.databrickscfg`` profile name, e.g. ``"oss"``;
``None`` short-circuits.
:param model_id: Endpoint/model id to pin; defaults to
:data:`DEFAULT_DATABRICKS_GATEWAY_MODEL` (a ``databricks-*`` chat
endpoint the gateway routes).
:returns: A resolution, or ``None`` when the gateway can't be resolved.
"""
if not profile:
return None
try:
from databricks.sdk.core import Config
config = Config(profile=profile)
host = (config.host or "").rstrip("/")
if not host:
return None
headers = config.authenticate() or {}
authz = headers.get("Authorization", "")
token = authz.split(" ", 1)[1] if authz.lower().startswith("bearer ") else ""
if not token:
return None
except Exception as exc: # noqa: BLE001 - SDK absent / auth failure / bad profile.
_logger.info("opencode Databricks gateway resolve failed for %r: %r", profile, exc)
return None
resolved_model = _gateway_endpoint_for_model(model_id) or DEFAULT_DATABRICKS_GATEWAY_MODEL
return OpenCodeGatewayResolution(
base_url=f"{host}/{_SERVING_ENDPOINTS_PATH}",
api_key=token,
model_id=resolved_model,
)
def _gateway_endpoint_for_model(model_id: str | None) -> str | None:
"""
Normalize a spec model id to a Databricks serving-endpoint name.
Accepts ``"databricks-claude-..."`` and ``"databricks/claude-..."`` spellings
and strips a leading ``databricks/`` provider prefix; anything that does not
look like a ``databricks-*`` endpoint is ignored (the gateway only routes
its own endpoint names), so the default applies.
:param model_id: The spec/override model id, or ``None``.
:returns: A bare endpoint name, or ``None``.
"""
if not model_id:
return None
candidate = model_id.split("/", 1)[1] if model_id.startswith("databricks/") else model_id
return candidate if candidate.startswith("databricks-") else None
+144
View File
@@ -0,0 +1,144 @@
"""Persistent client-side state for ``omnigent opencode`` sessions.
The native OpenCode wrapper records the cwd used to create a session so a
later ``omnigent opencode --resume <conv_id>`` can launch OpenCode from
the same workspace. This state is intentionally client-side: local
filesystem paths belong to the user's machine and should not be stored on
the shared Omnigent server. Mirrors :mod:`omnigent.codex_native_state`.
Layout (per conversation):
~/.omnigent/opencode-native/<sha256(conv_id)[:32]>/launch.json
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
_STATE_ROOT_ENV_VAR = "OMNIGENT_OPENCODE_NATIVE_STATE_DIR"
_logger = logging.getLogger(__name__)
_LAUNCH_FILE = "launch.json"
_ID_HASH_CHARS = 32
@dataclass(frozen=True)
class OpenCodeNativeLaunchState:
"""
Persisted state about how an opencode-native session was launched.
:param working_directory: Absolute filesystem path the wrapper was
invoked from when the session was created, e.g. ``"/home/me/repo"``.
"""
working_directory: str
def _opencode_native_state_root() -> Path:
"""
Return the root directory for persistent opencode-native state.
Honors :data:`_STATE_ROOT_ENV_VAR` for tests and advanced local setups.
Production defaults to ``~/.omnigent/opencode-native``.
:returns: Absolute path to the state root.
"""
override = os.environ.get(_STATE_ROOT_ENV_VAR)
if override:
return Path(override)
return Path.home() / ".omnigent" / "opencode-native"
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
"""
Return the per-conversation persistent state directory.
Hashing the conversation id prevents path traversal if a server ever
returned an attacker-controlled id such as ``"../etc"``.
:param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:returns: Absolute directory path; not guaranteed to exist.
"""
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
return _opencode_native_state_root() / digest
def write_launch_state(conversation_id: str, working_directory: str) -> None:
"""
Persist a session's launch state at creation time.
Same-value writes are idempotent. Different-value writes are refused
and logged because changing the recorded cwd for an existing session
would make future resume checks incorrect.
:param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param working_directory: Absolute launch cwd, e.g. ``"/home/me/repo"``.
:returns: None.
:raises ValueError: If *working_directory* is empty or relative.
"""
if not working_directory:
raise ValueError("working_directory must be a non-empty absolute path")
if not Path(working_directory).is_absolute():
raise ValueError("working_directory must be a non-empty absolute path")
state_dir = _state_dir_for_conversation_id(conversation_id)
existing = read_launch_state(conversation_id)
if existing is not None and existing.working_directory != working_directory:
_logger.warning(
"opencode-native launch state mismatch for %s: existing=%r new=%r; "
"keeping existing value",
conversation_id,
existing.working_directory,
working_directory,
)
return
state_dir.mkdir(parents=True, exist_ok=True)
target = state_dir / _LAUNCH_FILE
payload = {
"conversation_id": conversation_id,
"working_directory": working_directory,
}
tmp = target.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
os.replace(tmp, target)
def read_launch_state(conversation_id: str) -> OpenCodeNativeLaunchState | None:
"""
Load a session's launch state, or ``None`` if not recorded.
Missing, unreadable, or malformed state is treated as absent so legacy
and cross-machine resumes continue to behave as before.
:param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:returns: Parsed state, or ``None`` if missing / malformed.
"""
target = _state_dir_for_conversation_id(conversation_id) / _LAUNCH_FILE
try:
raw = target.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError:
_logger.warning(
"opencode-native launch state read failed for %s",
conversation_id,
exc_info=True,
)
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
_logger.warning(
"opencode-native launch state JSON is malformed for %s; ignoring",
conversation_id,
)
return None
if not isinstance(payload, dict):
return None
working_directory = payload.get("working_directory")
if not isinstance(working_directory, str) or not working_directory:
return None
return OpenCodeNativeLaunchState(working_directory=working_directory)
+580 -1
View File
@@ -20,7 +20,7 @@ import tempfile
import time
import urllib.parse
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -59,6 +59,7 @@ from omnigent.runner.resource_registry import (
CURSOR_NATIVE_TERMINAL_ROLE,
GOOSE_NATIVE_TERMINAL_ROLE,
OMNIGENT_REPL_TERMINAL_ROLE,
OPENCODE_NATIVE_TERMINAL_ROLE,
PI_NATIVE_TERMINAL_ROLE,
SessionResourceRegistry,
TerminalExitEvent,
@@ -351,6 +352,11 @@ _COST_POPUP_REPOP_TASKS: set[asyncio.Task[Any]] = set()
# runners, kept referenced so they aren't garbage-collected mid-run.
_AUTO_CODEX_APP_SERVERS: dict[str, Any] = {}
# Background OpenCode ``opencode serve`` instances for host-spawned
# opencode-native runners, kept referenced so they aren't garbage-collected
# mid-run (mirrors ``_AUTO_CODEX_APP_SERVERS``).
_AUTO_OPENCODE_SERVERS: dict[str, Any] = {}
# Bound repeated terminal GET miss logs from tight client poll loops.
_TERMINAL_LOOKUP_MISS_LOG_INTERVAL_S = 10.0
_terminal_lookup_miss_log_state: dict[tuple[str, str, str], float] = {}
@@ -721,6 +727,475 @@ async def _codex_native_launch_config(
)
@dataclasses.dataclass(frozen=True)
class _OpenCodeNativeLaunchConfig:
"""
Persisted launch config for runner-owned OpenCode terminals.
:param workspace: Workspace cwd for ``opencode serve`` and the TUI.
:param policy_server_url: Omnigent server URL for the forwarder.
:param terminal_launch_args: User pass-through OpenCode CLI args.
:param model_override: Persisted model override, or ``None``.
:param external_session_id: Existing OpenCode session id to resume.
"""
workspace: Path
policy_server_url: str
terminal_launch_args: list[str] | None
model_override: str | None
external_session_id: str | None
async def _opencode_native_launch_config(
*,
session_id: str,
server_client: httpx.AsyncClient | None,
) -> _OpenCodeNativeLaunchConfig:
"""
Fetch and validate persisted OpenCode launch config for a session.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
:param server_client: Runner Omnigent server client.
:returns: Parsed launch config.
:raises RuntimeError: If the snapshot or required runner env is missing.
"""
if server_client is None:
raise RuntimeError("server_client is required for runner-owned OpenCode terminals.")
try:
resp = await server_client.get(
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}",
timeout=10.0,
)
except httpx.HTTPError as exc:
raise RuntimeError(f"Could not fetch OpenCode launch config for {session_id!r}.") from exc
if resp.status_code != 200:
raise RuntimeError(
f"Could not fetch OpenCode launch config for {session_id!r}: "
f"GET /v1/sessions returned {resp.status_code}."
)
try:
snapshot = resp.json()
except ValueError as exc:
raise RuntimeError(
f"Could not fetch OpenCode launch config for {session_id!r}: invalid JSON."
) from exc
if not isinstance(snapshot, dict):
raise RuntimeError(
f"Could not fetch OpenCode launch config for {session_id!r}: "
"snapshot was not a JSON object."
)
terminal_launch_args = snapshot.get("terminal_launch_args")
if terminal_launch_args is not None and not (
isinstance(terminal_launch_args, list)
and all(isinstance(arg, str) for arg in terminal_launch_args)
):
raise RuntimeError(f"Invalid terminal_launch_args for OpenCode session {session_id!r}.")
model_override = snapshot.get("model_override")
if model_override is not None:
if not isinstance(model_override, str) or not model_override:
raise RuntimeError(f"Invalid model_override for OpenCode session {session_id!r}.")
try:
validate_model_override(model_override)
except ValueError as exc:
raise RuntimeError(
f"Invalid model_override for OpenCode session {session_id!r}: {exc}"
) from exc
external_session_id = snapshot.get("external_session_id")
if external_session_id is not None and (
not isinstance(external_session_id, str) or not external_session_id
):
raise RuntimeError(f"Invalid external_session_id for OpenCode session {session_id!r}.")
session_workspace = snapshot.get("workspace")
if session_workspace is not None and (
not isinstance(session_workspace, str) or not session_workspace
):
raise RuntimeError(f"Invalid workspace for OpenCode session {session_id!r}.")
return _OpenCodeNativeLaunchConfig(
workspace=_codex_session_workspace(session_workspace),
policy_server_url=_required_runner_env("RUNNER_SERVER_URL"),
terminal_launch_args=terminal_launch_args,
model_override=model_override,
external_session_id=external_session_id,
)
async def _auto_create_opencode_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
publish_event: Callable[[str, dict[str, Any]], None],
*,
agent_spec: Any | None = None,
server_client: httpx.AsyncClient | None = None,
) -> SessionResourceView:
"""
Auto-create an OpenCode terminal for an opencode-native session.
Mirrors :func:`_auto_create_codex_terminal`, substituting ``opencode
serve`` / ``opencode attach`` for Codex's app-server/remote transport:
boots a per-session ``opencode serve`` process, resumes-or-creates the
OpenCode session, persists bridge state + ``external_session_id``,
starts the SSE forwarder, then registers the ``opencode attach`` TUI as
a streamable terminal resource attached to that server.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
:param resource_registry: Registry used to launch the terminal.
:param publish_event: Per-session SSE emitter for the new terminal.
:param agent_spec: Optional resolved agent spec (os_env + model).
:param server_client: Runner Omnigent server HTTP client.
:returns: The created terminal resource view.
"""
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
from omnigent.opencode_native_app_server import (
OpenCodeNativeServer,
build_opencode_attach_args,
opencode_terminal_env,
)
from omnigent.opencode_native_bridge import (
OpenCodeNativeBridgeState,
clear_bridge_state,
prepare_bridge_dir,
seed_opencode_auth,
write_bridge_state,
)
from omnigent.opencode_native_forwarder import OpenCodeNativeForwarder
launch_config = await _opencode_native_launch_config(
session_id=session_id,
server_client=server_client,
)
workspace = str(launch_config.workspace)
bridge_dir = prepare_bridge_dir(session_id)
# Cancel any surviving forwarder first so its teardown closes the OLD
# server, then clear stale bridge state so web injection waits for the
# new launch's URL/session instead of a dead one.
await _cancel_auto_forwarder_task(session_id)
leftover = _AUTO_OPENCODE_SERVERS.pop(session_id, None)
if leftover is not None:
with contextlib.suppress(Exception):
await leftover.close()
clear_bridge_state(bridge_dir)
model_override = launch_config.model_override or _opencode_native_model_from_spec(agent_spec)
# Route opencode through the Databricks AI gateway when the spec names a
# profile. Unlike codex/claude/pi (which consume HARNESS_*_GATEWAY_* env the
# CLI translates), opencode reads provider/auth from its own config file, so
# synthesize an opencode.json into the per-session XDG config dir BEFORE the
# server boots. Best-effort: if the gateway can't be resolved (no profile,
# databricks-sdk absent, auth failure), opencode falls back to whatever
# provider config the ambient env/global config already gives it.
from omnigent.opencode_native_bridge import xdg_config_home_for_bridge_dir
from omnigent.opencode_native_provider import (
build_opencode_model_default_config,
build_opencode_provider_config,
resolve_databricks_gateway,
write_opencode_provider_config,
)
gateway = resolve_databricks_gateway(
_opencode_native_profile_from_spec(agent_spec), model_id=model_override
)
if gateway is not None:
# Pin the per-prompt model to the synthesized provider/endpoint id, and
# write it as opencode's default model too so the TUI launches on it.
model_override = gateway.qualified_model
config = build_opencode_provider_config(gateway)
config["model"] = model_override
write_opencode_provider_config(xdg_config_home_for_bridge_dir(bridge_dir), config)
elif model_override:
# No custom provider, but a model is pinned (``omni opencode --model`` or
# the ``omni setup`` OpenCode default): write opencode's default model so
# the native TUI and the first turn use it instead of ``opencode/big-pickle``.
# OpenCode resolves the provider from the model-id prefix against its own
# auth.json, so no provider block is needed.
write_opencode_provider_config(
xdg_config_home_for_bridge_dir(bridge_dir),
build_opencode_model_default_config(model_override),
)
# The server runs with a per-session XDG_DATA_HOME, so copy the user's
# `opencode auth login` credentials in — otherwise it can't authenticate
# their providers and falls back to the no-auth default model. No-op on a
# remote runner (no local auth.json) / Databricks-gateway path.
seed_opencode_auth(bridge_dir)
server = OpenCodeNativeServer(bridge_dir=bridge_dir, workspace=launch_config.workspace)
await server.start()
_AUTO_OPENCODE_SERVERS[session_id] = server
try:
client = server.client()
try:
opencode_session_id: str | None = None
if launch_config.external_session_id is not None:
existing = await client.get_session(launch_config.external_session_id)
if existing is not None:
opencode_session_id = existing.id
if opencode_session_id is None:
created = await client.create_session({"title": f"omnigent:{session_id}"})
opencode_session_id = created.id
# Persist the OpenCode session id so a later relaunch resumes
# it (best effort, like codex-native).
if server_client is not None:
with contextlib.suppress(httpx.HTTPError):
await server_client.patch(
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}",
json={"external_session_id": opencode_session_id},
timeout=10.0,
)
finally:
await client.aclose()
write_bridge_state(
bridge_dir,
OpenCodeNativeBridgeState(
session_id=session_id,
server_base_url=server.base_url,
opencode_session_id=opencode_session_id,
auth_secret=server.auth_secret,
xdg_data_home=str(server.xdg_data_home),
xdg_config_home=str(server.xdg_config_home),
model_override=model_override,
workspace=workspace,
),
)
except Exception:
await server.close()
_AUTO_OPENCODE_SERVERS.pop(session_id, None)
raise
# Start the SSE forwarder in the background so session creation never
# blocks on it. The forwarder owns its OpenCode client for the stream
# lifetime; ``server_client`` is the runner's Omnigent client. The
# supervisor closes the ``opencode serve`` subprocess when forwarding
# ends (cancelled on session teardown), mirroring the codex forwarder's
# ``finally`` — else one server orphans per session.
if server_client is not None:
forwarder = OpenCodeNativeForwarder(
session_id=session_id,
opencode_session_id=opencode_session_id,
opencode_client=server.client(),
server_client=server_client,
bridge_dir=bridge_dir,
workspace=workspace,
# Route OpenCode permission requests through the SAME server-side
# policy/approval gate codex-native uses. Without this the
# forwarder would fall back to its fail-closed ``reject`` default
# and deny every tool; with it, policy decides and an ``ask``
# parks a human approval card server-side.
policy_evaluator=_build_opencode_policy_evaluator(
server_client=server_client,
conversation_id=session_id,
),
)
forwarder_task = asyncio.create_task(
_supervise_opencode_forwarder(session_id, server, forwarder),
name=f"opencode-forwarder-{session_id}",
)
_register_auto_forwarder_task(session_id, forwarder_task)
agent_os_env = _agent_os_env_from_spec(agent_spec)
try:
terminal_view = await resource_registry.launch_auxiliary_terminal(
session_id=session_id,
terminal_name="opencode",
session_key="main",
resource_role=OPENCODE_NATIVE_TERMINAL_ROLE,
parent_os_env=agent_os_env,
spec=TerminalEnvSpec(
os_env=OSEnvSpec(
type="caller_process",
cwd=workspace,
sandbox=(agent_os_env.sandbox if agent_os_env is not None else None),
),
command=server.opencode_path,
args=build_opencode_attach_args(
server_url=server.base_url,
workspace=workspace,
session_id=opencode_session_id,
opencode_args=tuple(launch_config.terminal_launch_args or ()),
),
env=opencode_terminal_env(server),
scrollback=100_000,
tmux_allow_passthrough=True,
tmux_start_on_attach=False,
),
)
publish_event(
session_id,
{
"type": "session.resource.created",
"resource": session_resource_view_to_dict(terminal_view),
},
)
except Exception:
await _cancel_auto_forwarder_task(session_id)
await server.close()
_AUTO_OPENCODE_SERVERS.pop(session_id, None)
raise
_logger.info("Auto-created opencode terminal + forwarder for session %s", session_id)
return terminal_view
async def _supervise_opencode_forwarder(
session_id: str,
server: Any,
forwarder: Any,
) -> None:
"""
Run the OpenCode SSE forwarder, closing the server when it ends.
Mirrors the codex forwarder task's ``finally``: when forwarding stops
(the SSE connection dropped or the task was cancelled on session
teardown) the per-session ``opencode serve`` subprocess is ours to
stop, else it orphans one process per session.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
:param server: The :class:`OpenCodeNativeServer` to close on exit.
:param forwarder: The :class:`OpenCodeNativeForwarder` to run.
:returns: None.
"""
try:
await forwarder.run()
finally:
leftover = _AUTO_OPENCODE_SERVERS.pop(session_id, None)
if leftover is not None:
with contextlib.suppress(Exception):
await leftover.close()
elif server is not None:
with contextlib.suppress(Exception):
await server.close()
# Permission decisions can park a human approval card server-side
# (``POLICY_ACTION_ASK``), so the evaluate POST may block until a human
# resolves it. Match the codex-native policy hook's day-long budget; the
# server caps the real wait via the deciding policy's ``ask_timeout``.
_OPENCODE_POLICY_EVALUATE_TIMEOUT_S = 86400.0
# Map the server's proto verdict onto the forwarder's verdict vocabulary
# (``map_verdict_to_decision`` reads ``decision``). Anything unknown is
# treated as ``ask`` → the forwarder fails it closed to ``reject``.
_OPENCODE_POLICY_ACTION_TO_DECISION = {
"POLICY_ACTION_ALLOW": "allow",
"POLICY_ACTION_DENY": "deny",
"POLICY_ACTION_ASK": "ask",
}
def _build_opencode_policy_evaluator(
*,
server_client: httpx.AsyncClient,
conversation_id: str,
) -> Callable[[Mapping[str, Any]], Awaitable[Mapping[str, Any] | None]]:
"""
Build the policy evaluator the OpenCode permission forwarder consults.
Mirrors codex-native's policy hook exactly: every OpenCode
``permission.v2.asked`` request is POSTed to this session's
``/v1/sessions/{id}/policies/evaluate`` endpoint as a
``PHASE_TOOL_CALL`` event. The server evaluates configured policies and
for an ``ASK`` verdict parks a human approval card and blocks until
it is resolved, returning a hard ``ALLOW``/``DENY``. The forwarder turns
that into an OpenCode ``once``/``always``/``reject`` reply.
Fails CLOSED: an unreachable server, a non-200, a malformed body, or an
unresolved ``ASK`` all yield a ``deny``/``ask`` verdict the forwarder
rejects never a silent approve. Only an explicit ``ALLOW`` permits the
operation.
:param server_client: Runner's Omnigent server HTTP client.
:param conversation_id: Owning Omnigent session id, e.g. ``"conv_abc"``.
:returns: An async evaluator returning a verdict mapping, or a deny
verdict on failure.
"""
from omnigent.opencode_native_permissions import OPENCODE_NATIVE_HARNESS
session_component = urllib.parse.quote(conversation_id, safe="")
url = f"/v1/sessions/{session_component}/policies/evaluate"
async def _evaluate(normalized: Mapping[str, Any]) -> Mapping[str, Any] | None:
arguments: dict[str, Any] = {
key: normalized[key]
for key in ("command", "path", "url")
if normalized.get(key) is not None
}
metadata = normalized.get("metadata")
if isinstance(metadata, Mapping) and metadata:
arguments.setdefault("metadata", dict(metadata))
body = {
"event": {
"type": "PHASE_TOOL_CALL",
"target": "",
"data": {
"name": normalized.get("action") or "permission",
"arguments": arguments,
},
"context": {"harness": OPENCODE_NATIVE_HARNESS},
},
}
try:
resp = await server_client.post(
url, json=body, timeout=_OPENCODE_POLICY_EVALUATE_TIMEOUT_S
)
except httpx.HTTPError:
_logger.warning(
"OpenCode policy evaluate POST failed for %s; failing closed",
conversation_id,
exc_info=True,
)
return {"decision": "deny"}
if resp.status_code != 200 or not resp.content:
_logger.warning(
"OpenCode policy evaluate returned %s for %s; failing closed",
resp.status_code,
conversation_id,
)
return {"decision": "deny"}
try:
result = resp.json()
except ValueError:
_logger.warning("OpenCode policy evaluate returned non-JSON; failing closed")
return {"decision": "deny"}
action = result.get("result") if isinstance(result, Mapping) else None
return {"decision": _OPENCODE_POLICY_ACTION_TO_DECISION.get(str(action), "ask")}
return _evaluate
def _opencode_native_model_from_spec(agent_spec: Any | None) -> str | None:
"""
Resolve the OpenCode default model from a resolved agent spec.
:param agent_spec: Optional resolved agent spec.
:returns: The spec's executor model, or ``None``.
"""
if agent_spec is None:
return None
try:
from omnigent.runtime.workflow import _resolve_spec_model
return _resolve_spec_model(getattr(agent_spec, "spec", agent_spec))
except Exception: # noqa: BLE001 - model resolution is best effort.
return None
def _opencode_native_profile_from_spec(agent_spec: Any | None) -> str | None:
"""
Resolve the Databricks profile from a resolved agent spec, if any.
:param agent_spec: Optional resolved agent spec.
:returns: The spec's ``executor.config.profile``, or ``None``.
"""
if agent_spec is None:
return None
try:
spec = getattr(agent_spec, "spec", agent_spec)
profile = spec.executor.config.get("profile")
return str(profile) if profile else None
except Exception: # noqa: BLE001 - profile resolution is best effort.
return None
def _pi_args_have_session_control(args: list[str]) -> bool:
"""
Return whether user Pi args already specify session behavior.
@@ -4816,6 +5291,7 @@ def create_runner_app(
_session_comment_relays: dict[str, Any] = {}
_codex_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_pi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_opencode_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_goose_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
# Per-session lock guarding the claude-native terminal auto-create in
@@ -5676,6 +6152,18 @@ def create_runner_app(
from omnigent.pi_native_bridge import build_pi_native_spawn_env
spawn_env = build_pi_native_spawn_env(session_id)
if harness_name == "opencode-native" and spawn_env is None:
from omnigent.opencode_native_bridge import (
OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY,
build_opencode_native_spawn_env,
)
labels = await _session_labels_for_runner_spawn(
server_client=server_client,
session_id=session_id,
)
bridge_id = labels.get(OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY)
spawn_env = build_opencode_native_spawn_env(session_id, bridge_id=bridge_id)
if harness_name == "cursor-native" and spawn_env is None:
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
@@ -6041,6 +6529,49 @@ def create_runner_app(
finally:
_publish_terminal_pending(_publish_event, session_id, False)
if harness_name == "opencode-native":
# Host/web-UI session-creation path: boot the runner-owned
# ``opencode serve`` + SSE forwarder + ``opencode attach`` terminal
# so the web UI has a terminal+chat view to embed — the native-server
# sibling of the codex-native branch above. (The on-demand
# ``ensure_native_terminal`` message path also creates it; the
# per-session lock makes the two idempotent.)
_opencode_ensure_lock = _opencode_terminal_ensure_locks.setdefault(
session_id, asyncio.Lock()
)
async with _opencode_ensure_lock:
_tr = resource_registry.terminal_registry
_has_opencode_terminal = (
_tr is not None and _tr.get(session_id, "opencode", "main") is not None
)
if not _has_opencode_terminal:
_publish_terminal_pending(_publish_event, session_id, True)
try:
try:
_opencode_spec = await _resolve_session_agent_spec(session_id)
except OmnigentError:
_opencode_spec = None
await _auto_create_opencode_terminal(
session_id,
resource_registry,
_publish_event,
agent_spec=_opencode_spec,
server_client=server_client,
)
except Exception as exc:
_logger.exception(
"Failed to auto-create opencode terminal for %s",
session_id,
)
_publish_native_terminal_start_error(
_publish_event,
session_id,
"OpenCode",
exc,
)
finally:
_publish_terminal_pending(_publish_event, session_id, False)
if harness_name == "goose-native":
_goose_ensure_lock = _goose_terminal_ensure_locks.setdefault(
session_id, asyncio.Lock()
@@ -9892,6 +10423,18 @@ def create_runner_app(
from omnigent.pi_native_bridge import build_pi_native_spawn_env
spawn_env = build_pi_native_spawn_env(conv_id)
if harness_name == "opencode-native" and spawn_env is None:
from omnigent.opencode_native_bridge import (
OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY,
build_opencode_native_spawn_env,
)
labels = await _session_labels_for_runner_spawn(
server_client=server_client,
session_id=conv_id,
)
bridge_id = labels.get(OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY)
spawn_env = build_opencode_native_spawn_env(conv_id, bridge_id=bridge_id)
if harness_name == "cursor-native" and spawn_env is None:
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
@@ -11484,6 +12027,42 @@ def create_runner_app(
content=session_resource_view_to_dict(terminal_view),
)
if (
body.get("ensure_native_terminal")
and terminal_name == "opencode"
and session_key == "main"
):
opencode_terminal_id = terminal_resource_id("opencode", "main")
ensure_lock = _opencode_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
async with ensure_lock:
existing = await resource_registry.get_terminal_resource(
session_id, opencode_terminal_id
)
if existing is not None:
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(existing),
)
try:
opencode_agent_spec = await _resolve_session_agent_spec(session_id)
terminal_view = await _auto_create_opencode_terminal(
session_id,
resource_registry,
_publish_event,
agent_spec=opencode_agent_spec,
server_client=server_client,
)
except Exception as exc:
_logger.exception(
"OpenCode terminal ensure failed for session=%s",
session_id,
)
return _native_terminal_start_error_response(exc, "OpenCode")
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(terminal_view),
)
if (
body.get("ensure_native_terminal")
and terminal_name == "cursor"
+1
View File
@@ -50,6 +50,7 @@ _DEFAULT_WORKSPACE_ROOT = os.path.join(
CODEX_NATIVE_TERMINAL_ROLE = "codex-native"
CLAUDE_NATIVE_TERMINAL_ROLE = "claude-native"
PI_NATIVE_TERMINAL_ROLE = "pi-native"
OPENCODE_NATIVE_TERMINAL_ROLE = "opencode-native"
CURSOR_NATIVE_TERMINAL_ROLE = "cursor-native"
GOOSE_NATIVE_TERMINAL_ROLE = "goose-native"
# Role marker for the embedded Omnigent REPL terminal auto-created for
+106
View File
@@ -40,6 +40,7 @@ from omnigent._wrapper_labels import (
CLAUDE_NATIVE_WRAPPER_VALUE,
CODEX_NATIVE_WRAPPER_VALUE,
)
from omnigent.harness_aliases import canonicalize_harness
from omnigent.model_override import (
harness_supports_model_override,
model_family_mismatch,
@@ -829,6 +830,61 @@ def _subagent_harness(sub_agent_name: str, agent_spec: Any | None) -> str | None
return spec_harness(sub_spec) if sub_spec is not None else None
def _subagent_harness_override_from_args(args: dict[str, Any]) -> str | None:
"""
Extract a per-dispatch harness override from ``sys_session_send`` args.
The optional ``harness`` field lives in the object form of ``args``
(``{"input": ..., "harness": "opencode-native"}``). Returned raw (not
yet canonicalized) so the caller can validate it against the sub-agent
allowlist and quote the original spelling in errors.
:param args: Parsed ``sys_session_send`` arguments.
:returns: The raw harness override, or ``None`` when absent.
:raises ValueError: If ``harness`` is present but not a string.
"""
raw_message = args.get("args")
if not isinstance(raw_message, dict):
return None
raw_harness = raw_message.get("harness")
if raw_harness is None:
return None
if not isinstance(raw_harness, str) or not raw_harness:
raise ValueError("'harness' must be a non-empty string when provided")
return raw_harness
def _subagent_allowed_harnesses(sub_agent_name: str, agent_spec: Any | None) -> frozenset[str]:
"""
Resolve the canonical harness allowlist a sub-agent opts into.
Reads ``executor.config.allowed_harnesses`` from the named sub-agent's
spec — the explicit opt-in that gates ``args.harness``. Each entry is
canonicalized so a user-facing alias still matches.
:param sub_agent_name: Name of the sub-agent, e.g. ``"opencode"``.
:param agent_spec: Parent agent's spec.
:returns: Canonical allowlisted harness ids (empty when none declared).
"""
sub_spec = _find_subagent_spec(sub_agent_name, agent_spec)
if sub_spec is None:
return frozenset()
executor = getattr(sub_spec, "executor", None)
config = getattr(executor, "config", None)
raw_allowed: Any = None
if isinstance(config, dict):
raw_allowed = config.get("allowed_harnesses")
elif config is not None:
raw_allowed = getattr(config, "allowed_harnesses", None)
if not isinstance(raw_allowed, (list, tuple, set, frozenset)):
return frozenset()
return frozenset(
canonicalize_harness(str(entry)) or str(entry)
for entry in raw_allowed
if isinstance(entry, str) and entry
)
def _normalize_subagent_model(
model: str,
*,
@@ -952,6 +1008,11 @@ async def _execute_subagent_tool(
except ValueError as exc:
return f"Error: sys_session_send invalid 'model': {exc}"
try:
harness_override = _subagent_harness_override_from_args(args)
except ValueError as exc:
return f"Error: sys_session_send invalid 'harness': {exc}"
# By-session-id mode: post to an existing direct child instead of
# spawning/continuing a named (agent, title) sub-agent.
target_session_id = args.get("session_id")
@@ -971,6 +1032,13 @@ async def _execute_subagent_tool(
"existing session. Re-send without 'model' to continue "
f"session {target_session_id!r}."
)
if harness_override is not None:
return (
"Error: sys_session_send 'harness' applies only when a "
"sub-agent session is first created; it cannot change an "
"existing session. Re-send without 'harness' to continue "
f"session {target_session_id!r}."
)
return await _send_to_existing_session(
target_session_id,
message,
@@ -1053,6 +1121,42 @@ async def _execute_subagent_tool(
)
else:
child_harness = _subagent_harness(str(sub_agent_name), agent_spec)
# Apply an allowlisted per-dispatch harness override. The sub-agent
# spec must explicitly opt in via executor.config.allowed_harnesses,
# and the requested harness must canonicalize into OMNIGENT_HARNESSES.
# NOTE: the server create route (``_validated_harness_override`` in
# server/routes/sessions.py) independently re-validates a session-create
# override against the GLOBAL ``OMNIGENT_HARNESSES`` (plus the omnigent
# executor-type rule), but it does NOT re-check the per-spec
# ``allowed_harnesses`` allowlist. So this orchestrator-dispatch check is
# the sole enforcement of that per-spec allowlist; a direct
# ``POST /v1/sessions`` harness_override is bounded only by the global
# allowlist.
harness_override_canonical: str | None = None
if harness_override is not None:
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
canonical = canonicalize_harness(harness_override) or harness_override
allowed = _subagent_allowed_harnesses(str(sub_agent_name), agent_spec)
if not allowed:
return (
f"Error: sys_session_send 'harness' override is not "
f"permitted for sub-agent {sub_agent_name!r}: its spec "
"declares no executor.config.allowed_harnesses allowlist."
)
if canonical not in allowed:
return (
f"Error: sys_session_send 'harness' {harness_override!r} is "
f"not allowlisted for sub-agent {sub_agent_name!r}: allowed "
f"harnesses are {sorted(allowed)}."
)
if canonical not in OMNIGENT_HARNESSES:
return (
f"Error: sys_session_send 'harness' {harness_override!r} is "
f"not a known harness; must be one of {sorted(OMNIGENT_HARNESSES)}."
)
harness_override_canonical = canonical
child_harness = canonical
# Fail loud at dispatch when the child's harness needs a CLI binary
# that isn't on PATH. Otherwise a missing CLI surfaces only as a lazy
# first-turn failure (e.g. the pi harness raises ImportError, which the
@@ -1089,6 +1193,8 @@ async def _execute_subagent_tool(
"title": f"{sub_agent_name}:{session_name}",
"sub_agent_name": sub_agent_name,
}
if harness_override_canonical is not None:
create_body["harness_override"] = harness_override_canonical
if model is not None:
# Reject up front when the child harness would silently
# ignore the persisted override — no silent drops.
+10
View File
@@ -88,6 +88,16 @@ _HARNESS_MODULES: dict[str, str] = {
# counterpart to the terminal-first ``goose-native`` TUI harness. Tool
# approvals surface as web elicitation cards via session/request_permission.
"goose": "omnigent.inner.goose_harness",
# Native OpenCode server bridge used by ``omnigent opencode``. The runner
# owns ``opencode serve`` + an SSE forwarder and this harness injects each
# web-UI turn over loopback HTTP — a native-server harness like
# codex-native, so both ``opencode-native`` and its ``native-opencode``
# alias are in ``NATIVE_HARNESSES``. See
# omnigent/inner/opencode_native_harness.py.
"opencode-native": "omnigent.inner.opencode_native_harness",
# ``opencode`` is accepted as a friendly alias for the canonical
# ``opencode-native`` (there is no separate SDK ``opencode`` harness).
"opencode": "omnigent.inner.opencode_native_harness",
}
__all__ = ["_HARNESS_MODULES"]
+46
View File
@@ -23,6 +23,7 @@ from omnigent.native_coding_agents import (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
OPENCODE_NATIVE_CODING_AGENT,
PI_NATIVE_CODING_AGENT,
)
from omnigent.resources import examples as _examples_resources
@@ -77,6 +78,7 @@ _WEB_UI_GZIP_MINIMUM_SIZE = 1024
_CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name
_CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name
_PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name
_OPENCODE_NATIVE_AGENT_NAME = OPENCODE_NATIVE_CODING_AGENT.agent_name
_CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name
_DEBBY_AGENT_NAME = "debby"
_POLLY_AGENT_NAME = "polly"
@@ -352,6 +354,7 @@ def _ensure_default_agents(
_ensure_default_claude_agent(agent_store, artifact_store, agent_cache)
_ensure_default_codex_agent(agent_store, artifact_store, agent_cache)
_ensure_default_pi_agent(agent_store, artifact_store, agent_cache)
_ensure_default_opencode_agent(agent_store, artifact_store, agent_cache)
_ensure_default_cursor_agent(agent_store, artifact_store, agent_cache)
_ensure_default_debby_agent(agent_store, artifact_store, agent_cache)
_ensure_default_polly_agent(agent_store, artifact_store, agent_cache)
@@ -514,6 +517,49 @@ def _ensure_default_codex_agent(
)
def _build_opencode_native_bundle() -> bytes:
"""
Build a gzipped tarball of the opencode-native-ui agent spec.
:returns: Gzipped tarball bytes suitable for the artifact store.
"""
import tempfile
from omnigent.opencode_native import _materialize_opencode_agent_spec
from omnigent.spec import materialize_bundle
with tempfile.TemporaryDirectory() as tmpdir:
spec_path = _materialize_opencode_agent_spec(Path(tmpdir), model=None)
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
return _tar_gz_dir(bundle_dir)
def _ensure_default_opencode_agent(
agent_store: AgentStore,
artifact_store: ArtifactStore,
agent_cache: Any,
) -> None:
"""
Register or refresh the opencode-native-ui agent.
Called during server lifespan startup so the Web UI can offer OpenCode
as a built-in agent alongside Claude / Codex / Pi. Content-aware via
:func:`_ensure_builtin_agent`: a new wheel with a changed spec refreshes
the row in place rather than being ignored.
:param agent_store: Store for agent metadata.
:param artifact_store: Store for agent bundles.
:param agent_cache: Cache for loaded agent specs.
"""
_ensure_builtin_agent(
agent_store,
artifact_store,
agent_cache,
name=_OPENCODE_NATIVE_AGENT_NAME,
bundle_bytes=_build_opencode_native_bundle(),
)
def _build_pi_native_bundle() -> bytes:
"""
Build a gzipped tarball of the pi-native-ui agent spec.
+7
View File
@@ -73,6 +73,10 @@ OMNIGENT_EXECUTOR_TYPE = "omnigent"
# made ``examples/terminal_workers.yaml``
# fail at spec-load with a "must be one of [...], got
# 'open-responses'" error.
#
# ``opencode-native`` is the native OpenCode server bridge (runner-owned
# ``opencode serve`` + SSE forwarder); its ``opencode`` / ``native-opencode``
# spellings are accepted aliases below.
OMNIGENT_HARNESSES = frozenset(
{
"antigravity",
@@ -86,6 +90,7 @@ OMNIGENT_HARNESSES = frozenset(
"goose-native",
"openai-agents",
"open-responses",
"opencode-native",
"pi",
"pi-native",
"qwen",
@@ -101,6 +106,8 @@ OMNIGENT_HARNESS_ALIASES = frozenset(
"agy",
"google-antigravity",
"qwen-code",
"opencode",
"native-opencode",
}
)
_OMNIGENT_ACCEPTED_HARNESSES = OMNIGENT_HARNESSES | OMNIGENT_HARNESS_ALIASES
+75 -11
View File
@@ -154,6 +154,34 @@ class SysSessionSendTool(Tool):
return _build_sys_session_send_schema(self._sub_specs)
def _spec_opts_into_harness_override(spec: Any) -> bool:
"""
Return ``True`` if a sub-agent spec opts into the ``args.harness`` override.
The override is allowlist-gated (design D.4): a sub-agent advertises it
only when its ``executor.config.allowed_harnesses`` declares a non-empty
allowlist. This mirrors the dispatch-side opt-in read in
``omnigent/runner/tool_dispatch.py`` (``_subagent_allowed_harnesses``) so
the schema gate and the runtime guard agree on what "opted in" means.
Specs without the opt-in keep the base ``{input, purpose, model}`` args
contract.
:param spec: A sub-agent :class:`AgentSpec` (or structural equivalent).
:returns: ``True`` when the spec declares a non-empty allowlist.
"""
executor = getattr(spec, "executor", None)
config = getattr(executor, "config", None)
if isinstance(config, dict):
raw_allowed: Any = config.get("allowed_harnesses")
elif config is not None:
raw_allowed = getattr(config, "allowed_harnesses", None)
else:
raw_allowed = None
if not isinstance(raw_allowed, (list, tuple, set, frozenset)):
return False
return any(isinstance(entry, str) and entry for entry in raw_allowed)
def _build_sys_session_send_schema(
sub_specs: dict[str, AgentSpec],
) -> dict[str, Any]:
@@ -220,6 +248,51 @@ def _build_sys_session_send_schema(
"the same response — they dispatch concurrently."
)
)
# ``args.harness`` is allowlist-gated (design D.4): advertise it only when
# at least one declared sub-agent opts in via
# ``executor.config.allowed_harnesses``. Specs without the opt-in keep the
# base {input, purpose, model} args object, so the orchestrator never sees a
# harness knob it can't use. The dispatch-side guard in tool_dispatch.py
# re-enforces the opt-in per child (and the server create route does too).
harness_opt_in = any(_spec_opts_into_harness_override(spec) for spec in sub_specs.values())
harness_property: dict[str, Any] = (
{
"harness": {
"type": "string",
"description": (
"Optional harness override for "
"this sub-agent session, e.g. "
"'opencode-native'. Applies only "
"when this send CREATES the "
"session AND the sub-agent spec "
"allowlists it via "
"executor.config.allowed_harnesses; "
"otherwise rejected. Omitted = the "
"sub-agent's declared harness."
),
}
}
if harness_opt_in
else {}
)
args_description = (
(
"The user-input message to send to the sub-agent. The sub-agent "
"treats this as the first user turn in its conversation. Pass a "
"plain string for the normal contract, or pass "
"{input, purpose, model, harness} when a spec-level policy "
"requires explicit dispatch metadata, a per-dispatch model "
"override, or an allowlisted harness override."
)
if harness_opt_in
else (
"The user-input message to send to the sub-agent. The sub-agent "
"treats this as the first user turn in its conversation. Pass a "
"plain string for the normal contract, or pass "
"{input, purpose, model} when a spec-level policy requires "
"explicit dispatch metadata or a per-dispatch model override."
)
)
return {
"type": "function",
"function": {
@@ -274,22 +347,13 @@ def _build_sys_session_send_schema(
"omitted = the harness default."
),
},
**harness_property,
},
"required": ["input"],
"additionalProperties": False,
},
],
"description": (
"The user-input message to send "
"to the sub-agent. The sub-agent "
"treats this as the first user "
"turn in its conversation. Pass a "
"plain string for the normal contract, "
"or pass {input, purpose, model} when "
"a spec-level policy requires explicit "
"dispatch metadata or a per-dispatch "
"model override."
),
"description": args_description,
},
},
# Only ``args`` is universally required; the
+10 -2
View File
@@ -1935,8 +1935,16 @@ def test_materialize_directory_bundle_with_override_keeps_nested_harness_unpinne
@pytest.mark.parametrize(
("bundle_name", "expected_workers"),
[
("polly", {"claude_code": "claude-native", "codex": "codex-native", "pi": "pi"}),
("debby", {"claude": "claude-sdk", "gpt": "codex"}),
(
"polly",
{
"claude_code": "claude-native",
"codex": "codex-native",
"pi": "pi",
"opencode": "opencode-native",
},
),
("debby", {"claude": "claude-sdk", "gpt": "codex", "opencode": "opencode-native"}),
],
)
def test_materialize_bundle_overrides_brain_harness(
+111
View File
@@ -0,0 +1,111 @@
"""Tests for the OpenCode ``omni setup`` default-model picker helpers."""
from __future__ import annotations
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
import omnigent.cli as cli
from omnigent.cli import _list_opencode_models, _load_global_config, _set_opencode_default_model
@pytest.fixture
def _isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Point the global config at a tmp file so saves don't touch ``~/.omnigent``."""
path = tmp_path / "config.yaml"
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", path)
return path
def _fake_spec() -> SimpleNamespace:
return SimpleNamespace(binary="opencode")
# ── _list_opencode_models ───────────────────────────────────────────────────
def test_list_models_parses_nonblank_lines(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec()
)
monkeypatch.setattr(
cli.subprocess,
"run",
lambda *a, **k: subprocess.CompletedProcess(
a, 0, stdout="anthropic/claude-sonnet-4-5\nopenai/gpt-5.5\n\n \n", stderr=""
),
)
assert _list_opencode_models() == ["anthropic/claude-sonnet-4-5", "openai/gpt-5.5"]
def test_list_models_empty_when_cli_absent(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: None
)
assert _list_opencode_models() == []
def test_list_models_empty_on_subprocess_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec()
)
def _boom(*_a: object, **_k: object) -> object:
raise OSError("no binary")
monkeypatch.setattr(cli.subprocess, "run", _boom)
assert _list_opencode_models() == []
# ── _set_opencode_default_model ─────────────────────────────────────────────
def test_set_default_model_persists_choice(
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
) -> None:
monkeypatch.setattr(
cli, "_list_opencode_models", lambda: ["anthropic/claude-sonnet-4-5", "x/y"]
)
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 0)
status = _set_opencode_default_model(current=None)
assert status == "✓ default model: anthropic/claude-sonnet-4-5"
assert _load_global_config()["opencode_model"] == "anthropic/claude-sonnet-4-5"
def test_set_default_model_clear_unsets(
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
) -> None:
cli._save_global_config({"opencode_model": "x/y"})
monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"])
# options == ["a/b", "Clear default ..."]; index 1 is the clear row.
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 1)
status = _set_opencode_default_model(current="x/y")
assert status == "✓ default model cleared"
assert "opencode_model" not in _load_global_config()
def test_set_default_model_cancel_is_noop(
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
) -> None:
monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"])
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: -1)
assert _set_opencode_default_model(current=None) is None
assert _load_global_config() == {}
def test_set_default_model_no_models_short_circuits(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cli, "_list_opencode_models", list)
called = False
def _select(*_a: object, **_k: object) -> int:
nonlocal called
called = True
return 0
monkeypatch.setattr("omnigent.onboarding.interactive.select", _select)
status = _set_opencode_default_model(current=None)
assert status is not None and status.startswith("")
assert called is False # never prompts when there's nothing to pick
+6 -3
View File
@@ -44,11 +44,14 @@ def test_debby_is_two_headed_cross_vendor(debby_spec: AgentSpec) -> None:
"""
assert debby_spec.name == "debby"
fam = {a.name: a.executor.config.get("harness") for a in debby_spec.sub_agents}
assert sorted(debby_spec.tools.agents) == ["claude", "gpt"]
# claude + gpt are the two default heads; opencode is the optional third
# perspective (default fanout stays claude + gpt — see the prompt).
assert sorted(debby_spec.tools.agents) == ["claude", "gpt", "opencode"]
assert fam["claude"] == "claude-sdk"
assert fam["gpt"] == "codex"
# Two distinct vendors → the heads always disagree across providers.
assert len(set(fam.values())) == 2
assert fam["opencode"] == "opencode-native"
# Three distinct vendors → the heads always disagree across providers.
assert len(set(fam.values())) == 3
def test_debby_heads_are_unpinned(debby_spec: AgentSpec) -> None:
+9 -7
View File
@@ -79,13 +79,15 @@ def test_coding_subagents(polly_spec: AgentSpec) -> None:
would break cross-vendor review — polly's differentiator.
"""
fam = {a.name: a.executor.config.get("harness") for a in polly_spec.sub_agents}
assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "pi"]
assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "opencode", "pi"]
assert fam["claude_code"] == "claude-native"
assert fam["codex"] == "codex-native"
assert fam["pi"] == "pi"
# Three distinct vendors → any diff is always reviewable by another.
assert len(set(fam.values())) == 3
for name in ("claude_code", "codex", "pi"):
assert fam["opencode"] == "opencode-native"
# Four distinct vendors (opencode is the optional fourth) → any diff is
# always reviewable by another.
assert len(set(fam.values())) == 4
for name in ("claude_code", "codex", "pi", "opencode"):
prompt = (_POLLY_BUNDLE / "agents" / name / "config.yaml").read_text(encoding="utf-8")
assert "IMPLEMENT — write real product code" in prompt
assert "REVIEW — verify another agent's diff" in prompt
@@ -388,6 +390,6 @@ def test_function_policies_have_nonempty_arguments(polly_spec: AgentSpec) -> Non
)
checked += 1
# orchestrator: blast_radius + spawn_bounds + headless_subagent_purpose_guard
# = 3; sub-agents: blast_radius x3 (claude_code, codex, pi) = 3 -> 6 total.
# Fewer = a policy dropped.
assert checked == 6, f"expected 6 function policies in the bundle, inspected {checked}"
# = 3; sub-agents: blast_radius x4 (claude_code, codex, pi, opencode) = 4
# -> 7 total. Fewer = a policy dropped.
assert checked == 7, f"expected 7 function policies in the bundle, inspected {checked}"
@@ -137,10 +137,15 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
``_HARNESS_MODULES``, this file must gain a live round-trip row
for it.
``claude-native``, ``codex-native``, and ``pi-native`` are excluded
because their inner executors require bridge directories plus
runner-managed terminal panes to inject keys into — both set up by
their native launchers, not by ``omnigent run --harness <native>``.
``claude-native``, ``codex-native``, ``pi-native``, and
``opencode-native`` are excluded because their inner executors require
bridge directories plus runner-managed terminal panes to inject keys
into — both set up by their native launchers, not by
``omnigent run --harness <native>``. (``opencode-native`` is a
terminal-takeover ``native-server`` harness, the same shape as the
other natives.) Running them through this matrix would hang or crash.
Their e2e coverage is via native launcher smoke tests (tracked
separately as native-launcher PTY/REPL smoke tests).
``cursor`` is excluded because this matrix authenticates through
the Databricks gateway/profile, while cursor-agent talks only to
@@ -173,6 +178,7 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
"claude-native",
"codex-native",
"pi-native",
"opencode-native",
"cursor",
"cursor-native",
"antigravity",
+283
View File
@@ -0,0 +1,283 @@
"""End-to-end test for the ``opencode-native-ui`` built-in agent (full stack).
The runner-orchestration sibling of ``test_opencode_native_wire_contract_e2e.py``
(which drives ``OpenCodeNativeServer`` directly). This exercises the WHOLE
product path: list built-in agents -> find ``opencode-native-ui`` -> connect a
host daemon -> create a host-bound session -> the runner auto-creates the
``opencode serve`` + SSE forwarder + ``opencode attach`` terminal resource ->
send a user message -> poll session items until the assistant echoes a marker.
Opt-in (needs a pinned ``opencode`` binary + LLM credentials)::
OMNIGENT_E2E_OPENCODE_NATIVE=1 \
HOME=/tmp/omni-isolated DATABRICKS_CONFIG_FILE=$REAL_HOME/.databrickscfg \
.venv/bin/python -m pytest tests/e2e/test_host_opencode_native_e2e.py \
--profile ai-devtools-prod \
--llm-api-key "$(databricks auth token -p ai-devtools-prod \
| python -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')" \
-v
Running under an isolated ``$HOME`` keeps the runner-owned ``opencode serve``
bridge dirs (``~/.omnigent/opencode-native``) and the daemon registry off the
developer's real ones, so a co-resident daemon is never disturbed.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
import httpx
import pytest
from omnigent.entities.session_resources import terminal_resource_id
from tests._helpers.compat import apply_runner_env, compat_runner_cwd, runner_executable
from tests.e2e.helpers import POLL_INTERVAL_S
_OPENCODE_NATIVE_AGENT_NAME = "opencode-native-ui"
pytestmark = pytest.mark.skipif(
os.environ.get("OMNIGENT_E2E_OPENCODE_NATIVE") != "1" or shutil.which("opencode") is None,
reason=(
"opencode-native host e2e needs a pinned `opencode` binary + LLM creds; "
"set OMNIGENT_E2E_OPENCODE_NATIVE=1 (and pass --profile/--llm-api-key) to run"
),
)
def _spawn_host_daemon(*, tmp_path: Path, live_server: str) -> subprocess.Popen[bytes]:
"""Spawn an ``omnigent host`` daemon pointed at the test server."""
repo_root = Path(__file__).resolve().parents[2]
env = os.environ.copy()
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{env.get('PYTHONPATH', '')}"
daemon_log = tmp_path / "host-daemon.log"
with open(daemon_log, "w") as log_fh:
return subprocess.Popen(
[runner_executable(), "-m", "omnigent.host._daemon_entry", "--server", live_server],
env=apply_runner_env(env),
cwd=compat_runner_cwd(),
stdout=subprocess.DEVNULL,
stderr=log_fh,
)
def _online_host_id(client: httpx.Client, timeout: float = 30.0) -> str:
"""Poll ``GET /v1/hosts`` until at least one host is online."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = client.get("/v1/hosts")
if resp.status_code == 200:
online = [h for h in resp.json().get("hosts", []) if h["status"] == "online"]
if online:
return str(online[0]["host_id"])
time.sleep(POLL_INTERVAL_S)
raise AssertionError(f"No host came online within {timeout}s")
def _poll_for_terminal(
client: httpx.Client, *, session_id: str, resource_id: str, timeout: float
) -> None:
"""Poll resources until the runner registers the opencode terminal."""
deadline = time.monotonic() + timeout
last: list[object] = []
while time.monotonic() < deadline:
resp = client.get(f"/v1/sessions/{session_id}/resources")
if resp.status_code == 200:
data = resp.json().get("data", [])
last = [r.get("id") for r in data]
if any(r.get("id") == resource_id and r.get("type") == "terminal" for r in data):
return
time.sleep(POLL_INTERVAL_S)
raise AssertionError(
f"Terminal {resource_id!r} never appeared for {session_id} within {timeout}s; saw {last!r}"
)
def test_opencode_native_multiturn_item_order(
http_client: httpx.Client,
tmp_path: Path,
live_server: str,
) -> None:
"""DIAGNOSTIC: dump /items for a 3-turn conversation to inspect ordering.
Reproduces the reported web-chat bug (assistant messages clustered, user
messages out of order/missing). Sends 3 user turns through a real
host-bound opencode session and prints every persisted item's
role/position/response_id so we can see exactly how user vs assistant items
land. Asserts strict user/assistant interleaving.
"""
resp = http_client.get("/v1/agents")
resp.raise_for_status()
agent_id = next(
(a["id"] for a in resp.json()["data"] if a["name"] == _OPENCODE_NATIVE_AGENT_NAME), None
)
assert agent_id is not None
workspace = tmp_path / "ws"
workspace.mkdir()
daemon = _spawn_host_daemon(tmp_path=tmp_path, live_server=live_server)
try:
host_id = _online_host_id(http_client)
create = http_client.post(
"/v1/sessions",
json={
"agent_id": agent_id,
"host_id": host_id,
"workspace": str(workspace),
# gateway-valid model via opencode's openai provider (the daemon
# has OPENAI_BASE_URL/OPENAI_API_KEY pointed at the gateway).
"model_override": "openai/databricks-claude-sonnet-4-6",
},
timeout=60.0,
)
create.raise_for_status()
session_id = create.json()["id"]
_poll_for_terminal(
http_client,
session_id=session_id,
resource_id=terminal_resource_id("opencode", "main"),
timeout=90.0,
)
prompts = ["say ONE", "say TWO", "say THREE"]
for i, prompt in enumerate(prompts):
http_client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": prompt}]},
},
timeout=30.0,
).raise_for_status()
# Wait until at least i+1 assistant message items exist.
deadline = time.monotonic() + 120.0
while time.monotonic() < deadline:
data = (
http_client.get(
f"/v1/sessions/{session_id}/items", params={"limit": 100, "order": "asc"}
)
.json()
.get("data", [])
)
n_asst = sum(
1
for it in data
if it.get("type") == "message" and it.get("role") == "assistant"
)
if n_asst >= i + 1:
break
time.sleep(POLL_INTERVAL_S)
data = (
http_client.get(
f"/v1/sessions/{session_id}/items", params={"limit": 100, "order": "asc"}
)
.json()
.get("data", [])
)
print("\n===ITEMS_DUMP_START===")
for it in data:
text = ""
for blk in it.get("content", []) or []:
if isinstance(blk, dict) and isinstance(blk.get("text"), str):
text += blk["text"]
print(
f"pos={it.get('position')!s:>4} type={it.get('type')!s:18} "
f"role={it.get('role')!s:10} rid={str(it.get('response_id'))[:24]:24} "
f"text={text[:40]!r}"
)
print("===ITEMS_DUMP_END===\n")
roles = [
it.get("role")
for it in data
if it.get("type") == "message" and it.get("role") in ("user", "assistant")
]
expected = ["user", "assistant", "user", "assistant", "user", "assistant"]
assert roles == expected, f"messages not interleaved by turn: {roles}"
finally:
daemon.terminate()
try:
daemon.wait(timeout=10)
except subprocess.TimeoutExpired:
daemon.kill()
def test_opencode_native_builtin_registered_at_startup(http_client: httpx.Client) -> None:
"""The server auto-registers ``opencode-native-ui`` as a built-in agent."""
resp = http_client.get("/v1/agents")
resp.raise_for_status()
names = {a["name"] for a in resp.json()["data"]}
assert _OPENCODE_NATIVE_AGENT_NAME in names, (
f"Expected {_OPENCODE_NATIVE_AGENT_NAME!r} in built-ins {names}; "
"_ensure_default_opencode_agent did not run."
)
def test_opencode_native_host_session_auto_creates_terminal(
http_client: httpx.Client,
tmp_path: Path,
live_server: str,
) -> None:
"""A host-bound opencode-native session auto-creates the opencode terminal.
Exercises the runner-orchestration path end-to-end: an online host daemon
runs the session, and the runner's session-creation dispatch must call
:func:`_auto_create_opencode_terminal` (boot ``opencode serve`` + SSE
forwarder + ``opencode attach``) and register ``terminal_opencode_main`` as
a streamable resource — so the Web UI has a terminal+chat view to embed,
exactly as it does for claude/codex/pi/cursor.
(The LLM turn itself is covered by ``test_opencode_native_wire_contract_e2e``
and the standalone gateway round-trip; out-of-box turns through the built-in
agent additionally need its default model gateway-wired — tracked
separately.)
"""
resp = http_client.get("/v1/agents")
resp.raise_for_status()
agent_id = next(
(a["id"] for a in resp.json()["data"] if a["name"] == _OPENCODE_NATIVE_AGENT_NAME), None
)
assert agent_id is not None, "opencode-native-ui agent not seeded"
workspace = tmp_path / "ws"
workspace.mkdir()
daemon = _spawn_host_daemon(tmp_path=tmp_path, live_server=live_server)
try:
host_id = _online_host_id(http_client)
create = http_client.post(
"/v1/sessions",
json={"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)},
timeout=60.0,
)
create.raise_for_status()
session_id = create.json()["id"]
# The runner's _auto_create_opencode_terminal must register the TUI on
# session creation (the dispatch branch this PR adds alongside the other
# natives) — otherwise the Web UI would have no terminal to attach to.
terminal_id = terminal_resource_id("opencode", "main")
_poll_for_terminal(
http_client,
session_id=session_id,
resource_id=terminal_id,
timeout=90.0,
)
# The `omnigent opencode` CLI launcher attaches this TTY directly to the
# runner-owned tmux pane, so the terminal resource must expose the tmux
# socket + target — assert that prerequisite is present.
detail = http_client.get(f"/v1/sessions/{session_id}/resources/terminals/{terminal_id}")
detail.raise_for_status()
meta = detail.json().get("metadata", {})
assert meta.get("tmux_socket"), f"terminal has no tmux_socket: {meta}"
assert meta.get("tmux_target"), f"terminal has no tmux_target: {meta}"
finally:
daemon.terminate()
try:
daemon.wait(timeout=10)
except subprocess.TimeoutExpired:
daemon.kill()
@@ -0,0 +1,115 @@
"""End-to-end test: the OpenCode-native client speaks to a REAL ``opencode serve``.
The opencode-native harness's HTTP+SSE client (``omnigent.opencode_native_client``)
is hand-shaped from the pinned OpenCode OpenAPI (vendored at
``omnigent/opencode/openapi-1.17.7.json``), so the rest of the suite exercises it
only against in-process fakes. This test boots a real ``opencode serve`` via the
PR's own :class:`~omnigent.opencode_native_app_server.OpenCodeNativeServer` and
drives the provider-independent endpoints the harness relies on, validating the
wire contract against the actual binary — the one thing the fakes cannot prove.
Environment requirements (why this is opt-in, not pure-CI)
----------------------------------------------------------
* **Opt-in only**: set ``OMNIGENT_E2E_OPENCODE_NATIVE=1`` and have a pinned
``opencode`` (>=1.17.7,<1.18.0) on ``PATH``. Unlike the codex/claude native
e2es this needs **no** interactive login or model credential — session
create/list, the SSE ``/event`` stream, permissions, fork and abort are all
provider-independent. The gate just keeps it off CI runners without the binary.
* Run it with::
OMNIGENT_E2E_OPENCODE_NATIVE=1 \
.venv/bin/python -m pytest \
tests/e2e/test_opencode_native_wire_contract_e2e.py -v
"""
from __future__ import annotations
import asyncio
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from omnigent.opencode_native_app_server import (
OpenCodeNativeServer,
OpenCodeVersionError,
)
pytestmark = pytest.mark.skipif(
os.environ.get("OMNIGENT_E2E_OPENCODE_NATIVE") != "1" or shutil.which("opencode") is None,
reason=(
"opencode-native wire-contract e2e needs a pinned `opencode` binary on PATH; "
"set OMNIGENT_E2E_OPENCODE_NATIVE=1 (and `npm i -g opencode-ai@1.17.7`) to run"
),
)
# Keys the typed client parses off a created session — assert the real server
# still emits the shape OpenCodeSession.from_payload depends on.
_REQUIRED_SESSION_KEYS = {"id", "directory", "title"}
async def test_opencode_native_wire_contract_against_real_server() -> None:
"""A real ``opencode serve`` answers every endpoint the harness drives.
Covers: server boot + version pin, session create/get (+404), message
list, permission list, the SSE ``/event`` stream framing, fork and abort —
the provider-independent surface the SSE forwarder and executor depend on.
"""
tmp = Path(tempfile.mkdtemp(prefix="opencode-e2e-"))
bridge = tmp / "bridge"
bridge.mkdir(parents=True, exist_ok=True)
workspace = tmp / "ws"
workspace.mkdir(parents=True, exist_ok=True)
server = OpenCodeNativeServer(bridge_dir=bridge, workspace=workspace)
try:
try:
await server.start()
except OpenCodeVersionError as exc:
pytest.skip(f"installed opencode is outside the supported pin: {exc}")
assert server.version is not None
client = server.client()
try:
# create_session — and the parsed shape the forwarder relies on.
session = await client.create_session({"title": "omnigent-e2e"})
assert session.id, "created session has no id"
assert set(session.raw) >= _REQUIRED_SESSION_KEYS, (
f"server session payload missing keys the client parses: "
f"{_REQUIRED_SESSION_KEYS - set(session.raw)}"
)
# get_session round-trips, and a missing id is a clean None (404).
fetched = await client.get_session(session.id)
assert fetched is not None and fetched.id == session.id
assert await client.get_session("ses_does_not_exist_xyz") is None
# message + permission listings are well-formed (empty for a fresh
# session that has run no turn).
assert await client.list_messages(session.id) == []
assert await client.list_permissions() == []
# The SSE /event stream connects and frames at least the initial
# ``server.connected`` event (proves _parse_sse against the real wire).
async def _first_event() -> object | None:
async for event in client.events():
return event
return None
try:
event = await asyncio.wait_for(_first_event(), timeout=10.0)
except asyncio.TimeoutError:
event = None
if event is not None:
assert isinstance(event.type, str)
# fork creates a new session; abort returns cleanly with no work.
forked = await client.fork(session.id)
assert forked.id and forked.id != session.id
assert isinstance(await client.abort(session.id), bool)
finally:
await client.aclose()
finally:
await server.close()
@@ -221,6 +221,33 @@ def _pi_native_agents_body() -> str:
)
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(
@@ -612,6 +639,98 @@ async def _drive_pi_native_start(base_url: str, session_id: str) -> None:
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.
@@ -0,0 +1,241 @@
"""Tests for the OpenCode native executor turn lifecycle."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import httpx
import pytest
from omnigent import opencode_http_transport as transport_mod
from omnigent.inner.executor import ExecutorError, TurnComplete
from omnigent.inner.opencode_native_executor import OpenCodeNativeExecutor
from omnigent.opencode_native_bridge import (
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR,
OpenCodeNativeBridgeState,
write_bridge_state,
)
from omnigent.opencode_native_client import OpenCodeClient
_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501
_PNG_DATA_URI = f"data:image/png;base64,{_PNG_B64}"
class _FakeServer:
"""Records the requests a fake OpenCode HTTP server receives."""
def __init__(self) -> None:
self.requests: list[tuple[str, str, dict[str, Any]]] = []
def handler(self, request: httpx.Request) -> httpx.Response:
body: dict[str, Any] = {}
if request.content:
try:
body = json.loads(request.content)
except json.JSONDecodeError:
body = {}
self.requests.append((request.method, request.url.path, body))
if request.url.path.endswith("/abort"):
return httpx.Response(200, json=True)
return httpx.Response(200, json={})
@pytest.fixture
def fake_server(monkeypatch: pytest.MonkeyPatch) -> _FakeServer:
"""Patch the transport's client factory to talk to a fake server."""
server = _FakeServer()
def fake_client_for_state(
*, base_url: str, auth_secret: str | None, directory: str | None = None
) -> OpenCodeClient:
mock = httpx.AsyncClient(
base_url="http://opencode.test",
transport=httpx.MockTransport(server.handler),
)
return OpenCodeClient("http://opencode.test", client=mock)
monkeypatch.setattr(transport_mod, "client_for_state", fake_client_for_state)
return server
def _seed_state(
bridge_dir: Path,
*,
session_id: str = "conv_1",
opencode_session_id: str = "ses_1",
model_override: str | None = None,
) -> None:
write_bridge_state(
bridge_dir,
OpenCodeNativeBridgeState(
session_id=session_id,
server_base_url="http://127.0.0.1:49231",
opencode_session_id=opencode_session_id,
auth_secret="pw",
model_override=model_override,
),
)
def _executor(
bridge_dir: Path, monkeypatch: pytest.MonkeyPatch, *, request_id: str = "conv_1"
) -> OpenCodeNativeExecutor:
monkeypatch.setenv(OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, request_id)
executor = OpenCodeNativeExecutor(bridge_dir=bridge_dir)
executor._boot_poll_attempts = 1
executor._boot_poll_delay = 0.0
return executor
async def _run(executor: OpenCodeNativeExecutor, content: Any) -> list[Any]:
events: list[Any] = []
async for event in executor.run_turn([{"role": "user", "content": content}], [], ""):
events.append(event)
return events
async def test_run_turn_injects_prompt_and_completes(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
events = await _run(executor, "hello")
assert [type(e) for e in events] == [TurnComplete]
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
assert len(prompt_reqs) == 1
parts = prompt_reqs[0][2]["parts"]
assert parts == [{"type": "text", "text": "hello"}]
async def test_run_turn_with_blocks(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
events = await _run(
executor,
[
{"type": "input_text", "text": "what is this?"},
{"type": "input_image", "image_url": _PNG_DATA_URI},
],
)
assert [type(e) for e in events] == [TurnComplete]
parts = fake_server.requests[0][2]["parts"]
text_parts = [p for p in parts if p["type"] == "text"]
file_parts = [p for p in parts if p["type"] == "file"]
assert text_parts[0]["text"] == "what is this?"
assert len(file_parts) == 1
assert file_parts[0]["url"] == _PNG_DATA_URI
assert file_parts[0]["mime"] == "image/png"
# No inline base64 in any text part.
assert all(_PNG_B64 not in p.get("text", "") for p in parts)
async def test_run_turn_pins_resolved_model_on_prompt(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The session's resolved model reaches the prompt body from turn one.
OpenCode's session-create body cannot carry a model, so the override is
applied per prompt as ``model: {providerID, modelID}``. This pins the
first injected turn (which OpenCode then persists as the session
default), so the override governs the run from the start — not only a
later web turn.
"""
_seed_state(tmp_path, model_override="anthropic/claude-opus-4")
executor = _executor(tmp_path, monkeypatch)
events = await _run(executor, "hello")
assert [type(e) for e in events] == [TurnComplete]
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
assert len(prompt_reqs) == 1
body = prompt_reqs[0][2]
assert body["model"] == {"providerID": "anthropic", "modelID": "claude-opus-4"}
async def test_run_turn_omits_model_when_no_override(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With no model_override the prompt carries no model (OpenCode default)."""
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
await _run(executor, "hello")
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
assert "model" not in prompt_reqs[0][2]
async def test_run_turn_no_user_content_errors(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
events = await _run(executor, "")
assert [type(e) for e in events] == [ExecutorError]
assert fake_server.requests == []
async def test_run_turn_missing_bridge_state_errors(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# No state written; resolve never returns a session id.
executor = _executor(tmp_path, monkeypatch)
events = await _run(executor, "hi")
assert [type(e) for e in events] == [ExecutorError]
async def test_run_turn_session_mismatch_errors(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path, session_id="conv_OTHER")
executor = _executor(tmp_path, monkeypatch, request_id="conv_1")
events = await _run(executor, "hi")
assert [type(e) for e in events] == [ExecutorError]
async def test_interrupt_calls_abort(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
assert await executor.interrupt_session("k") is True
abort_reqs = [r for r in fake_server.requests if r[1].endswith("/abort")]
assert len(abort_reqs) == 1
async def test_enqueue_message_injects_prompt(
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
assert await executor.enqueue_session_message("k", "steer me") is True
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
assert prompt_reqs[0][2]["parts"] == [{"type": "text", "text": "steer me"}]
def test_capabilities(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_seed_state(tmp_path)
executor = _executor(tmp_path, monkeypatch)
assert executor.supports_streaming() is False
assert executor.handles_tools_internally() is True
assert executor.supports_live_message_queue() is True
def test_harness_create_app_builds_fastapi() -> None:
"""The ``opencode-native`` harness module builds a FastAPI app (lazy executor)."""
from fastapi import FastAPI
from omnigent.inner.opencode_native_harness import create_app
assert isinstance(create_app(), FastAPI)
def test_harness_executor_factory_builds_from_env(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The harness executor factory constructs an executor from the spawn env."""
from omnigent.inner.opencode_native_harness import _build_opencode_native_executor
from omnigent.opencode_native_bridge import OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR
monkeypatch.setenv(OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, str(tmp_path))
assert isinstance(_build_opencode_native_executor(), OpenCodeNativeExecutor)
@@ -149,6 +149,10 @@ def test_configured_harness_map_covers_all_spellings(
"antigravity",
"agy",
"google-antigravity",
# Native OpenCode harness + its user-facing aliases.
"opencode-native",
"native-opencode",
"opencode",
# Qwen harnesses
"qwen",
"qwen-code",
+31
View File
@@ -358,3 +358,34 @@ def test_clear_screen_emits_clear_sequence_only_on_a_tty(monkeypatch: pytest.Mon
monkeypatch.setattr(sys, "stdout", pipe)
interactive.clear_screen()
assert pipe.getvalue() == "" # no escape sequences leak into non-TTY output
def test_render_menu_windows_long_list_to_viewport() -> None:
"""``max_visible`` renders only the window slice + scroll markers."""
options = [f"item-{i}" for i in range(20)]
out = interactive._render_menu(
"Pick",
options,
10,
descriptions=None,
width=80,
selectable=[True] * 20,
max_visible=5,
window_start=8,
)
# Visible window is options[8:13]; rows outside it are not rendered.
for shown in ("item-8", "item-10", "item-12"):
assert shown in out
assert "item-0" not in out
assert "item-19" not in out
assert "8 more" in out and "7 more" in out # ↑/↓ scroll markers
def test_render_menu_without_max_visible_renders_all_rows() -> None:
"""Default (no ``max_visible``) renders every row — no regression."""
options = [f"item-{i}" for i in range(20)]
out = interactive._render_menu(
"Pick", options, 0, descriptions=None, width=80, selectable=[True] * 20
)
assert "item-0" in out and "item-19" in out
assert "more" not in out
+89
View File
@@ -0,0 +1,89 @@
"""Tests for opencode-native credential reporting (``opencode_auth.py``)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import omnigent.onboarding.opencode_auth as oc
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point XDG_DATA_HOME at a tmp dir and clear provider env keys."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
for _provider_id, _label, var in oc._ENV_PROVIDER_VARS:
monkeypatch.delenv(var, raising=False)
def _write_auth(tmp_path: Path, providers: dict[str, object]) -> None:
path = oc.opencode_auth_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(providers), encoding="utf-8")
def test_auth_path_honors_xdg_data_home(tmp_path: Path) -> None:
assert oc.opencode_auth_path() == tmp_path / "share" / "opencode" / "auth.json"
def test_stored_providers_reads_auth_json_keys(tmp_path: Path) -> None:
_write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}, "openai": {"type": "oauth"}})
assert set(oc._stored_providers()) == {"anthropic", "openai"}
def test_stored_providers_empty_when_missing_or_invalid(tmp_path: Path) -> None:
assert oc._stored_providers() == () # no file
oc.opencode_auth_path().parent.mkdir(parents=True, exist_ok=True)
oc.opencode_auth_path().write_text("not json", encoding="utf-8")
assert oc._stored_providers() == ()
def test_env_providers_detects_present_keys(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "sk-x")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-y")
labels = oc._env_providers()
assert "OpenAI" in labels and "Anthropic" in labels
def test_summary_ready_requires_installed_and_a_provider(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: True)
# No provider yet → not ready.
assert oc.opencode_auth_summary().ready is False
# An env key flips it ready.
monkeypatch.setenv("OPENAI_API_KEY", "sk-x")
summary = oc.opencode_auth_summary()
assert summary.ready is True
assert summary.has_provider is True
assert "env: OpenAI" in summary.describe()
def test_summary_not_ready_when_cli_absent(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-x")
assert oc.opencode_auth_summary().ready is False # provider present but no binary
def test_describe_lists_stored_and_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: True)
_write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}})
monkeypatch.setenv("OPENAI_API_KEY", "sk-x")
text = oc.opencode_auth_summary().describe()
assert "1 stored (anthropic)" in text
assert "env: OpenAI" in text
def test_reachable_provider_ids_merges_stored_and_env(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}})
monkeypatch.setenv("OPENAI_API_KEY", "sk-x")
ids = oc.reachable_provider_ids()
assert "anthropic" in ids # from auth.json
assert "openai" in ids # from env key
assert "groq" not in ids
@@ -0,0 +1,102 @@
"""Unit tests for the OpenCode permission policy evaluator wiring.
The runner wires this evaluator into the OpenCode permission forwarder so
every ``permission.v2.asked`` request is decided by the SAME server-side
policy/approval gate codex-native uses (``POST /policies/evaluate``), not
silently auto-approved. These tests pin the request shape, the verdict
mapping, and — critically — that every failure mode fails CLOSED.
"""
from __future__ import annotations
import json as _json
from typing import Any
import httpx
from omnigent.runner.app import _build_opencode_policy_evaluator
class _FakeServerClient:
"""httpx-shaped stub recording the policy-evaluate POST."""
def __init__(
self,
*,
status: int = 200,
body: dict[str, Any] | None = None,
raise_exc: Exception | None = None,
) -> None:
self._status = status
self._body = body
self._raise_exc = raise_exc
self.calls: list[tuple[str, dict[str, Any], Any]] = []
async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> httpx.Response:
self.calls.append((url, json, timeout))
if self._raise_exc is not None:
raise self._raise_exc
content = b"" if self._body is None else _json.dumps(self._body).encode()
return httpx.Response(self._status, content=content, request=httpx.Request("POST", url))
async def test_evaluator_posts_tool_call_event_and_maps_allow() -> None:
"""ALLOW maps to the ``allow`` verdict; the POST carries a tool-call event."""
client = _FakeServerClient(body={"result": "POLICY_ACTION_ALLOW"})
evaluate = _build_opencode_policy_evaluator(
server_client=client, # type: ignore[arg-type]
conversation_id="conv_1",
)
verdict = await evaluate(
{"action": "bash", "command": "ls", "path": None, "url": None, "metadata": {}}
)
assert verdict == {"decision": "allow"}
url, body, _timeout = client.calls[0]
assert url == "/v1/sessions/conv_1/policies/evaluate"
event = body["event"]
assert event["type"] == "PHASE_TOOL_CALL"
assert event["data"]["name"] == "bash"
# Only the concrete, present resources reach the policy engine.
assert event["data"]["arguments"] == {"command": "ls"}
assert event["context"]["harness"] == "opencode-native"
async def test_evaluator_maps_deny_and_ask() -> None:
"""DENY → ``deny``; ASK → ``ask`` (the forwarder fails an unresolved ask closed)."""
for action, decision in (("POLICY_ACTION_DENY", "deny"), ("POLICY_ACTION_ASK", "ask")):
client = _FakeServerClient(body={"result": action})
evaluate = _build_opencode_policy_evaluator(
server_client=client, # type: ignore[arg-type]
conversation_id="c",
)
verdict = await evaluate({"action": "edit"})
assert verdict == {"decision": decision}
async def test_evaluator_maps_unknown_verdict_to_ask() -> None:
"""An unrecognized verdict fails closed (``ask`` → reject downstream)."""
client = _FakeServerClient(body={"result": "POLICY_ACTION_SOMETHING_NEW"})
evaluate = _build_opencode_policy_evaluator(
server_client=client, # type: ignore[arg-type]
conversation_id="c",
)
assert (await evaluate({"action": "bash"})) == {"decision": "ask"}
async def test_evaluator_fails_closed_on_transport_error() -> None:
client = _FakeServerClient(raise_exc=httpx.ConnectError("boom"))
evaluate = _build_opencode_policy_evaluator(
server_client=client, # type: ignore[arg-type]
conversation_id="c",
)
assert (await evaluate({"action": "bash"})) == {"decision": "deny"}
async def test_evaluator_fails_closed_on_non_200_or_empty_body() -> None:
for status, body in ((500, {"result": "POLICY_ACTION_ALLOW"}), (200, None)):
client = _FakeServerClient(status=status, body=body)
evaluate = _build_opencode_policy_evaluator(
server_client=client, # type: ignore[arg-type]
conversation_id="c",
)
assert (await evaluate({"action": "bash"})) == {"decision": "deny"}
@@ -0,0 +1,54 @@
"""Tests for the allowlisted ``args.harness`` sub-agent override helpers."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from omnigent.runner.tool_dispatch import (
_subagent_allowed_harnesses,
_subagent_harness_override_from_args,
)
def test_harness_override_absent_returns_none() -> None:
assert _subagent_harness_override_from_args({"args": {"input": "hi"}}) is None
assert _subagent_harness_override_from_args({"args": "plain string"}) is None
def test_harness_override_extracted() -> None:
args = {"args": {"input": "hi", "harness": "opencode-native"}}
assert _subagent_harness_override_from_args(args) == "opencode-native"
def test_harness_override_non_string_raises() -> None:
with pytest.raises(ValueError, match="harness"):
_subagent_harness_override_from_args({"args": {"input": "hi", "harness": 5}})
def _spec_with(allowed: object) -> SimpleNamespace:
config = {"harness": "codex-native"}
if allowed is not None:
config["allowed_harnesses"] = allowed
sub = SimpleNamespace(name="codex", executor=SimpleNamespace(config=config))
return SimpleNamespace(sub_agents=[sub])
def test_allowed_harnesses_reads_and_canonicalizes() -> None:
spec = _spec_with(["codex-native", "native-opencode"])
allowed = _subagent_allowed_harnesses("codex", spec)
# native-opencode canonicalizes to opencode-native.
assert allowed == frozenset({"codex-native", "opencode-native"})
def test_allowed_harnesses_empty_when_undeclared() -> None:
assert _subagent_allowed_harnesses("codex", _spec_with(None)) == frozenset()
def test_allowed_harnesses_empty_for_unknown_subagent() -> None:
assert _subagent_allowed_harnesses("nope", _spec_with(["codex-native"])) == frozenset()
def test_allowed_harnesses_no_spec() -> None:
assert _subagent_allowed_harnesses("codex", None) == frozenset()
+175
View File
@@ -0,0 +1,175 @@
"""Unit tests for :class:`omnigent.native_server_harness.NativeServerHarness`.
Drives the transport-agnostic base directly over an in-memory fake transport,
covering the run-turn / interrupt / enqueue orchestration (boot-poll, model
pinning, and the error branches) independent of any concrete harness.
"""
from __future__ import annotations
from typing import Any
from omnigent.inner.executor import ExecutorConfig, ExecutorError, TurnComplete
from omnigent.native_server_harness import NativeServerHarness
from omnigent.native_server_transport import NativePrompt
class _FakeTransport:
"""Records ``send_prompt`` / ``abort`` calls; optionally raises."""
def __init__(self, *, send_raises: bool = False, abort_raises: bool = False) -> None:
self.prompts: list[tuple[str, NativePrompt]] = []
self.aborted: list[str] = []
self._send_raises = send_raises
self._abort_raises = abort_raises
async def send_prompt(self, session_id: str, prompt: NativePrompt) -> dict[str, Any]:
if self._send_raises:
raise RuntimeError("inject boom")
self.prompts.append((session_id, prompt))
return {"ok": True}
async def abort(self, session_id: str) -> bool:
if self._abort_raises:
raise RuntimeError("abort boom")
self.aborted.append(session_id)
return True
def _build_prompt(content: Any) -> NativePrompt | None:
return NativePrompt(text=content) if isinstance(content, str) and content else None
def _harness(
transport: _FakeTransport,
*,
session_id: str | None = "ses_1",
resolver: Any = None,
supports_enqueue: bool = True,
) -> NativeServerHarness:
resolve = resolver if resolver is not None else _const_resolver(session_id)
return NativeServerHarness(
harness_id="fake-native",
supports_enqueue=supports_enqueue,
transport=transport, # type: ignore[arg-type]
resolve_session_id=resolve,
build_prompt=_build_prompt,
boot_poll_attempts=2,
boot_poll_delay=0.0,
)
def _const_resolver(session_id: str | None) -> Any:
async def _resolve() -> str | None:
return session_id
return _resolve
async def _drive(harness: NativeServerHarness, content: Any = "hello", config: Any = None) -> list:
return [
e async for e in harness.run_turn([{"role": "user", "content": content}], [], "", config)
]
# ── capabilities ────────────────────────────────────────────────────────────
def test_capabilities() -> None:
harness = _harness(_FakeTransport())
assert harness.supports_streaming() is False
assert harness.handles_tools_internally() is True
assert harness.supports_live_message_queue() is True
assert (
_harness(_FakeTransport(), supports_enqueue=False).supports_live_message_queue() is False
)
# ── run_turn ────────────────────────────────────────────────────────────────
async def test_run_turn_injects_and_completes() -> None:
transport = _FakeTransport()
events = await _drive(_harness(transport))
assert [type(e) for e in events] == [TurnComplete]
assert transport.prompts == [("ses_1", NativePrompt(text="hello"))]
async def test_run_turn_pins_config_model_when_prompt_unset() -> None:
transport = _FakeTransport()
await _drive(_harness(transport), config=ExecutorConfig(model="anthropic/claude-opus-4"))
assert transport.prompts[0][1].model == "anthropic/claude-opus-4"
async def test_run_turn_no_user_input_errors() -> None:
events = await _drive(_harness(_FakeTransport()), content="")
assert [type(e) for e in events] == [ExecutorError]
assert "no user input" in events[0].message
async def test_run_turn_missing_session_errors_after_boot_poll() -> None:
# Resolver always None → boot-poll exhausts → bridge-missing error.
events = await _drive(_harness(_FakeTransport(), resolver=_const_resolver(None)))
assert [type(e) for e in events] == [ExecutorError]
assert "bridge state is missing" in events[0].message
async def test_run_turn_boot_poll_recovers_session() -> None:
seq = [None, "ses_late"]
async def _resolve() -> str | None:
return seq.pop(0)
transport = _FakeTransport()
events = await _drive(_harness(transport, resolver=_resolve))
assert [type(e) for e in events] == [TurnComplete]
assert transport.prompts[0][0] == "ses_late"
async def test_run_turn_send_failure_becomes_error_event() -> None:
events = await _drive(_harness(_FakeTransport(send_raises=True)))
assert [type(e) for e in events] == [ExecutorError]
assert "executor error" in events[0].message
# ── interrupt_session ───────────────────────────────────────────────────────
async def test_interrupt_aborts() -> None:
transport = _FakeTransport()
assert await _harness(transport).interrupt_session("k") is True
assert transport.aborted == ["ses_1"]
async def test_interrupt_no_session_returns_false() -> None:
assert await _harness(_FakeTransport(), session_id=None).interrupt_session("k") is False
async def test_interrupt_swallows_abort_error() -> None:
assert await _harness(_FakeTransport(abort_raises=True)).interrupt_session("k") is False
# ── enqueue_session_message ─────────────────────────────────────────────────
async def test_enqueue_injects_prompt() -> None:
transport = _FakeTransport()
assert await _harness(transport).enqueue_session_message("k", "steer") is True
assert transport.prompts == [("ses_1", NativePrompt(text="steer"))]
async def test_enqueue_empty_content_returns_false() -> None:
assert await _harness(_FakeTransport()).enqueue_session_message("k", "") is False
async def test_enqueue_no_session_returns_false() -> None:
assert (
await _harness(_FakeTransport(), session_id=None).enqueue_session_message("k", "x")
is False
)
async def test_enqueue_swallows_send_error() -> None:
assert (
await _harness(_FakeTransport(send_raises=True)).enqueue_session_message("k", "x") is False
)
+207
View File
@@ -0,0 +1,207 @@
"""Unit tests for :class:`omnigent.opencode_http_transport.OpenCodeHttpTransport`.
Covers the payload builder + every transport method over an injected fake
``OpenCodeClient`` (the documented ``client_factory`` test seam), so the
opencode-native HTTP/SSE wire surface stays covered without a live server.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from omnigent.native_server_transport import (
NativeLaunchConfig,
NativePermissionDecision,
NativePrompt,
)
from omnigent.opencode_http_transport import OpenCodeHttpTransport, build_prompt_payload
# ── build_prompt_payload + part/model helpers ──────────────────────────────
def test_build_prompt_payload_text_only() -> None:
assert build_prompt_payload(NativePrompt(text="hi")) == {
"parts": [{"type": "text", "text": "hi"}]
}
def test_build_prompt_payload_system_and_model_split() -> None:
body = build_prompt_payload(
NativePrompt(text="hi", system_prompt="be brief", model="anthropic/claude-opus-4")
)
assert body["system"] == "be brief"
assert body["model"] == {"providerID": "anthropic", "modelID": "claude-opus-4"}
def test_build_prompt_payload_bare_model_id_is_dropped() -> None:
# No ``provider/model`` slash → not a valid opencode model object → omitted.
assert "model" not in build_prompt_payload(NativePrompt(text="hi", model="just-a-name"))
def test_build_prompt_payload_image_and_file_attachments() -> None:
prompt = NativePrompt(
text="look",
attachments=(
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
{
"type": "input_file",
"file_data": "data:application/pdf;base64,BBBB",
"filename": "a.pdf",
},
{"type": "input_file", "url": "data:text/plain;base64,CCCC"},
{"type": "input_image"}, # no url → skipped
),
)
parts = build_prompt_payload(prompt)["parts"]
assert {"type": "file", "mime": "image/png", "url": "data:image/png;base64,AAAA"} in parts
pdf = next(p for p in parts if p.get("filename") == "a.pdf")
assert pdf["mime"] == "application/pdf"
# The url-only file part falls back to its data-URI mime; the empty image is dropped.
assert any(p.get("mime") == "text/plain" for p in parts)
assert sum(1 for p in parts if p["type"] == "file") == 3
# ── transport methods over a fake client ────────────────────────────────────
class _FakeClient:
"""Records protocol calls; returns canned results for the transport."""
def __init__(self) -> None:
self.calls: list[tuple[str, Any]] = []
self.closed = False
self.existing: SimpleNamespace | None = None
async def get_session(self, session_id: str) -> SimpleNamespace | None:
self.calls.append(("get_session", session_id))
return self.existing
async def create_session(self, payload: Any = None) -> SimpleNamespace:
self.calls.append(("create_session", payload))
return SimpleNamespace(id="ses_new")
async def prompt_async(self, session_id: str, payload: Any) -> dict[str, Any]:
self.calls.append(("prompt_async", (session_id, payload)))
return {"ok": True}
async def abort(self, session_id: str) -> bool:
self.calls.append(("abort", session_id))
return True
async def list_messages(self, session_id: str) -> list[dict[str, Any]]:
self.calls.append(("list_messages", session_id))
return [{"info": {"id": "msg_1"}}]
async def fork(self, session_id: str, payload: Any = None) -> SimpleNamespace:
self.calls.append(("fork", (session_id, payload)))
return SimpleNamespace(id="ses_fork")
async def reply_permission(self, request_id: str, reply: Any) -> bool:
self.calls.append(("reply_permission", (request_id, reply)))
return True
async def events(self) -> Any:
self.calls.append(("events", None))
yield SimpleNamespace(
id="evt_1", type="message.updated", properties={"k": "v"}, raw={"r": 1}
)
async def aclose(self) -> None:
self.closed = True
def _transport(client: _FakeClient) -> OpenCodeHttpTransport:
return OpenCodeHttpTransport(client_factory=lambda: client)
def _launch(**kwargs: Any) -> NativeLaunchConfig:
return NativeLaunchConfig(omnigent_session_id="conv_1", workspace="/w", **kwargs)
async def test_create_session_when_no_external_id() -> None:
client = _FakeClient()
sid = await _transport(client).create_or_resume_session(_launch())
assert sid == "ses_new"
assert client.closed
async def test_resume_returns_existing_session() -> None:
client = _FakeClient()
client.existing = SimpleNamespace(id="ses_old")
sid = await _transport(client).create_or_resume_session(_launch(external_session_id="ses_old"))
assert sid == "ses_old"
async def test_resume_falls_back_to_create_when_session_gone() -> None:
client = _FakeClient() # existing is None
sid = await _transport(client).create_or_resume_session(_launch(external_session_id="gone"))
assert sid == "ses_new"
async def test_send_prompt_builds_payload_and_closes() -> None:
client = _FakeClient()
out = await _transport(client).send_prompt("ses_1", NativePrompt(text="hi"))
assert out == {"ok": True}
assert ("prompt_async", ("ses_1", {"parts": [{"type": "text", "text": "hi"}]})) in client.calls
assert client.closed
async def test_abort() -> None:
client = _FakeClient()
assert await _transport(client).abort("ses_1") is True
async def test_events_maps_to_native_event() -> None:
client = _FakeClient()
events = [event async for event in _transport(client).events("ses_1")]
assert len(events) == 1
assert (events[0].id, events[0].type, events[0].payload) == (
"evt_1",
"message.updated",
{"k": "v"},
)
assert client.closed
async def test_list_history() -> None:
client = _FakeClient()
assert await _transport(client).list_history("ses_1") == [{"info": {"id": "msg_1"}}]
async def test_fork_with_and_without_message_id() -> None:
client = _FakeClient()
transport = _transport(client)
assert await transport.fork("ses_1") == "ses_fork"
assert await transport.fork("ses_1", at_message_id="msg_9") == "ses_fork"
assert ("fork", ("ses_1", {"messageID": "msg_9"})) in client.calls
assert ("fork", ("ses_1", None)) in client.calls
async def test_reply_permission_maps_decision() -> None:
client = _FakeClient()
await _transport(client).reply_permission(
NativePermissionDecision(request_id="per_1", decision="allow_always", message="ok")
)
assert ("reply_permission", ("per_1", {"reply": "always", "message": "ok"})) in client.calls
def test_build_tui_attach_command_uses_launch_server_url() -> None:
transport = OpenCodeHttpTransport(client_factory=lambda: _FakeClient())
argv, env = transport.build_tui_attach_command(
_launch(server_url="http://127.0.0.1:1234", terminal_launch_args=("--foo",)),
"ses_1",
)
assert argv[0] == "attach"
assert "http://127.0.0.1:1234" in argv
assert "ses_1" in argv
assert "--foo" in argv
assert env == {} # no server handle → empty terminal env
async def test_no_connection_coordinates_raises() -> None:
# No factory / server / bridge_dir → the client builder fails loud.
with pytest.raises(RuntimeError):
await OpenCodeHttpTransport().abort("ses_1")
+278
View File
@@ -0,0 +1,278 @@
"""Unit tests for the ``omni opencode`` launcher helpers (``opencode_native.py``).
Covers the pure spec/payload/tmux helpers plus the httpx-backed session and
terminal helpers over a fake ``AsyncClient`` — the daemon/tmux attach plumbing
itself stays for the live host e2e.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import click
import httpx
import pytest
import yaml
from omnigent.opencode_native import (
LaunchedOpenCodeTerminal,
PreparedOpenCodeTerminal,
_create_opencode_session,
_direct_tmux_unavailable_reason,
_ensure_opencode_terminal_on_runner,
_fetch_opencode_session,
_find_running_opencode_terminal,
_launched_opencode_terminal_from_payload,
_materialize_opencode_agent_spec,
_resolve_session_id_for_resume,
opencode_terminal_resource_id,
)
class _FakeClient:
"""Async httpx stand-in returning one preset response per call."""
def __init__(self, response: httpx.Response) -> None:
self._response = response
self.requests: list[tuple[str, str, dict[str, Any]]] = []
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
self.requests.append(("POST", url, kwargs))
return self._response
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
self.requests.append(("GET", url, kwargs))
return self._response
# ── _materialize_opencode_agent_spec ────────────────────────────────────────
def test_materialize_spec_defaults_no_model(tmp_path: Path) -> None:
spec = yaml.safe_load(_materialize_opencode_agent_spec(tmp_path).read_text())
assert spec["executor"] == {"harness": "opencode-native"}
assert spec["spawn"] is True
assert "shell" in spec["terminals"]
def test_materialize_spec_pins_model(tmp_path: Path) -> None:
spec = yaml.safe_load(
_materialize_opencode_agent_spec(tmp_path, model="anthropic/claude-opus-4").read_text()
)
assert spec["executor"] == {"harness": "opencode-native", "model": "anthropic/claude-opus-4"}
def test_terminal_resource_id_is_deterministic() -> None:
assert opencode_terminal_resource_id() == opencode_terminal_resource_id()
# ── _launched_opencode_terminal_from_payload ────────────────────────────────
def test_launched_terminal_parses_tmux_metadata() -> None:
launched = _launched_opencode_terminal_from_payload(
{"id": "term_1", "metadata": {"tmux_socket": "/tmp/s.sock", "tmux_target": "sess:0.0"}}
)
assert launched.terminal_id == "term_1"
assert launched.tmux_socket == Path("/tmp/s.sock")
assert launched.tmux_target == "sess:0.0"
def test_launched_terminal_without_metadata_has_no_tmux() -> None:
launched = _launched_opencode_terminal_from_payload({"id": "term_1"})
assert launched.tmux_socket is None and launched.tmux_target is None
def test_launched_terminal_missing_id_raises() -> None:
with pytest.raises(click.ClickException):
_launched_opencode_terminal_from_payload({"metadata": {}})
with pytest.raises(click.ClickException):
_launched_opencode_terminal_from_payload("not-a-dict")
# ── _direct_tmux_unavailable_reason ─────────────────────────────────────────
def _prepared(socket: Path | None, target: str | None) -> PreparedOpenCodeTerminal:
return PreparedOpenCodeTerminal(
session_id="conv_1",
terminal_id="term_1",
tmux_socket=socket,
tmux_target=target,
reattached=False,
)
def test_tmux_reason_missing_socket() -> None:
assert "tmux socket" in (_direct_tmux_unavailable_reason(_prepared(None, "t")) or "")
def test_tmux_reason_missing_target() -> None:
assert "tmux target" in (_direct_tmux_unavailable_reason(_prepared(Path("/x"), None)) or "")
def test_tmux_reason_socket_not_reachable(tmp_path: Path) -> None:
reason = _direct_tmux_unavailable_reason(_prepared(tmp_path / "missing.sock", "t"))
assert reason is not None and "not reachable" in reason
# ── _resolve_session_id_for_resume ──────────────────────────────────────────
def test_resolve_session_id_passthrough() -> None:
assert (
_resolve_session_id_for_resume(
base_url="http://x", headers={}, session_id="conv_9", resume_picker=False
)
== "conv_9"
)
def test_resolve_session_id_none_without_picker() -> None:
assert (
_resolve_session_id_for_resume(
base_url="http://x", headers={}, session_id=None, resume_picker=False
)
is None
)
# ── httpx-backed session/terminal helpers ───────────────────────────────────
async def test_create_session_returns_id() -> None:
client = _FakeClient(httpx.Response(200, json={"session_id": "conv_new"}))
sid = await _create_opencode_session(client, b"bundle", terminal_launch_args=["--foo"]) # type: ignore[arg-type]
assert sid == "conv_new"
assert client.requests[0][1] == "/v1/sessions"
async def test_create_session_errors_on_http_failure() -> None:
client = _FakeClient(httpx.Response(500, json={"error": "boom"}))
with pytest.raises(click.ClickException):
await _create_opencode_session(client, b"bundle") # type: ignore[arg-type]
async def test_create_session_errors_without_session_id() -> None:
client = _FakeClient(httpx.Response(200, json={}))
with pytest.raises(click.ClickException):
await _create_opencode_session(client, b"bundle") # type: ignore[arg-type]
async def test_fetch_session_returns_payload() -> None:
client = _FakeClient(httpx.Response(200, json={"id": "conv_1", "title": "t"}))
assert (await _fetch_opencode_session(client, "conv_1"))["id"] == "conv_1" # type: ignore[arg-type]
async def test_fetch_session_404_raises() -> None:
client = _FakeClient(httpx.Response(404, json={"error": "nope"}))
with pytest.raises(click.ClickException):
await _fetch_opencode_session(client, "conv_1") # type: ignore[arg-type]
async def test_ensure_terminal_ok_then_error() -> None:
ok = _FakeClient(httpx.Response(200, json={}))
await _ensure_opencode_terminal_on_runner(ok, "conv_1") # type: ignore[arg-type]
assert ok.requests[0][0] == "POST"
bad = _FakeClient(httpx.Response(503, json={"error": "x"}))
with pytest.raises(click.ClickException):
await _ensure_opencode_terminal_on_runner(bad, "conv_1") # type: ignore[arg-type]
async def test_find_terminal_404_returns_none() -> None:
client = _FakeClient(httpx.Response(404, json={}))
assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type]
async def test_find_terminal_not_running_returns_none() -> None:
client = _FakeClient(
httpx.Response(200, json={"id": "term_1", "metadata": {"running": False}})
)
assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type]
async def test_find_terminal_returns_launched() -> None:
client = _FakeClient(
httpx.Response(200, json={"id": "term_1", "metadata": {"tmux_target": "s:0.0"}})
)
launched = await _find_running_opencode_terminal(client, "conv_1") # type: ignore[arg-type]
assert isinstance(launched, LaunchedOpenCodeTerminal)
assert launched.tmux_target == "s:0.0"
async def test_find_terminal_offline_runner_returns_none() -> None:
client = _FakeClient(httpx.Response(409, text="session not bound to a runner"))
assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type]
# ── launcher local-preflight / progress / tmux-reason / wait helpers ─────────
def test_preflight_local_tools_ok(monkeypatch: pytest.MonkeyPatch) -> None:
import omnigent.opencode_native as on
monkeypatch.setattr(on.shutil, "which", lambda _x: "/usr/bin/tmux")
on._preflight_local_tools() # tmux present → no raise
def test_preflight_local_tools_missing_tmux_raises(monkeypatch: pytest.MonkeyPatch) -> None:
import omnigent.opencode_native as on
monkeypatch.setattr(on.shutil, "which", lambda _x: None)
with pytest.raises(click.ClickException):
on._preflight_local_tools()
def test_update_startup_progress_handles_none_and_active() -> None:
from unittest.mock import Mock
from omnigent.opencode_native import _update_startup_progress
_update_startup_progress(None, "boot") # no renderer → no-op branch
_update_startup_progress(Mock(), "boot") # active renderer → update branch
def test_tmux_reason_tmux_not_on_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
import omnigent.opencode_native as on
sock = tmp_path / "s.sock"
sock.write_text("")
monkeypatch.setattr(on.shutil, "which", lambda _x: None)
reason = on._direct_tmux_unavailable_reason(_prepared(sock, "t"))
assert reason is not None and "tmux is not available" in reason
def test_tmux_reason_none_when_socket_and_tmux_present(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import omnigent.opencode_native as on
sock = tmp_path / "s.sock"
sock.write_text("")
monkeypatch.setattr(on.shutil, "which", lambda _x: "/usr/bin/tmux")
assert on._direct_tmux_unavailable_reason(_prepared(sock, "t")) is None
async def test_wait_for_terminal_returns_when_found(monkeypatch: pytest.MonkeyPatch) -> None:
import omnigent.opencode_native as on
term = on.LaunchedOpenCodeTerminal(terminal_id="t", tmux_socket=None, tmux_target=None)
async def _fake_find(_client: object, _sid: str) -> on.LaunchedOpenCodeTerminal:
return term
monkeypatch.setattr(on, "_find_running_opencode_terminal", _fake_find)
got = await on._wait_for_opencode_terminal_ready(object(), "conv_1", timeout_s=5) # type: ignore[arg-type]
assert got is term
async def test_wait_for_terminal_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
import omnigent.opencode_native as on
async def _never(_client: object, _sid: str) -> None:
return None
monkeypatch.setattr(on, "_find_running_opencode_terminal", _never)
with pytest.raises(click.ClickException):
await on._wait_for_opencode_terminal_ready(object(), "conv_1", timeout_s=0) # type: ignore[arg-type]
+221
View File
@@ -0,0 +1,221 @@
"""Tests for the opencode serve process manager + arg/env builders."""
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent import opencode_native_app_server as appsrv
from omnigent.opencode_native_app_server import (
OpenCodeCliNotFoundError,
OpenCodeNativeServer,
OpenCodeVersionError,
build_opencode_attach_args,
build_opencode_serve_args,
check_opencode_version,
filtered_server_env,
find_opencode_cli,
opencode_terminal_env,
parse_opencode_version,
)
def test_parse_opencode_version() -> None:
assert parse_opencode_version("opencode 1.17.7") == "1.17.7"
assert parse_opencode_version("1.17.7") == "1.17.7"
assert parse_opencode_version("v1.17.7-beta.1") == "1.17.7-beta.1"
assert parse_opencode_version("no version here") is None
def test_check_version_in_range() -> None:
check_opencode_version("1.17.7")
check_opencode_version("1.17.99")
@pytest.mark.parametrize("version", ["1.16.0", "1.18.0", "2.0.0"])
def test_check_version_out_of_range_raises(version: str) -> None:
with pytest.raises(OpenCodeVersionError):
check_opencode_version(version)
def test_check_version_unparsable_raises() -> None:
with pytest.raises(OpenCodeVersionError):
check_opencode_version("not-a-version")
def test_find_opencode_cli_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(appsrv.shutil, "which", lambda _name: None)
with pytest.raises(OpenCodeCliNotFoundError):
find_opencode_cli()
def test_find_opencode_cli_resolved(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(appsrv.shutil, "which", lambda name: f"/usr/bin/{name}")
assert find_opencode_cli() == "/usr/bin/opencode"
def test_build_serve_args_has_explicit_host_port() -> None:
args = build_opencode_serve_args(hostname="127.0.0.1", port=49231)
assert args == ["serve", "--hostname", "127.0.0.1", "--port", "49231"]
def test_build_attach_args() -> None:
args = build_opencode_attach_args(
server_url="http://127.0.0.1:49231",
workspace="/repo",
session_id="ses_1",
)
assert args == [
"attach",
"http://127.0.0.1:49231",
"--dir",
"/repo",
"--session",
"ses_1",
]
def test_build_attach_args_without_session() -> None:
args = build_opencode_attach_args(
server_url="http://127.0.0.1:49231",
workspace="/repo",
session_id=None,
opencode_args=("--extra",),
)
assert "--session" not in args
assert args[-1] == "--extra"
def test_filtered_server_env_sets_xdg_and_password(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "secret-key")
monkeypatch.setenv("RANDOM_UNRELATED", "nope")
env = filtered_server_env(bridge_dir=tmp_path, auth_secret="pw")
assert env["XDG_DATA_HOME"] == str(tmp_path / "xdg-data")
assert env["XDG_CONFIG_HOME"] == str(tmp_path / "xdg-config")
assert env["OPENCODE_SERVER_PASSWORD"] == "pw"
assert env["OPENCODE_SERVER_USERNAME"] == "opencode"
assert env["ANTHROPIC_API_KEY"] == "secret-key" # provider env passes through
assert "RANDOM_UNRELATED" not in env # unrelated env filtered out
def test_filtered_server_env_drops_global_opencode_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Global OpenCode config env never leaks into the isolated session.
``OPENCODE_CONFIG`` / ``OPENCODE_CONFIG_CONTENT`` would re-introduce the
parent shell's config/model/permission settings, defeating the
per-session XDG isolation — so they are dropped even though they match
the ``OPENCODE_`` passthrough prefix. Other ``OPENCODE_`` vars (and the
server password we set) are unaffected.
"""
monkeypatch.setenv("OPENCODE_CONFIG", "/home/user/.config/opencode/opencode.json")
monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", '{"model": "evil/model"}')
monkeypatch.setenv("OPENCODE_DISABLE_AUTOUPDATE", "1")
env = filtered_server_env(bridge_dir=tmp_path, auth_secret="pw")
assert "OPENCODE_CONFIG" not in env
assert "OPENCODE_CONFIG_CONTENT" not in env
# An unrelated OPENCODE_ var is still passed through (not config leakage).
assert env["OPENCODE_DISABLE_AUTOUPDATE"] == "1"
# The per-session XDG dirs remain the only config source.
assert env["XDG_CONFIG_HOME"] == str(tmp_path / "xdg-config")
def _server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> OpenCodeNativeServer:
monkeypatch.setattr(appsrv.shutil, "which", lambda name: f"/usr/bin/{name}")
return OpenCodeNativeServer(
bridge_dir=tmp_path,
workspace=tmp_path,
port=49231,
verify_version=False,
)
def test_build_argv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
server = _server(monkeypatch, tmp_path)
server.port = 49231
argv = server.build_argv()
assert argv[0] == "/usr/bin/opencode"
assert argv[1:] == ["serve", "--hostname", "127.0.0.1", "--port", "49231"]
def test_base_url_and_auth_headers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
server = _server(monkeypatch, tmp_path)
assert server.base_url == "http://127.0.0.1:49231"
assert server.auth_headers["Authorization"].startswith("Basic ")
def test_terminal_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
server = _server(monkeypatch, tmp_path)
env = opencode_terminal_env(server)
assert env["OPENCODE_SERVER_PASSWORD"] == server.auth_secret
assert env["XDG_DATA_HOME"] == str(server.xdg_data_home)
async def test_start_polls_until_ready(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
server = _server(monkeypatch, tmp_path)
started: dict[str, object] = {}
class _FakeProc:
pid = 4242
def poll(self) -> None:
return None
def fake_popen(argv, **kwargs): # type: ignore[no-untyped-def]
started["argv"] = argv
started["env"] = kwargs.get("env")
return _FakeProc()
async def fake_wait(self: OpenCodeNativeServer) -> None:
started["ready"] = True
monkeypatch.setattr(appsrv.subprocess, "Popen", fake_popen)
monkeypatch.setattr(OpenCodeNativeServer, "_wait_until_ready", fake_wait)
await server.start()
assert started["ready"] is True
assert started["argv"][1] == "serve"
assert server.process is not None
assert server.process.pid == 4242
def test_find_opencode_cli_absolute_executable(tmp_path: Path) -> None:
exe = tmp_path / "opencode"
exe.write_text("#!/bin/sh\n")
exe.chmod(0o755)
assert appsrv.find_opencode_cli(str(exe)) == str(exe)
def test_resolve_opencode_version_parses(monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess
monkeypatch.setattr(
appsrv.subprocess,
"run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="opencode 1.17.7\n", stderr=""),
)
assert appsrv.resolve_opencode_version("/x/opencode") == "1.17.7"
def test_resolve_opencode_version_run_error_raises(monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(*_a: object, **_k: object) -> object:
raise OSError("cannot exec")
monkeypatch.setattr(appsrv.subprocess, "run", _boom)
with pytest.raises(appsrv.OpenCodeVersionError):
appsrv.resolve_opencode_version("/x/opencode")
def test_resolve_opencode_version_unparseable_raises(monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess
monkeypatch.setattr(
appsrv.subprocess,
"run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="no version here", stderr=""),
)
with pytest.raises(appsrv.OpenCodeVersionError):
appsrv.resolve_opencode_version("/x/opencode")
+174
View File
@@ -0,0 +1,174 @@
"""Tests for the native OpenCode bridge state helpers."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from omnigent import opencode_native_bridge as bridge
from omnigent.opencode_native_bridge import (
OpenCodeNativeBridgeState,
auth_headers_for_secret,
bridge_dir_for_bridge_id,
build_opencode_native_spawn_env,
clear_bridge_state,
ensure_auth_secret,
prepare_bridge_dir,
read_bridge_state,
update_active_message_id,
update_last_event_id,
write_bridge_state,
xdg_config_home_for_bridge_dir,
xdg_data_home_for_bridge_dir,
)
@pytest.fixture
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Isolated bridge directory rooted under a tmp path."""
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
return prepare_bridge_dir("bridge_test")
def _state(bridge_dir: Path, **overrides: object) -> OpenCodeNativeBridgeState:
base = {
"session_id": "conv_abc",
"server_base_url": "http://127.0.0.1:49231",
"opencode_session_id": "ses_abc",
"auth_secret": "s3cret",
"xdg_data_home": str(xdg_data_home_for_bridge_dir(bridge_dir)),
"xdg_config_home": str(xdg_config_home_for_bridge_dir(bridge_dir)),
}
base.update(overrides)
return OpenCodeNativeBridgeState(**base) # type: ignore[arg-type]
def test_prepare_bridge_dir_creates_xdg_roots(bridge_dir: Path) -> None:
assert bridge_dir.is_dir()
assert xdg_data_home_for_bridge_dir(bridge_dir).is_dir()
assert xdg_config_home_for_bridge_dir(bridge_dir).is_dir()
# 0700 perms on the bridge dir.
assert (os.stat(bridge_dir).st_mode & 0o777) == 0o700
def test_write_read_state_round_trips(bridge_dir: Path) -> None:
write_bridge_state(bridge_dir, _state(bridge_dir, model_override="anthropic/claude-opus-4"))
loaded = read_bridge_state(bridge_dir)
assert loaded is not None
assert loaded.session_id == "conv_abc"
assert loaded.opencode_session_id == "ses_abc"
assert loaded.server_base_url == "http://127.0.0.1:49231"
assert loaded.auth_secret == "s3cret"
assert loaded.model_override == "anthropic/claude-opus-4"
assert loaded.status == "idle"
def test_read_missing_state_is_none(bridge_dir: Path) -> None:
assert read_bridge_state(bridge_dir) is None
def test_read_corrupt_state_is_none(bridge_dir: Path) -> None:
(bridge_dir / "state.json").write_text("{not json", encoding="utf-8")
assert read_bridge_state(bridge_dir) is None
def test_read_incomplete_state_is_none(bridge_dir: Path) -> None:
(bridge_dir / "state.json").write_text(json.dumps({"session_id": "x"}), encoding="utf-8")
assert read_bridge_state(bridge_dir) is None
def test_clear_state_removes_file(bridge_dir: Path) -> None:
write_bridge_state(bridge_dir, _state(bridge_dir))
clear_bridge_state(bridge_dir)
assert read_bridge_state(bridge_dir) is None
# Idempotent.
clear_bridge_state(bridge_dir)
def test_update_active_message_id(bridge_dir: Path) -> None:
write_bridge_state(bridge_dir, _state(bridge_dir))
update_active_message_id(bridge_dir, "msg_1", status="busy")
loaded = read_bridge_state(bridge_dir)
assert loaded is not None
assert loaded.active_message_id == "msg_1"
assert loaded.status == "busy"
update_active_message_id(bridge_dir, None, status="idle")
loaded = read_bridge_state(bridge_dir)
assert loaded is not None
assert loaded.active_message_id is None
assert loaded.status == "idle"
def test_update_last_event_id(bridge_dir: Path) -> None:
write_bridge_state(bridge_dir, _state(bridge_dir))
update_last_event_id(bridge_dir, "evt_42")
loaded = read_bridge_state(bridge_dir)
assert loaded is not None
assert loaded.last_event_id == "evt_42"
def test_ensure_auth_secret_is_stable_and_0600(bridge_dir: Path) -> None:
secret = ensure_auth_secret(bridge_dir)
assert secret
# Same secret on a second call (reused across server restarts).
assert ensure_auth_secret(bridge_dir) == secret
path = bridge_dir / "auth.secret"
assert (os.stat(path).st_mode & 0o777) == 0o600
def test_auth_headers_for_secret() -> None:
assert auth_headers_for_secret(None) == {}
headers = auth_headers_for_secret("pw")
assert headers["Authorization"].startswith("Basic ")
import base64
decoded = base64.b64decode(headers["Authorization"].split(" ", 1)[1]).decode()
assert decoded == "opencode:pw"
def test_state_auth_headers_method(bridge_dir: Path) -> None:
state = _state(bridge_dir, auth_secret="pw")
assert state.auth_headers()["Authorization"].startswith("Basic ")
def test_spawn_env_points_at_bridge_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
env = build_opencode_native_spawn_env("conv_abc")
assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("conv_abc"))
assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc"
def test_spawn_env_bridge_id_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
env = build_opencode_native_spawn_env("conv_abc", bridge_id="bridge_xyz")
assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("bridge_xyz"))
assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc"
def test_seed_opencode_auth_copies_user_auth(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The user's auth.json is copied into the per-session XDG_DATA_HOME (0600)."""
user_data = tmp_path / "user-share"
(user_data / "opencode").mkdir(parents=True)
(user_data / "opencode" / "auth.json").write_text('{"anthropic": {"type": "api"}}')
monkeypatch.setenv("XDG_DATA_HOME", str(user_data))
bridge_dir = bridge.prepare_bridge_dir("conv_seed")
dest = bridge.seed_opencode_auth(bridge_dir)
assert dest is not None and dest.is_file()
assert dest == bridge.xdg_data_home_for_bridge_dir(bridge_dir) / "opencode" / "auth.json"
assert "anthropic" in dest.read_text()
assert (os.stat(dest).st_mode & 0o777) == 0o600
def test_seed_opencode_auth_noop_without_source(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""No user auth.json → no-op (returns None), e.g. on a remote runner."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "empty-share"))
bridge_dir = bridge.prepare_bridge_dir("conv_noseed")
assert bridge.seed_opencode_auth(bridge_dir) is None
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the OpenCode HTTP + SSE client against a fake server."""
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from omnigent.opencode_native_client import (
OpenCodeClient,
OpenCodeClientError,
OpenCodeEvent,
OpenCodeSession,
)
Handler = Callable[[httpx.Request], httpx.Response]
def _client(handler: Handler, **kwargs: object) -> OpenCodeClient:
mock = httpx.AsyncClient(
base_url="http://opencode.test",
transport=httpx.MockTransport(handler),
)
return OpenCodeClient("http://opencode.test", client=mock, **kwargs) # type: ignore[arg-type]
async def test_create_session() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/session"
return httpx.Response(200, json={"id": "ses_1", "title": "t"})
client = _client(handler)
session = await client.create_session({"title": "t"})
assert isinstance(session, OpenCodeSession)
assert session.id == "ses_1"
assert session.title == "t"
await client.aclose()
async def test_get_session_404_returns_none() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"error": "not found"})
client = _client(handler)
assert await client.get_session("ses_missing") is None
await client.aclose()
async def test_get_session_found() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "ses_1", "parentID": "ses_0"})
client = _client(handler)
session = await client.get_session("ses_1")
assert session is not None
assert session.parent_id == "ses_0"
await client.aclose()
async def test_list_messages() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"info": {"id": "msg_1"}, "parts": []}])
client = _client(handler)
messages = await client.list_messages("ses_1")
assert messages == [{"info": {"id": "msg_1"}, "parts": []}]
await client.aclose()
async def test_prompt_async_posts_parts() -> None:
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/session/ses_1/prompt_async"
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={})
client = _client(handler)
await client.prompt_async("ses_1", {"parts": [{"type": "text", "text": "hi"}]})
assert captured["body"] == {"parts": [{"type": "text", "text": "hi"}]}
await client.aclose()
async def test_abort_returns_bool() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/session/ses_1/abort"
return httpx.Response(200, json=True)
client = _client(handler)
assert await client.abort("ses_1") is True
await client.aclose()
async def test_fork() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/session/ses_1/fork"
return httpx.Response(200, json={"id": "ses_2", "parentID": "ses_1"})
client = _client(handler)
forked = await client.fork("ses_1", {"messageID": "msg_1"})
assert forked.id == "ses_2"
await client.aclose()
async def test_reply_permission() -> None:
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/permission/per_1/reply"
captured["body"] = json.loads(request.content)
return httpx.Response(200, json={})
client = _client(handler)
assert await client.reply_permission("per_1", {"reply": "once"}) is True
assert captured["body"] == {"reply": "once"}
await client.aclose()
async def test_list_permissions() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"id": "per_1", "action": "bash"}])
client = _client(handler)
perms = await client.list_permissions()
assert perms == [{"id": "per_1", "action": "bash"}]
await client.aclose()
async def test_error_raises() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"error": "boom"})
client = _client(handler)
with pytest.raises(OpenCodeClientError):
await client.create_session()
await client.aclose()
async def test_auth_and_directory_headers_applied() -> None:
captured: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["auth"] = request.headers.get("authorization", "")
captured["dir"] = request.headers.get("x-opencode-directory", "")
return httpx.Response(200, json=[])
client = _client(handler, headers={"Authorization": "Basic abc"}, directory="/repo")
await client.list_messages("ses_1")
assert captured["auth"] == "Basic abc"
assert captured["dir"] == "/repo"
await client.aclose()
async def test_events_parses_sse_stream() -> None:
sse_body = (
"event: message\n"
'data: {"type": "session.next.text.delta", '
'"properties": {"sessionID": "ses_1", "delta": "hel"}}\n'
"\n"
"id: evt_2\n"
'data: {"type": "session.next.text.ended", '
'"properties": {"sessionID": "ses_1", "text": "hello"}}\n'
"\n"
": heartbeat comment\n"
"\n"
)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/event"
return httpx.Response(200, text=sse_body, headers={"content-type": "text/event-stream"})
client = _client(handler)
events: list[OpenCodeEvent] = []
async for event in client.events():
events.append(event)
assert [e.type for e in events] == [
"session.next.text.delta",
"session.next.text.ended",
]
assert events[0].properties["delta"] == "hel"
assert events[1].id == "evt_2"
await client.aclose()
async def test_events_skips_non_json_data() -> None:
sse_body = 'data: not-json\n\ndata: {"type": "x", "properties": {}}\n\n'
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=sse_body)
client = _client(handler)
events = [e async for e in client.events()]
assert [e.type for e in events] == ["x"]
await client.aclose()
async def test_create_session_non_object_body_raises() -> None:
client = _client(lambda _r: httpx.Response(200, json=["not", "an", "object"]))
with pytest.raises(OpenCodeClientError):
await client.create_session()
await client.aclose()
async def test_get_session_server_error_raises() -> None:
client = _client(lambda _r: httpx.Response(500, json={"error": "boom"}))
with pytest.raises(OpenCodeClientError):
await client.get_session("ses_1")
await client.aclose()
async def test_get_session_non_object_returns_none() -> None:
client = _client(lambda _r: httpx.Response(200, json=["x"]))
assert await client.get_session("ses_1") is None
await client.aclose()
async def test_list_messages_non_list_returns_empty() -> None:
client = _client(lambda _r: httpx.Response(200, json={"not": "a list"}))
assert await client.list_messages("ses_1") == []
await client.aclose()
async def test_get_message_non_dict_returns_empty() -> None:
client = _client(lambda _r: httpx.Response(200, json=[1, 2]))
assert await client.get_message("ses_1", "msg_1") == {}
await client.aclose()
async def test_prompt_non_dict_returns_empty() -> None:
client = _client(lambda _r: httpx.Response(200, json=[1]))
assert await client.prompt("ses_1", {"parts": []}) == {}
await client.aclose()
async def test_fork_non_object_body_raises() -> None:
client = _client(lambda _r: httpx.Response(200, json="nope"))
with pytest.raises(OpenCodeClientError):
await client.fork("ses_1")
await client.aclose()
async def test_request_json_http_error_raises() -> None:
client = _client(lambda _r: httpx.Response(503, json={"error": "down"}))
with pytest.raises(OpenCodeClientError):
await client.list_messages("ses_1")
await client.aclose()
+423
View File
@@ -0,0 +1,423 @@
"""Tests for the OpenCode SSE -> Omnigent event forwarder translation."""
from __future__ import annotations
from typing import Any
import httpx
import omnigent.opencode_native_forwarder as fwd_mod
from omnigent.opencode_native_client import OpenCodeEvent
_SESSION = "ses_1"
class _RecordingServerClient:
"""httpx-shaped stub recording Omnigent event POSTs."""
def __init__(self) -> None:
self.posts: list[tuple[str, dict[str, Any]]] = []
async def post(self, url: str, *, json: dict[str, Any]) -> httpx.Response:
self.posts.append((url, json))
return httpx.Response(200, request=httpx.Request("POST", url))
class _FakeOpenCodeClient:
"""Fake OpenCode client recording permission replies + history."""
def __init__(self) -> None:
self.replies: list[tuple[str, dict[str, Any]]] = []
self.messages: list[dict[str, Any]] = []
async def list_messages(self, session_id: str) -> list[dict[str, Any]]:
return self.messages
async def reply_permission(self, request_id: str, reply: dict[str, Any]) -> bool:
self.replies.append((request_id, reply))
return True
def _forwarder(
server: _RecordingServerClient,
opencode: _FakeOpenCodeClient,
**kwargs: Any,
) -> fwd_mod.OpenCodeNativeForwarder:
return fwd_mod.OpenCodeNativeForwarder(
session_id="conv_1",
opencode_session_id=_SESSION,
opencode_client=opencode, # type: ignore[arg-type]
server_client=server, # type: ignore[arg-type]
**kwargs,
)
def _event(event_type: str, **props: Any) -> OpenCodeEvent:
props.setdefault("sessionID", _SESSION)
return OpenCodeEvent(id=None, type=event_type, properties=props, raw={})
def _types(posts: list[tuple[str, dict[str, Any]]]) -> list[str]:
return [body["type"] for _url, body in posts]
async def test_part_delta_is_not_forwarded() -> None:
"""Live token deltas are intentionally dropped (see the _HANDLERS note).
The web chat view reconciles live ``text_delta`` previews with the
committed item via a finalize/retire handshake; emitting deltas without it
duplicated/garbled the chat. The forwarder posts only the durable item.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(
_event(
"message.part.delta", field="text", partID="prt_1", messageID="msg_1", delta="hello"
)
)
assert "external_output_text_delta" not in _types(server.posts)
async def test_assistant_text_part_finalized_on_idle_and_dedupes() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
# The role lives on the message; the text on a text part of that message.
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={"id": "prt_1", "messageID": "msg_1", "type": "text", "text": "full answer"},
)
)
await fwd.handle_event(_event("session.idle"))
await fwd.handle_event(_event("session.idle")) # duplicate flush must not re-post
items = [b for _u, b in server.posts if b["type"] == "external_conversation_item"]
assert len(items) == 1
assert items[0]["data"]["item_type"] == "message"
assert items[0]["data"]["item_data"]["role"] == "assistant"
assert items[0]["data"]["item_data"]["content"][0]["text"] == "full answer"
# The item groups under its assistant messageID (per-turn response), NOT a
# constant session id — that constant id was what clustered every turn's
# assistant items together and broke chat ordering.
assert items[0]["data"]["response_id"] == "msg_1"
async def test_each_assistant_message_gets_its_own_response_id() -> None:
"""Distinct assistant messages map to distinct per-turn response groups."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
for msg in ("msg_a", "msg_b"):
await fwd.handle_event(_event("message.updated", info={"id": msg, "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={"id": f"prt_{msg}", "messageID": msg, "type": "text", "text": f"t-{msg}"},
)
)
await fwd.handle_event(_event("session.idle"))
items = [b for _u, b in server.posts if b["type"] == "external_conversation_item"]
response_ids = [it["data"]["response_id"] for it in items]
assert response_ids == ["msg_a", "msg_b"], "each turn must get its own response_id"
async def test_user_text_part_is_mirrored_before_the_assistant() -> None:
"""The forwarder is the transcript source: it posts the user message too.
For native-server harnesses omnigent persists no separate user item, so the
forwarder must mirror the user message (role=user) — posted eagerly so it
precedes its assistant reply (correct chat ordering). Deduped by part id.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_u", "role": "user"}))
user_part = _event(
"message.part.updated",
part={"id": "prt_u", "messageID": "msg_u", "type": "text", "text": "my prompt"},
)
await fwd.handle_event(user_part)
await fwd.handle_event(user_part) # snapshot repeat must not double-post
# Then the assistant reply for the same turn.
await fwd.handle_event(_event("message.updated", info={"id": "msg_a", "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={"id": "prt_a", "messageID": "msg_a", "type": "text", "text": "hello"},
)
)
await fwd.handle_event(_event("session.idle"))
items = [b["data"] for _u, b in server.posts if b["type"] == "external_conversation_item"]
roles = [it["item_data"]["role"] for it in items if it["item_type"] == "message"]
assert roles == ["user", "assistant"], f"expected user before assistant, got {roles}"
user_item = next(it for it in items if it["item_data"]["role"] == "user")
assert user_item["item_data"]["content"][0]["text"] == "my prompt"
assert user_item["response_id"] == "msg_u"
async def test_tool_part_posts_function_call_and_output() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={
"id": "prt_t",
"messageID": "msg_1",
"type": "tool",
"callID": "call_1",
"tool": "bash",
"state": {
"status": "completed",
"input": {"command": "ls"},
"output": "file1\nfile2",
},
},
)
)
call = next(b for _u, b in server.posts if b["data"].get("item_type") == "function_call")
assert call["data"]["item_data"]["name"] == "bash"
assert call["data"]["item_data"]["call_id"] == "call_1"
assert '"command": "ls"' in call["data"]["item_data"]["arguments"]
assert call["data"]["response_id"] == "msg_1"
out = next(b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output")
assert out["data"]["item_data"]["call_id"] == "call_1"
assert out["data"]["item_data"]["output"] == "file1\nfile2"
assert out["data"]["response_id"] == "msg_1"
async def test_tool_part_dedupes_call_and_output_across_snapshots() -> None:
"""The same tool part as running then completed posts the call/output once each."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
base = {"id": "prt_t", "messageID": "msg_1", "type": "tool", "callID": "c1", "tool": "bash"}
running = {"status": "running", "input": {"command": "ls"}}
completed = {"status": "completed", "input": {"command": "ls"}, "output": "ok"}
await fwd.handle_event(_event("message.part.updated", part={**base, "state": running}))
await fwd.handle_event(_event("message.part.updated", part={**base, "state": completed}))
calls = [b for _u, b in server.posts if b["data"].get("item_type") == "function_call"]
outs = [b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output"]
assert len(calls) == 1
assert len(outs) == 1
async def test_tool_part_error_posts_error_output() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={
"id": "prt_e",
"messageID": "msg_1",
"type": "tool",
"callID": "call_2",
"tool": "bash",
"state": {"status": "error", "input": {"command": "x"}, "error": "boom"},
},
)
)
item = next(
b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output"
)
assert "boom" in item["data"]["item_data"]["output"]
async def test_lifecycle_emits_running_then_idle() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(_event("session.idle"))
statuses = [
b["data"]["status"] for _u, b in server.posts if b["type"] == "external_session_status"
]
assert statuses == ["running", "idle"]
async def test_permission_asked_rejects_when_no_policy_wired() -> None:
"""Absent a policy evaluator the forwarder FAILS CLOSED (no auto-approve).
The security contract: a headless OpenCode turn must never silently
auto-approve a sensitive op just because no policy gate is wired. The
previous ``allow_once`` default did exactly that.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode) # no policy_evaluator → fail closed
await fwd.handle_event(
_event("permission.v2.asked", id="per_1", action="bash", resources=[{"command": "ls"}])
)
assert opencode.replies == [("per_1", {"reply": "reject", "message": "omnigent-policy"})]
async def test_permission_asked_rejects_when_policy_denies() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
async def deny(_normalized: Any) -> dict[str, Any]:
return {"decision": "deny"}
fwd = _forwarder(server, opencode, policy_evaluator=deny)
await fwd.handle_event(_event("permission.v2.asked", id="per_2", action="bash"))
assert opencode.replies[0][1]["reply"] == "reject"
async def test_permission_asked_allows_only_on_explicit_policy_allow() -> None:
"""An explicit policy ``allow`` is the only path to ``once``."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
async def allow(_normalized: Any) -> dict[str, Any]:
return {"decision": "allow"}
fwd = _forwarder(server, opencode, policy_evaluator=allow)
await fwd.handle_event(_event("permission.v2.asked", id="per_a", action="bash"))
assert opencode.replies[0][1]["reply"] == "once"
async def test_permission_asked_allow_always_maps_to_always() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
async def allow_always(_normalized: Any) -> dict[str, Any]:
return {"decision": "allow_always"}
fwd = _forwarder(server, opencode, policy_evaluator=allow_always)
await fwd.handle_event(_event("permission.v2.asked", id="per_aa", action="bash"))
assert opencode.replies[0][1]["reply"] == "always"
async def test_permission_asked_rejects_when_policy_returns_ask() -> None:
"""An unresolved ``ask`` reaching the forwarder FAILS CLOSED, not auto-approve.
The genuine human approval for an ``ask`` is resolved UPSTREAM by the
policy evaluator (the server parks an approval card on
``/policies/evaluate`` and returns a hard allow/deny). An ``ask`` that
still reaches the forwarder means no human resolution was obtained, so
it must DENY — never silently approve.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
async def ask(_normalized: Any) -> dict[str, Any]:
return {"decision": "ask"}
fwd = _forwarder(server, opencode, policy_evaluator=ask)
await fwd.handle_event(_event("permission.v2.asked", id="per_ask", action="bash"))
assert opencode.replies[0][1]["reply"] == "reject"
async def test_permission_asked_passes_normalized_input_to_evaluator() -> None:
"""The forwarder routes through the policy gate with a normalized input.
Proves the request is genuinely evaluated (harness + action + the
concrete command), not decided by a hardcoded default.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
seen: list[Any] = []
async def capture(normalized: Any) -> dict[str, Any]:
seen.append(normalized)
return {"decision": "deny"}
fwd = _forwarder(server, opencode, policy_evaluator=capture, workspace="/work/repo")
await fwd.handle_event(
_event("permission.v2.asked", id="per_n", action="bash", resources=[{"command": "ls"}])
)
assert len(seen) == 1
assert seen[0]["harness"] == "opencode-native"
assert seen[0]["action"] == "bash"
assert seen[0]["command"] == "ls"
assert seen[0]["working_directory"] == "/work/repo"
assert seen[0]["omnigent_session_id"] == "conv_1"
async def test_permission_asked_dedupes() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
ev = _event("permission.v2.asked", id="per_3", action="bash")
await fwd.handle_event(ev)
await fwd.handle_event(ev)
assert len(opencode.replies) == 1
async def test_event_for_other_session_ignored() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(
OpenCodeEvent(
id=None,
type="message.part.updated",
properties={
"sessionID": "ses_OTHER",
"part": {"id": "p", "messageID": "m", "type": "text", "text": "x"},
},
raw={},
)
)
assert server.posts == []
async def test_unknown_event_is_ignored() -> None:
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("some.unknown.event", foo="bar"))
assert server.posts == []
async def test_run_reconnects_until_cap() -> None:
"""run() retries the SSE consume loop and stops at the reconnect cap."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
calls = {"n": 0}
async def failing_consume() -> None:
calls["n"] += 1
raise httpx.ReadError("dropped", request=httpx.Request("GET", "http://x/event"))
fwd._consume_once = failing_consume # type: ignore[method-assign]
# Patch sleep so the backoff doesn't slow the test.
async def _no_sleep(_seconds: float) -> None:
return None
orig_sleep = fwd_mod.asyncio.sleep
fwd_mod.asyncio.sleep = _no_sleep # type: ignore[assignment]
try:
await fwd.run(max_reconnects=3)
finally:
fwd_mod.asyncio.sleep = orig_sleep # type: ignore[assignment]
assert calls["n"] == 4 # initial + 3 reconnects
async def test_seed_dedupe_from_history_marks_parts_and_roles() -> None:
"""Resume seeding records message roles and pre-marks text/tool part keys."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
opencode.messages = [
{
"info": {"id": "msg_1", "role": "assistant"},
"parts": [
{"id": "prt_text", "type": "text"},
{"id": "prt_tool", "type": "tool", "callID": "call_1"},
"not-a-mapping",
],
},
{"info": {"id": "msg_2", "role": "user"}, "parts": []},
"not-a-mapping-message",
]
fwd = _forwarder(server, opencode)
await fwd.seed_dedupe_from_history()
assert fwd._msg_role == {"msg_1": "assistant", "msg_2": "user"}
# Seeded keys are pre-marked, so re-marking returns False (would be deduped).
assert fwd.state.mark(fwd._key("text-final", "prt_text")) is False
assert fwd.state.mark(fwd._key("tool-call", "call_1")) is False
async def test_seed_dedupe_from_history_swallows_errors() -> None:
"""A history-fetch failure leaves the dedupe empty rather than raising."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
async def _boom(_sid: str) -> list[dict[str, Any]]:
raise RuntimeError("history unavailable")
opencode.list_messages = _boom # type: ignore[assignment]
fwd = _forwarder(server, opencode)
await fwd.seed_dedupe_from_history() # best-effort → no raise
assert fwd._msg_role == {}
+94
View File
@@ -0,0 +1,94 @@
"""Tests for OpenCode permission normalization + policy/approval mapping."""
from __future__ import annotations
from omnigent.opencode_native_permissions import (
OPENCODE_NATIVE_HARNESS,
decision_to_reply,
map_verdict_to_decision,
normalize_for_policy,
parse_permission_request,
reply_body,
)
def test_parse_permission_request_from_event_properties() -> None:
req = parse_permission_request(
{
"id": "per_1",
"sessionID": "ses_1",
"action": "bash",
"resources": [{"command": "rm -rf build"}],
"metadata": {"path": "/repo/build"},
"source": "tool",
}
)
assert req is not None
assert req.request_id == "per_1"
assert req.session_id == "ses_1"
assert req.action == "bash"
assert req.source == "tool"
def test_parse_permission_request_accepts_request_id_alias() -> None:
req = parse_permission_request({"requestID": "per_2", "action": "edit"})
assert req is not None
assert req.request_id == "per_2"
def test_parse_permission_request_requires_id() -> None:
assert parse_permission_request({"action": "bash"}) is None
def test_normalize_for_policy_extracts_command_and_path() -> None:
req = parse_permission_request(
{
"id": "per_1",
"sessionID": "ses_1",
"action": "bash",
"resources": [{"command": "ls", "path": "/repo/x"}],
}
)
assert req is not None
normalized = normalize_for_policy(req, omnigent_session_id="conv_1", workspace="/repo")
assert normalized["harness"] == OPENCODE_NATIVE_HARNESS
assert normalized["action"] == "bash"
assert normalized["command"] == "ls"
assert normalized["path"] == "/repo/x"
assert normalized["working_directory"] == "/repo"
assert normalized["omnigent_session_id"] == "conv_1"
assert normalized["opencode_session_id"] == "ses_1"
def test_map_verdict_allow_variants() -> None:
assert map_verdict_to_decision({"decision": "allow"}) == "allow_once"
assert map_verdict_to_decision({"action": "approve"}) == "allow_once"
assert map_verdict_to_decision({"decision": "allow_always"}) == "allow_always"
assert map_verdict_to_decision({"decision": "always"}) == "allow_always"
def test_map_verdict_deny_variants() -> None:
assert map_verdict_to_decision({"decision": "deny"}) == "reject"
assert map_verdict_to_decision({"verdict": "block"}) == "reject"
def test_map_verdict_unknown_fails_closed_to_ask() -> None:
assert map_verdict_to_decision(None) == "ask"
assert map_verdict_to_decision({}) == "ask"
assert map_verdict_to_decision({"decision": "maybe"}) == "ask"
def test_decision_to_reply() -> None:
assert decision_to_reply("allow_once") == "once"
assert decision_to_reply("allow_always") == "always"
assert decision_to_reply("reject") == "reject"
# ask has no automatic reply (needs a human).
assert decision_to_reply("ask") is None
def test_reply_body() -> None:
assert reply_body("once") == {"reply": "once"}
assert reply_body("reject", message="blocked by policy") == {
"reply": "reject",
"message": "blocked by policy",
}
+140
View File
@@ -0,0 +1,140 @@
"""Unit tests for opencode-native provider-config synthesis."""
from __future__ import annotations
import json
import stat
import sys
import types
from pathlib import Path
import pytest
from omnigent.opencode_native_provider import (
DEFAULT_DATABRICKS_GATEWAY_MODEL,
OpenCodeGatewayResolution,
_gateway_endpoint_for_model,
build_opencode_model_default_config,
build_opencode_provider_config,
resolve_databricks_gateway,
write_opencode_provider_config,
)
def test_build_model_default_config_pins_model_without_provider_block() -> None:
cfg = build_opencode_model_default_config("anthropic/claude-sonnet-4-5")
assert cfg == {
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
}
# No provider block: opencode resolves the provider from the model prefix.
assert "provider" not in cfg
def test_model_default_config_round_trips_through_writer(tmp_path: Path) -> None:
path = write_opencode_provider_config(
tmp_path, build_opencode_model_default_config("openai/gpt-5.5")
)
written = json.loads(path.read_text(encoding="utf-8"))
assert written["model"] == "openai/gpt-5.5"
def test_qualified_model_joins_provider_and_endpoint() -> None:
res = OpenCodeGatewayResolution(
base_url="https://ws/serving-endpoints",
api_key="tok",
model_id="databricks-claude-sonnet-4-6",
provider_id="databricks-gateway",
)
assert res.qualified_model == "databricks-gateway/databricks-claude-sonnet-4-6"
def test_build_provider_config_shape() -> None:
res = OpenCodeGatewayResolution(
base_url="https://ws/serving-endpoints",
api_key="sekret",
model_id="databricks-claude-sonnet-4-6",
)
cfg = build_opencode_provider_config(res)
block = cfg["provider"]["databricks-gateway"]
assert block["npm"] == "@ai-sdk/openai-compatible"
assert block["options"] == {"baseURL": "https://ws/serving-endpoints", "apiKey": "sekret"}
assert "databricks-claude-sonnet-4-6" in block["models"]
assert cfg["$schema"].endswith("config.json")
def test_write_provider_config_is_0600_and_valid_json(tmp_path: Path) -> None:
res = OpenCodeGatewayResolution(
base_url="https://ws/serving-endpoints", api_key="tok", model_id="databricks-x"
)
path = write_opencode_provider_config(tmp_path, build_opencode_provider_config(res))
assert path == tmp_path / "opencode" / "opencode.json"
# Token-bearing config must not be world/group readable.
assert stat.S_IMODE(path.stat().st_mode) == 0o600
parsed = json.loads(path.read_text())
assert parsed["provider"]["databricks-gateway"]["options"]["apiKey"] == "tok"
@pytest.mark.parametrize(
"model_id,expected",
[
("databricks-claude-sonnet-4-6", "databricks-claude-sonnet-4-6"),
("databricks/databricks-gpt-5-5", "databricks-gpt-5-5"),
("claude-opus-4", None), # not a gateway endpoint name
("anthropic/claude-opus-4", None),
(None, None),
],
)
def test_gateway_endpoint_normalization(model_id: str | None, expected: str | None) -> None:
assert _gateway_endpoint_for_model(model_id) == expected
def test_resolve_gateway_none_without_profile() -> None:
assert resolve_databricks_gateway(None) is None
assert resolve_databricks_gateway("") is None
def test_resolve_gateway_none_when_sdk_absent(monkeypatch: pytest.MonkeyPatch) -> None:
# Simulate databricks-sdk not installed: the import inside the function raises.
monkeypatch.setitem(sys.modules, "databricks.sdk.core", None)
assert resolve_databricks_gateway("oss") is None
def _install_fake_sdk(monkeypatch: pytest.MonkeyPatch, *, host: str, token: str | None) -> None:
fake = types.ModuleType("databricks.sdk.core")
class _Config:
def __init__(self, *, profile: str) -> None:
self.profile = profile
self.host = host
def authenticate(self) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} if token else {}
fake.Config = _Config # type: ignore[attr-defined]
# Ensure parent packages resolve for the dotted import.
monkeypatch.setitem(sys.modules, "databricks", types.ModuleType("databricks"))
monkeypatch.setitem(sys.modules, "databricks.sdk", types.ModuleType("databricks.sdk"))
monkeypatch.setitem(sys.modules, "databricks.sdk.core", fake)
def test_resolve_gateway_success(monkeypatch: pytest.MonkeyPatch) -> None:
_install_fake_sdk(monkeypatch, host="https://ws.cloud.databricks.com/", token="abc123")
res = resolve_databricks_gateway("oss", model_id="databricks-gpt-5-5")
assert res is not None
assert res.base_url == "https://ws.cloud.databricks.com/serving-endpoints"
assert res.api_key == "abc123"
assert res.model_id == "databricks-gpt-5-5"
assert res.qualified_model == "databricks-gateway/databricks-gpt-5-5"
def test_resolve_gateway_defaults_non_gateway_model(monkeypatch: pytest.MonkeyPatch) -> None:
_install_fake_sdk(monkeypatch, host="https://ws.databricks.com", token="t")
res = resolve_databricks_gateway("oss", model_id="claude-opus-4")
assert res is not None
assert res.model_id == DEFAULT_DATABRICKS_GATEWAY_MODEL
def test_resolve_gateway_none_when_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
_install_fake_sdk(monkeypatch, host="https://ws.databricks.com", token=None)
assert resolve_databricks_gateway("oss") is None
+69
View File
@@ -0,0 +1,69 @@
"""Tests for client-side opencode-native launch state."""
from __future__ import annotations
import hashlib
import logging
from pathlib import Path
import pytest
from omnigent.opencode_native_state import (
read_launch_state,
write_launch_state,
)
def test_write_and_read_round_trips(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state"))
write_launch_state("conv_abc", "/repo")
state = read_launch_state("conv_abc")
assert state is not None
assert state.working_directory == "/repo"
def test_missing_state_is_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state"))
assert read_launch_state("nope") is None
def test_path_hashes_conversation_id(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
state_root = tmp_path / "state"
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(state_root))
conversation_id = "../../../etc/passwd"
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:32]
write_launch_state(conversation_id, "/repo")
assert (state_root / digest / "launch.json").is_file()
assert not (tmp_path / "etc").exists()
def test_relative_path_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state"))
with pytest.raises(ValueError, match="absolute path"):
write_launch_state("conv_abc", "relative/dir")
def test_conflicting_write_keeps_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state"))
logging.getLogger("omnigent").propagate = True
write_launch_state("conv_abc", "/original")
with caplog.at_level(logging.WARNING):
write_launch_state("conv_abc", "/other")
state = read_launch_state("conv_abc")
assert state is not None
assert state.working_directory == "/original"
assert any("launch state mismatch" in r.message for r in caplog.records)
def test_malformed_state_is_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
state_root = tmp_path / "state"
monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(state_root))
digest = hashlib.sha256(b"conv_abc").hexdigest()[:32]
target = state_root / digest
target.mkdir(parents=True)
(target / "launch.json").write_text("{bad", encoding="utf-8")
assert read_launch_state("conv_abc") is None
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the optional OpenCode worker in polly and debby specs."""
from __future__ import annotations
from pathlib import Path
from omnigent.spec import load
_REPO_ROOT = Path(__file__).resolve().parents[1]
def _sub_agents(bundle: str) -> dict[str, object]:
spec = load(_REPO_ROOT / "examples" / bundle)
return {sa.name: sa for sa in (getattr(spec, "sub_agents", None) or [])}
def _config(sub_agent: object) -> dict[str, object]:
executor = getattr(sub_agent, "executor", None)
config = getattr(executor, "config", None)
if isinstance(config, dict):
return config
return {}
def test_polly_declares_opencode_worker() -> None:
subs = _sub_agents("polly")
assert "opencode" in subs
cfg = _config(subs["opencode"])
assert cfg.get("harness") == "opencode-native"
def test_polly_codex_worker_allowlists_opencode_override() -> None:
subs = _sub_agents("polly")
cfg = _config(subs["codex"])
allowed = cfg.get("allowed_harnesses")
assert allowed is not None
assert "opencode-native" in allowed
assert "codex-native" in allowed
def test_polly_prompt_preflight_probes_opencode() -> None:
config = (_REPO_ROOT / "examples" / "polly" / "config.yaml").read_text(encoding="utf-8")
assert "command -v claude codex pi opencode" in config
def test_debby_declares_opencode_perspective() -> None:
subs = _sub_agents("debby")
assert "opencode" in subs
cfg = _config(subs["opencode"])
assert cfg.get("harness") == "opencode-native"
# Default fanout is still the two heads.
assert "claude" in subs
assert "gpt" in subs
def test_debby_prompt_keeps_opencode_optional() -> None:
config = (_REPO_ROOT / "examples" / "debby" / "config.yaml").read_text(encoding="utf-8")
assert "Optional OpenCode perspective" in config
assert "do not dispatch" in config.lower()
+66 -1
View File
@@ -22,7 +22,7 @@ import pytest
from omnigent.entities.conversation import MessageData, NewConversationItem
from omnigent.runtime import pending_elicitations
from omnigent.session_lifecycle import CLOSED_LABEL_KEY, CLOSED_LABEL_VALUE
from omnigent.spec.types import AgentSpec
from omnigent.spec.types import AgentSpec, ExecutorSpec
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
@@ -198,6 +198,71 @@ def test_send_schema_advertises_plain_string_and_purpose_object_args() -> None:
assert "harness default" in model_desc
def _object_branch_props(tool: SysSessionSendTool) -> set[str]:
"""Return the property names of the object branch of ``args``."""
params = tool.get_schema()["function"]["parameters"]
object_schema = next(
b for b in params["properties"]["args"]["anyOf"] if b.get("type") == "object"
)
return set(object_schema["properties"])
def test_send_schema_gates_harness_field_behind_allowlist_opt_in() -> None:
"""
``args.harness`` is advertised ONLY when a sub-agent opts in.
Per design D.4 the runtime harness override is allowlist-gated: the
schema exposes ``harness`` only when at least one declared sub-agent
declares a non-empty ``executor.config.allowed_harnesses``. A sub-agent
without that opt-in keeps the base ``{input, purpose, model}`` args
object, so the orchestrator never sees a harness knob it cannot use.
This mirrors the per-child dispatch guard in tool_dispatch.py the two
gates must agree on what "opted in" means.
"""
# Not opted in: a plain sub-agent (no allowed_harnesses) → base schema.
plain = SysSessionSendTool(
{"claude": AgentSpec(spec_version=1, name="claude", description="Review helper.")}
)
assert _object_branch_props(plain) == {"input", "purpose", "model"}
# Opted in: a sub-agent whose executor.config.allowed_harnesses declares a
# non-empty allowlist (the polly/debby `codex`/`opencode` worker shape) →
# the schema adds the gated `harness` field.
opted_in_spec = AgentSpec(
spec_version=1,
name="codex",
description="Codex coding sub-agent.",
executor=ExecutorSpec(
type="omnigent",
config={
"harness": "codex-native",
"allowed_harnesses": ["codex-native", "opencode-native"],
},
),
)
opted_in = SysSessionSendTool({"codex": opted_in_spec})
assert _object_branch_props(opted_in) == {"input", "purpose", "model", "harness"}
object_schema = next(
b
for b in opted_in.get_schema()["function"]["parameters"]["properties"]["args"]["anyOf"]
if b.get("type") == "object"
)
assert "allowed_harnesses" in object_schema["properties"]["harness"]["description"]
# additionalProperties stays closed even with the extra gated field, so a
# spurious arg is still rejected by validation.
assert object_schema["additionalProperties"] is False
# Mixed: one opted-in sub-agent among several opts the whole tool's schema
# in — the dispatch guard still rejects harness for the non-opted children.
mixed = SysSessionSendTool(
{
"claude": AgentSpec(spec_version=1, name="claude", description="Review helper."),
"codex": opted_in_spec,
}
)
assert _object_branch_props(mixed) == {"input", "purpose", "model", "harness"}
def test_peek_schema_required_fields_and_no_extra_props() -> None:
"""
The ``sys_session_get_history`` schema requires ``conversation_id``