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:
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 == {}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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``
|
||||
|
||||
Reference in New Issue
Block a user