diff --git a/README.md b/README.md index bdfe90b..cd49f5b 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,16 @@ Typed Claude harness Codex harness + OpenCode harness License: Apache-2.0

-**A Python SDK for building agents on Claude Code and Codex.** +**A Python SDK for building agents on Claude Code, Codex, and OpenCode.** -Claude Code and Codex have become general-purpose agents: give them -instructions, skills, and subagents, and they can be shaped to any task. Yoke -lets you reuse them from code — one `Harness` that drives both. +Claude Code, Codex, and OpenCode have become general-purpose agents: give +them instructions, skills, and subagents, and they can be shaped to any +task. Yoke lets you reuse them from code — one `Harness` that drives all +three.

Quickstart · @@ -42,6 +44,9 @@ Install a provider extra when you want Yoke to manage that SDK directly: pip install 'almanac-yoke[claude]' # or [codex], or [all] ``` +OpenCode needs no extra — it has no Python SDK to install; Yoke drives it +by spawning `opencode serve` and talking to its HTTP API directly. + Define an agent, pick a harness, run: ```python @@ -60,7 +65,9 @@ print(result.output) ``` Swap `"codex"` for `"claude"` and the same agent runs there. Your existing -Claude Code or ChatGPT login is all it needs — no API keys. +Claude Code or ChatGPT login is all it needs — no API keys. Swap it for +`"opencode"` and Yoke drives whatever provider your `opencode auth login` +(or a configured API key) already has set up. Embedding applications can observe a one-shot run while it is happening: @@ -258,6 +265,7 @@ require an exact one: | `codex:sdk` | Codex Python SDK | | `codex:cli` | Codex CLI — `codex exec`, resumable threads | | `claude:sdk` | Claude Agent SDK for Python | +| `opencode:server` | OpenCode's local HTTP server — sessions, fork, native skills | `discover` reports what this machine already has — surfaces installed, logins ready, models available — and picks the first ready surface satisfying the diff --git a/almanac/concepts/provider-surfaces.md b/almanac/concepts/provider-surfaces.md index d5e8a90..fab3f7a 100644 --- a/almanac/concepts/provider-surfaces.md +++ b/almanac/concepts/provider-surfaces.md @@ -21,6 +21,24 @@ sources: - id: capability-tests type: file path: tests/test_capabilities.py + - id: opencode-server + type: file + path: src/yoke/providers/opencode_server.py + - id: opencode-plan + type: file + path: docs/plans/2026-07-11-opencode-provider.md + - id: opencode-permissions + type: file + path: src/yoke/providers/opencode/permissions.py + - id: opencode-hooks + type: file + path: src/yoke/providers/opencode/hooks.py + - id: opencode-agents + type: file + path: src/yoke/providers/opencode/agents.py + - id: options + type: file + path: src/yoke/options.py --- Provider surfaces are the concrete Claude or Codex entrypoints that Yoke plans against. A provider is the family, such as `codex` or `claude`; a surface is the actual exposure path, such as `codex_app_server`, `codex_python_sdk`, `codex_cli`, or `claude_python_sdk` [@models]. Surfaces matter because Yoke treats features as surface-specific, not provider-wide [@decision]. @@ -52,3 +70,27 @@ Run event callbacks show the planning rule in a small form. `RunOptions(on_event Provider surfaces are not a cosmetic naming scheme. They control whether a [Yoke Harness](yoke-harness) may run, stream, resume, fork, use workflows, expose request callbacks, or report a feature as unavailable. The capability matrix answers what the surface can do before the provider turn starts [@surfaces]. This is why the decision note calls provider surfaces first-class. A generic provider default is acceptable for simple use, but advanced behavior must resolve to a concrete surface before Yoke claims that a feature exists [@decision]. + +## OpenCode: a third provider with no Python SDK + +`opencode_server` is Yoke's third provider surface, alongside Claude and Codex [@models]. Unlike either of those, OpenCode ships no Python SDK — the adapter spawns `opencode serve --port 0` as a child process and drives it entirely over its documented HTTP API [@opencode-server]. That makes it closer in shape to `codex_app_server` (a locally spawned, long-lived process) than to a Python-SDK surface, and it reuses the same design precedent: the process/HTTP/polling mechanics stay synchronous and thread-backed, and the async `ProviderAdapter` methods bridge to them with `asyncio.to_thread` rather than a from-scratch asyncio rewrite [@opencode-server]. + +OpenCode's own SSE event stream was found unreliable for live progress narration in a prior live spike, so this adapter instead polls OpenCode's own SQLite database for new session parts while a turn is in flight, and uses the same polling loop to detect a tool call stuck past a threshold — a confirmed upstream OpenCode reliability gap, not a Yoke bug [@opencode-plan]. SQLite is only used for this in-flight progress poll; stored session history (`read_session()`) instead calls the documented `GET /session/:id/message` endpoint, sliced locally for offset/limit since OpenCode's own `limit` keeps the most recent N messages rather than the earliest N [@opencode-server]. + +Sessions are first-class on this surface, not a one-shot-only wrapper — OpenCode's HTTP API supports real `GET /session`, `PATCH /session/:id`, `POST /session/:id/fork`, and `POST /session/:id/summarize` endpoints, so `start`/`send`/`close` map onto genuine multi-turn sessions and `run()` is a thin convenience wrapper over them [@opencode-server]. A forked session shares its parent's underlying server process rather than spawning a new one, so process termination is reference-counted the same way `CodexAppServer` reference-counts its shared app-server process, rather than tied to whichever session happens to close first [@opencode-server]. Confirmed live: `POST /session/:id/fork` does not inherit the parent's permission ruleset — a fork starts with none at all (default allow) regardless of how restrictive the parent session was — so `fork()` re-applies it via `PATCH /session/:id` right after forking [@opencode-server]. + +### Skills, subagents, and MCP: config-file compilation + +OpenCode discovers skills, custom agents, and MCP servers from files and config, not a runtime registration API, so this adapter compiles Yoke's declarative model into that shape once per session rather than issuing calls during a turn. Inline skills and direct Yoke subagents render as `skills//SKILL.md` and `agents/.md` (YAML frontmatter, `mode: subagent`) under a Yoke-owned deployment directory pointed at by `OPENCODE_CONFIG_DIR`, so nothing lands in the user's real project [@opencode-agents] [@opencode-server]. Direct subagents only — OpenCode documents no nested subagent-of-subagent invocation model to compile a recursive one against [@opencode-agents]. `agent.options["mcp_servers"]` renders as `{"mcp": {...}}` JSON passed through `OPENCODE_CONFIG_CONTENT`, OpenCode's highest-precedence config source, since there is no runtime add-server endpoint to call instead [@opencode-server]. + +Each subagent's own `permissions.access`/`.network` also compiles into a `permission:` frontmatter key on its `agents/.md` file — confirmed live that a per-agent `bash: deny` genuinely blocks the tool for that agent specifically, independent of whatever the parent session's own ruleset allows. This mirrors `codex_agent_toml()`'s existing `sandbox_mode(agent.permissions)` translation for Codex subagents; a subagent left at the default `Permissions()` (access=READ) already got Codex's read-only sandbox, so OpenCode's default-permissions subagents now get the equivalent restriction rather than inheriting an unrestricted session [@opencode-agents]. + +### Live permission approval: polling, not SSE, and not the endpoint you'd expect + +The original plan assumed pending permissions were "only learnable via SSE" and shipped an always-allow-all session, declaring `PERMISSIONS`/`REQUEST_EVENTS` `compiled`/`unsupported` [@opencode-plan]. That assumption was wrong: `GET /permission` is a real, non-deprecated, polling-discoverable endpoint listing every pending permission across sessions, confirmed live against a real `opencode serve` process — the same poll-not-SSE shape as the DB-poll progress watchdog, just polling OpenCode's HTTP API instead of its SQLite database [@opencode-permissions]. `Permissions.approval=ASK` now passes an ask-all session permission block instead of allow-all, and a dedicated `OpencodePermissionWatchdog` runs on its own thread alongside the progress watchdog, resolving each pending permission through `ProviderOptions.opencode.request_handler`/`.policy` — the same `RequestPolicy`/`Response` contract Claude and Codex app-server already use for their own request callbacks — and replying via `POST /permission/:id/reply` [@opencode-permissions] [@options]. The endpoint this adapter used to target for replies, `/session/:id/permissions/:permissionID`, turned out to be deprecated in OpenCode's own OpenAPI document; the reply now goes through the current one [@opencode-permissions]. + +`Permissions.access`/`.network` are translated the same way `accessible_claude_tools()` gates Claude's own tool list: write/edit/apply_patch need WRITE or FULL, bash needs FULL, webfetch/websearch need `network`. An earlier version of this session-permission block only translated `approval`, so `Permissions(access=READ, network=False, approval=NEVER)` produced a bare `* = allow` rule and every tool actually ran despite the session reporting a read-only, no-network posture — confirmed live and fixed [@opencode-server]. + +### Plugins and hooks: a generated bridge, not just config + +Plugins are OpenCode's one genuinely executable extension point (JS/TS auto-loaded from the same `OPENCODE_CONFIG_DIR` used for skills/agents), which splits into two very different features here. `agent.options["opencode_plugins"]` (name → raw JS source) is pure pass-through — Yoke writes whatever the caller supplies into `plugin/.js` and does not generate or validate it, the same shape as MCP config [@opencode-server]. Hooks are the opposite: Yoke *generates* a `tool.execute.before` plugin that relays every tool call to a small local HTTP server this adapter starts (`OpencodeHookBridge`), which resolves the call through the same `request_handler`/`.policy` contract permissions use and replies with a decision [@opencode-hooks]. Confirmed live: mutating the reply's `args` actually changes the command OpenCode executes, and denying actually blocks the tool call with a graceful message back to the model — not just an observed-after-the-fact event [@opencode-hooks]. The bridge is opt-in (no configured handler means no plugin file and no server) and process-scoped rather than session-scoped: it's addressed by an env var fixed at `opencode serve` spawn time, and a fork shares its parent's process and env, so one bridge serves every session on that process, routing by the `sessionID` present in each tool call's own payload [@opencode-hooks]. diff --git a/docs/plans/2026-07-11-opencode-provider.md b/docs/plans/2026-07-11-opencode-provider.md new file mode 100644 index 0000000..294f517 --- /dev/null +++ b/docs/plans/2026-07-11-opencode-provider.md @@ -0,0 +1,81 @@ +# OpenCode Provider Implementation Plan + +**Status (2026-07-12):** Task 4's PERMISSIONS/REQUEST_EVENTS row and the "no +polling-discoverable pending permission signal" claim below turned out to be +wrong — `GET /permission` is real and non-deprecated. Live permission +approval, filesystem-agent subagents, and both PLUGINS and HOOKS (all listed +`UNSUPPORTED`/`UNKNOWN` below) shipped after this plan was written. This +document is kept as the historical record of the initial implementation; +see [Provider Surfaces](../../almanac/concepts/provider-surfaces.md) and +`src/yoke/surfaces.py` for current, accurate capability status. + +**Goal:** Add OpenCode as Yoke's third provider, so any Yoke consumer (CodeAlmanac included) can run agents on OpenCode through the same `Harness` API as Claude and Codex. + +**Architecture:** `Provider.OPENCODE` / `Surface.OPENCODE_SERVER`. OpenCode has no Python SDK — it's a locally spawned HTTP server (`opencode serve --port 0`) with a documented OpenAPI surface. Following the precedent already set by `CodexAppServer` (`providers/codex_app/process.py`), the process/HTTP/DB-polling mechanics stay synchronous and thread-backed — the same shape as the proven CodeAlmanac implementation being ported — and the async `ProviderAdapter` methods bridge to them via `asyncio.to_thread`, rather than a ground-up asyncio rewrite that would need fresh live-testing to trust. OpenCode's own SSE stream was found unreliable for live progress in a prior spike (CodeAlmanac, 2026-07-09), so this adapter polls OpenCode's own SQLite database for live events and stuck-tool-call detection instead. Sessions are first-class (`start`/`send`/`close`), not a one-shot-only wrapper: OpenCode's HTTP API supports session reuse natively, and Yoke shouldn't be narrower than the surface it wraps. + +**Tech Stack:** Python 3.11+, httpx (sync, bridged via `asyncio.to_thread`), stdlib `sqlite3`, pytest/pytest-asyncio. + +**Evidence base:** https://opencode.ai/docs/server/ (session/auth/mcp/permission endpoints), https://opencode.ai/docs/skills/ (native `.opencode/skills/*/SKILL.md`, also reads `.claude/skills/`), https://opencode.ai/docs/mcp-servers/ (config-file only, no runtime API), https://opencode.ai/docs/providers/, https://opencode.ai/docs/cli/. Domain logic (server spawn, part-mapping, stuck-tool-call heuristics) ported from CodeAlmanac's `integrations/harnesses/opencode/*` (pre-migration), which spiked and validated these HTTP behaviors live. + +--- + +### Task 1: Provider/Surface plumbing + +- Add `Provider.OPENCODE = "opencode"` and `Surface.OPENCODE_SERVER = "opencode_server"` to `models.py`. +- Register in `adapters.py`: `_builtin_surfaces["opencode"] = {"opencode_server"}`, `default_adapter()` branch. +- `surfaces.py`: `default_surface()`, `SURFACE_CHANNELS` (`Channel.APP_SERVER` — same shape as `codex_app_server`, a locally spawned long-lived process, not a CLI one-shot or SDK import), `SURFACE_RUNTIMES` (`"opencode_server"`), `SURFACE_EVIDENCE` citing the docs above. +- Add failing tests asserting `adapter_for("opencode")` resolves and `has_adapter("opencode", "opencode_server")` is true before any adapter exists (`test_provider_surface.py` pattern). + +### Task 2: Async process + HTTP mechanics + +- `providers/opencode/process.py`: `asyncio.create_subprocess_exec("opencode", "serve", "--port", "0", ...)`, async stdout scan for `listening on http://127.0.0.1:(\d+)` with `asyncio.wait_for` deadline (direct async port of the existing `_wait_for_listening`). Windows note carried over: resolve via `shutil.which` first. +- `providers/opencode/http.py`: `httpx.AsyncClient` wrapping `GET /config/providers`, `POST /session`, `GET /session`, `GET /session/:id`, `PATCH /session/:id`, `DELETE /session/:id`, `POST /session/:id/fork`, `POST /session/:id/abort`, `POST /session/:id/summarize`, `POST /session/:id/message`, `POST /session/:id/permissions/:permissionID`, `PUT /auth/:id`, `GET /agent`. +- `providers/opencode/db.py` (new — Yoke can't import CodeAlmanac's `query_readonly_or_empty`): stdlib `sqlite3.connect(f"file:{path}?mode=ro", uri=True)` wrapped in `asyncio.to_thread`, tolerating missing file/table/corrupt db by returning `()`. +- Tests: fake `opencode serve` (a tiny stub script) or a `respx`/`httpx.MockTransport`-backed fake server, matching `test_codex_app_server_params.py`'s fixture style. + +### Task 3: Event normalization + progress polling + +- `providers/opencode/parts.py`: port `map_opencode_part` (text/reasoning/tool/patch/step-finish) to yield Yoke `Event` objects — `EventKind.TEXT`, `TOOL_USE`/`TOOL_RESULT` (with `Tool`/`tool_is_error`), `TOOL_SUMMARY` (reasoning + patch), `CONTEXT_USAGE` (step-finish → `Usage`). Task-tool spawns become `Event(kind=TOOL_USE, agent=AgentCall(agent_type="task", new_thread_id=child_session_id, prompt=...))` — Yoke has no dedicated agent-spawn event kind, it rides the existing `agent` field. +- `providers/opencode/progress.py`: `asyncio.Task` polling loop (replaces the thread + `queue.Queue` + `threading.Event` original) reading `part`/`message` rows scoped to known session ids, discovering child sessions via the `task` tool the same way, and raising a stuck-tool-call error past `stuck_after_seconds` (default 240s — this is a confirmed upstream OpenCode reliability gap, not a Yoke bug; cite the same tracking issue in the docstring). +- The send-vs-watchdog race stays the original's thread-join-with-timeout loop (send on one thread, watchdog on another, main thread polls `watchdog.stuck_reason` while joining with a short timeout) — this is synchronous code bridged via `asyncio.to_thread` at the adapter boundary, not native asyncio tasks. +- Tests: feed a fake sqlite db with part/message rows across poll cycles, assert event ordering, child-session discovery, and stuck detection at the threshold. + +### Task 4: `OpencodeServer` adapter (`providers/opencode_server.py`) + +- Implement `ProviderAdapter`: `check` (brief server + `GET /config/providers`, empty list → not ready), `models` (native, from providers list), `start`/`send`/`close` as the primary path, `run` as `start → send → close`. +- `list_sessions`/`read_session`/`rename`/`fork`/`interrupt`/`compact` map directly to the endpoints in Task 2 — these are real, not emulated, per the OpenAPI surface. +- `login(method="api_key", api_key=...)` → `PUT /auth/:id`. OAuth authorize/callback exists in the API but is out of scope for this task (no interactive browser flow in this adapter yet) — declare it explicitly rather than silently doing nothing. +- Permissions: session creation always passes the allow-all block. `POST /session/:id/permissions/:permissionID` exists to *answer* a request, but there is no polling-discoverable way to learn a permission is *pending* (only SSE, which this adapter deliberately avoids) — do not build an approval-callback loop this pass; see the corrected capability table. +- Skills: render Yoke skills as `SKILL.md` files under a Yoke-owned deployment directory, and set `OPENCODE_CONFIG_DIR` (per https://opencode.ai/docs/config/) to point OpenCode at it — this reuses `native_skills.py` rendering but needs its own deployment wiring (`runtime_deployment.py` currently branches `if provider is Provider.CODEX else _write_claude(...)` — a binary dispatch that will silently write Claude-shaped files for OpenCode unless corrected to an exhaustive branch alongside a new `_write_opencode`. `runtime_owner_pid()`'s stale-deployment reclaim also hardcodes `{Provider.CLAUDE.value, Provider.CODEX.value}` and needs `Provider.OPENCODE.value` added, or opencode runtime dirs never get reclaimed). +- Declared subagents: `Support.COMPILED` — lower Yoke subagents into whatever file shape `GET /agent` reads from (check `opencode` source/docs for the exact file format before implementing; if undocumented, mark `Support.UNKNOWN` with a note rather than guessing the shape). +- Failure classification ported from `failures.py` (not_installed, server_start_failed, stuck_tool_call, timeout, generic). + +### Task 5: Capability matrix + docs + +- `surfaces.py` `MATRIX[(Provider.OPENCODE, Surface.OPENCODE_SERVER)]` per the table below, plus `FEATURE_EVIDENCE`/`FEATURE_LOWERING`/`FEATURE_RECIPES` entries citing the URLs above. Done. +- Almanac docs: no existing per-provider pages exist for Claude/Codex either (provider specifics already live inside `almanac/concepts/provider-surfaces.md`), so a new standalone `opencode.md` page would break that established pattern rather than follow it. Added an "OpenCode: a third provider with no Python SDK" section to `provider-surfaces.md` instead, documenting the DB-polling decision, why SSE was rejected, why permissions are compiled not native, and the shared-process/fork reference-counting design. Done. +- `pyproject.toml`: confirmed no new optional dependency needed — `httpx` is already a base dependency, not extras-gated, and no OpenCode Python SDK exists to depend on. +- `docs/reference.md`: added the `opencode_server` row to the surfaces/capabilities table. Done. +- `README.md`: the tagline ("A Python SDK for building agents on Claude Code and Codex") and intro paragraph became inaccurate once this lands — updated tagline, intro, badge row, quickstart install-extras note (OpenCode needs none), the `"codex"`/`"claude"` swap paragraph, and the surfaces table to include OpenCode. Left the "How it compares" table's Claude Agent SDK/Codex SDK row unchanged — it specifically compares official vendor SDKs, and OpenCode has no equivalent official SDK to list there; editing it would make it less accurate, not more. Done. + +| Feature | Support | Note | +|---|---|---| +| SESSION / SESSION_LIST / SESSION_READ / SESSION_RENAME / SESSION_COMPACT / FORK / INTERRUPT | NATIVE | direct endpoint per Task 4 | +| MODELS | NATIVE | `GET /config/providers` | +| LOGIN | NATIVE (api_key only) | `PUT /auth/:id`; OAuth path unimplemented, noted | +| PERMISSIONS | COMPILED | blanket allow/deny set at session creation only — see below | +| REQUEST_EVENTS / REQUEST_CALLBACKS | UNSUPPORTED | corrected during implementation: `POST /session/:id/permissions/:permissionID` can *answer* a pending request, but there is no polling-discoverable "pending permission" signal — OpenCode's docs indicate this is only learnable via SSE, and this adapter deliberately does not depend on SSE (see Task 3). Revisit if SSE reliability for this one low-volume signal is separately confirmed. | +| SKILLS | NATIVE | `OPENCODE_CONFIG_DIR` env var points OpenCode at a Yoke-generated skill directory (`skills//SKILL.md`) without touching the user's project — confirmed via https://opencode.ai/docs/config/. Reuses `native_skills.py` rendering. | +| INLINE_SUBAGENTS | NATIVE | `task` tool spawns, confirmed by CodeAlmanac's spike | +| DECLARED_SUBAGENTS | COMPILED or UNKNOWN | pending file-format confirmation, Task 4 | +| STREAMING / RUN_EVENT_CALLBACKS | EMULATED | DB-poll based; native SSE spiked unreliable | +| MCP | COMPILED | config-file only (`OPENCODE_CONFIG_CONTENT` env var), no runtime add-server API | +| SESSION_TAG, GOAL, GOAL_LOOP, MUTABLE_GOAL, READABLE_GOAL, NATIVE_WORKFLOW, STRUCTURED_OUTPUT, COLLAB_AGENT_TOOLS, COLLABORATION_MODE, HOOKS, PLUGINS | UNSUPPORTED | no endpoint/evidence found | + +**Permissions default:** session creation always passes the allow-all permission block (`OPENCODE_ALLOW_ALL_PERMISSION`), matching CodeAlmanac's proven behavior. `RunOptions.permissions`/`SessionOptions.permissions` with a non-default `Approval` can be lowered to a narrower session-creation-time permission block, but there is no live interactive approval loop this pass. + +### Task 6: Verify + +- Full `pytest` + `ruff check .`. +- Live lab: readiness check, one-shot run producing text + tool use, a multi-turn session (start/send/send/close), a forced permission-approval round-trip, and a deliberately long-running tool call to confirm stuck-detection fires (or a mocked-timing test if 240s is impractical live). +- Update `docs/reference.md` provider table. diff --git a/docs/reference.md b/docs/reference.md index 72cfaf7..aa68b2d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1545,6 +1545,7 @@ Yoke models capabilities by surface, not only by provider. | Codex | `codex_python_sdk` | `sdk` | `codex_app_server` | published Python SDK, app-server-backed automation surface | | Codex | `codex_typescript_sdk` | `sdk` | `codex_sdk` | documented TypeScript SDK over local Codex agents; tracked, no built-in adapter yet | | Codex | `codex_app_server` | `app_server` | `codex_app_server` | live app protocol, streaming, skill roots, plugins, goals, collab agent events | +| OpenCode | `opencode_server` | `app_server` | `opencode_server` | locally spawned HTTP server, real sessions/fork/rename/compact, native skills, poll-based live events | `Surface` intentionally names documented entrypoints even before Yoke ships a built-in adapter for each one. That keeps design discussions precise: a feature diff --git a/src/yoke/adapters.py b/src/yoke/adapters.py index cd99123..9b8c8db 100644 --- a/src/yoke/adapters.py +++ b/src/yoke/adapters.py @@ -13,6 +13,7 @@ _builtin_surfaces: dict[ProviderKey, set[str]] = { "claude": {"claude_python_sdk"}, "codex": {"codex_cli", "codex_python_sdk", "codex_app_server"}, + "opencode": {"opencode_server"}, } @@ -102,6 +103,15 @@ def default_adapter(provider: Provider, surface: str | None = None) -> ProviderA raise AdapterNotFound( f"no built-in Yoke adapter for provider {provider!r} surface {surface!r}" ) + if provider_key == "opencode": + if surface_key not in (None, "opencode_server"): + raise AdapterNotFound( + "no built-in Yoke adapter for provider " + f"{provider!r} surface {surface!r}" + ) + from yoke.providers.opencode_server import OpencodeServer + + return OpencodeServer() raise AdapterNotFound(f"no built-in Yoke adapter for provider {provider!r}") diff --git a/src/yoke/models.py b/src/yoke/models.py index abb5d34..25f2006 100644 --- a/src/yoke/models.py +++ b/src/yoke/models.py @@ -61,6 +61,7 @@ class Provider(StrEnum): CLAUDE = "claude" CODEX = "codex" + OPENCODE = "opencode" class Surface(StrEnum): @@ -73,6 +74,7 @@ class Surface(StrEnum): CODEX_PYTHON_SDK = "codex_python_sdk" CODEX_TYPESCRIPT_SDK = "codex_typescript_sdk" CODEX_APP_SERVER = "codex_app_server" + OPENCODE_SERVER = "opencode_server" class Channel(StrEnum): @@ -137,6 +139,12 @@ def normalize_provider_surface(provider: object, surface: object) -> object: "typescript": Surface.CODEX_TYPESCRIPT_SDK, "typescript_sdk": Surface.CODEX_TYPESCRIPT_SDK, }, + Provider.OPENCODE: { + "server": Surface.OPENCODE_SERVER, + "app": Surface.OPENCODE_SERVER, + "app_server": Surface.OPENCODE_SERVER, + "http": Surface.OPENCODE_SERVER, + }, } return aliases[provider_value].get(surface_key, surface_text) @@ -410,6 +418,7 @@ def require_message(cls, value: str) -> str: def normalize_known_surface(cls, value: object) -> object: return normalize_surface(value) + class Login(YokeModel): """Provider login flow result.""" @@ -2401,11 +2410,7 @@ def surface_plan( and channel_value is not None and profile.channel is not channel_value ) - if ( - surface is None - and required - and required != (Feature.GOAL,) - ): + if surface is None and required and required != (Feature.GOAL,): profile = select_profile( provider, requires=required, diff --git a/src/yoke/options.py b/src/yoke/options.py index 28c966d..157f942 100644 --- a/src/yoke/options.py +++ b/src/yoke/options.py @@ -800,6 +800,64 @@ def capabilities(self, *, experimental_api: bool = False) -> dict[str, Any]: return capabilities +class OpencodeOptions(YokeModel): + """Typed OpenCode options plus raw escape hatch. + + `policy`/`request_handler` answer permissions OpenCode's session marked + "ask" (see Permissions.approval) — discovered live via GET /permission + and answered via POST /permission/:id/reply, both poll-based like the + rest of this adapter, not SSE-based. + """ + + policy: RequestPolicy | dict[str, Any] | None = None + request_handler: Any | None = Field( + default=None, + exclude=True, + json_schema_extra={ + "runtime_only": True, + "reason": ( + "Callbacks are live Python objects. Configure request_handler " + "in SDK code, not in a Yoke folder." + ), + }, + ) + raw: dict[str, Any] = Field(default_factory=dict) + + def runtime_options(self) -> tuple[RuntimeOption, ...]: + """Return active SDK-only fields that will not round-trip through folders.""" + + return runtime_options(self) + + def features(self) -> tuple[Feature, ...]: + """Return provider features implied by OpenCode options.""" + + from yoke.capabilities import Feature + + features: list[Feature] = [] + if self.request_handler is not None or self.policy is not None: + features.append(Feature.REQUEST_EVENTS) + if self.raw.get("request_handler") is not None: + features.append(Feature.REQUEST_EVENTS) + if self.raw.get("requestHandler") is not None: + features.append(Feature.REQUEST_EVENTS) + if self.raw.get("policy") is not None: + features.append(Feature.REQUEST_EVENTS) + deduped: list[Feature] = [] + extend_unique(deduped, tuple(features)) + return tuple(deduped) + + +def opencode_request_handler(options: OpencodeOptions | dict[str, Any] | None) -> Any: + """Return the caller's OpenCode permission handler, typed or raw.""" + + if isinstance(options, OpencodeOptions): + return options.request_handler or options.policy + if isinstance(options, dict): + handler = options.get("request_handler") or options.get("requestHandler") + return handler if handler is not None else options.get("policy") + return None + + class ProviderOptions(YokeModel): """Provider-specific options. @@ -809,6 +867,9 @@ class ProviderOptions(YokeModel): claude: ClaudeOptions | dict[str, Any] = Field(default_factory=ClaudeOptions) codex: CodexOptions | dict[str, Any] = Field(default_factory=CodexOptions) + opencode: OpencodeOptions | dict[str, Any] = Field( + default_factory=OpencodeOptions + ) def runtime_options(self) -> tuple[RuntimeOption, ...]: """Return active SDK-only fields that will not round-trip through folders.""" @@ -823,6 +884,10 @@ def features(self, *, provider: object | None = None) -> tuple[Feature, ...]: extend_unique(features, provider_option_features(self.claude, "claude")) if provider in (None, "codex"): extend_unique(features, provider_option_features(self.codex, "codex")) + if provider in (None, "opencode"): + extend_unique( + features, provider_option_features(self.opencode, "opencode") + ) return tuple(features) @@ -831,10 +896,24 @@ def provider_option_features(options: object, provider: str) -> tuple[Feature, . from yoke.capabilities import Feature - if isinstance(options, (ClaudeOptions, CodexOptions)): + if isinstance(options, (ClaudeOptions, CodexOptions, OpencodeOptions)): return options.features() if not isinstance(options, dict): return () + if provider == "opencode": + features: list[Feature] = [] + if options.get("request_handler") is not None: + features.append(Feature.REQUEST_EVENTS) + if options.get("requestHandler") is not None: + features.append(Feature.REQUEST_EVENTS) + if options.get("policy") is not None: + features.append(Feature.REQUEST_EVENTS) + raw = options.get("raw") + if isinstance(raw, dict): + extend_unique(features, provider_option_features(raw, provider)) + deduped: list[Feature] = [] + extend_unique(deduped, tuple(features)) + return tuple(deduped) if provider == "claude": features: list[Feature] = [] if options.get("permission_mode") is not None: diff --git a/src/yoke/providers/_fields.py b/src/yoke/providers/_fields.py new file mode 100644 index 0000000..1312bc9 --- /dev/null +++ b/src/yoke/providers/_fields.py @@ -0,0 +1,44 @@ +"""Loose JSON field helpers shared by providers with no typed SDK payloads. + +Codex app-server and OpenCode both talk to their provider over JSON (JSON-RPC +and HTTP respectively) rather than a typed Python SDK, so both need the same +small set of "read this field defensively" helpers. Kept here once so a fix +to one provider's field parsing doesn't quietly diverge from the other's. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypeGuard + +from pydantic import JsonValue + +JsonObject = dict[str, JsonValue] + + +def as_record(value: JsonValue | None) -> JsonObject: + if is_record(value): + return value + return {} + + +def is_record(value: JsonValue | None) -> TypeGuard[JsonObject]: + return isinstance(value, dict) + + +def string_field(record: Mapping[str, JsonValue], field: str) -> str | None: + value = record.get(field) + if isinstance(value, str) and value != "": + return value + return None + + +def number_field(record: Mapping[str, JsonValue], field: str) -> int | None: + value = record.get(field) + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None diff --git a/src/yoke/providers/codex_app/fields.py b/src/yoke/providers/codex_app/fields.py index 98358ba..3ea1c4a 100644 --- a/src/yoke/providers/codex_app/fields.py +++ b/src/yoke/providers/codex_app/fields.py @@ -3,41 +3,29 @@ from __future__ import annotations import json -from collections.abc import Mapping -from typing import TypeGuard, TypeVar +from typing import TypeVar from pydantic import JsonValue -T = TypeVar("T") -JsonObject = dict[str, JsonValue] - - -def as_record(value: JsonValue | None) -> JsonObject: - if is_record(value): - return value - return {} - - -def is_record(value: JsonValue | None) -> TypeGuard[JsonObject]: - return isinstance(value, dict) - +from yoke.providers._fields import ( + JsonObject, + as_record, + is_record, + number_field, + string_field, +) + +__all__ = [ + "JsonObject", + "as_record", + "compact_json", + "first_present", + "is_record", + "number_field", + "string_field", +] -def string_field(record: Mapping[str, JsonValue], field: str) -> str | None: - value = record.get(field) - if isinstance(value, str) and value != "": - return value - return None - - -def number_field(record: Mapping[str, JsonValue], field: str) -> int | None: - value = record.get(field) - if isinstance(value, bool): - return None - if isinstance(value, int): - return value - if isinstance(value, float) and value.is_integer(): - return int(value) - return None +T = TypeVar("T") def compact_json(value: JsonValue) -> str: diff --git a/src/yoke/providers/opencode/__init__.py b/src/yoke/providers/opencode/__init__.py new file mode 100644 index 0000000..fed5201 --- /dev/null +++ b/src/yoke/providers/opencode/__init__.py @@ -0,0 +1 @@ +"""OpenCode provider mechanics: process, HTTP, DB polling, event mapping.""" diff --git a/src/yoke/providers/opencode/agents.py b/src/yoke/providers/opencode/agents.py new file mode 100644 index 0000000..4d5682d --- /dev/null +++ b/src/yoke/providers/opencode/agents.py @@ -0,0 +1,102 @@ +"""OpenCode filesystem agent (custom subagent) rendering. + +OpenCode discovers subagents from markdown files with YAML frontmatter — +confirmed at https://opencode.ai/docs/agents/: a global +`~/.config/opencode/agents/*.md` or per-project `.opencode/agents/*.md` +directory, filename becomes the agent id, `mode: subagent` marks it +invokable via `@mention` or auto-delegation rather than as a primary agent. +Scoped to direct Yoke subagents only (mirrors `codex_agent_files`'s scope) — +OpenCode's docs describe no nested subagent-of-subagent invocation model to +compile against. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from yoke.models import Access, Agent +from yoke.providers.codex_agents import description_for, instructions_for, slug + + +@dataclass(slots=True) +class OpencodeAgentFile: + """One `.opencode/agents/*.md` file Yoke can materialize explicitly.""" + + name: str + path: Path + text: str + + +def opencode_agent_files( + agent: Agent, + *, + directory: str | Path = ".opencode/agents", +) -> tuple[OpencodeAgentFile, ...]: + """Compile direct Yoke subagents to OpenCode custom-agent markdown files.""" + + root = Path(directory) + return tuple( + OpencodeAgentFile( + name=name, + path=root / f"{slug(name)}.md", + text=opencode_agent_markdown(name, subagent), + ) + for name, subagent in agent.subagents.items() + ) + + +def opencode_agent_markdown(name: str, agent: Agent) -> str: + """Render one OpenCode custom-agent markdown document.""" + + lines = [ + "---", + f"description: {yaml_string(description_for(name, agent))}", + "mode: subagent", + ] + if agent.model: + lines.append(f"model: {yaml_string(agent.model)}") + permission = _agent_permission_config(agent) + if permission: + lines.append("permission:") + for tool, action in permission.items(): + lines.append(f" {tool}: {action}") + lines.extend(["---", "", instructions_for(agent)]) + return "\n".join(lines) + "\n" + + +def _agent_permission_config(agent: Agent) -> dict[str, str]: + """Translate this subagent's own access/network posture into OpenCode's + per-agent `permission:` frontmatter key (confirmed live: a `bash: deny` + entry here genuinely blocks the tool, independent of whatever the + parent session's own ruleset allows). + + Mirrors `codex_agent_toml`'s `sandbox_mode(agent.permissions)` — the + same subagent-level `access` field already gates Codex's sandbox, so a + subagent left at the default `Permissions()` (access=READ) already gets + Codex's "read-only" treatment; without this, the identical subagent + compiled to OpenCode had no restriction at all, inheriting whatever the + parent session/process happened to allow. Approval is intentionally not + translated here, matching Codex's own subagent compiler, since there is + no live per-request approval signal at subagent granularity. + """ + + permissions = agent.permissions + denies: dict[str, str] = {} + if permissions.access not in (Access.WRITE, Access.FULL): + for tool in ("write", "edit", "apply_patch"): + denies[tool] = "deny" + if permissions.access is not Access.FULL: + denies["bash"] = "deny" + if not permissions.network: + for tool in ("webfetch", "websearch"): + denies[tool] = "deny" + return denies + + +def yaml_string(value: str) -> str: + # A JSON string literal is also a valid YAML double-quoted scalar, so + # this avoids hand-rolling YAML quoting/escaping rules — same trick + # codex_agents.py's toml_string uses for TOML basic strings. + return json.dumps(value) diff --git a/src/yoke/providers/opencode/db.py b/src/yoke/providers/opencode/db.py new file mode 100644 index 0000000..3fbfcce --- /dev/null +++ b/src/yoke/providers/opencode/db.py @@ -0,0 +1,33 @@ +"""Read-only access to OpenCode's own SQLite database. + +OpenCode's SSE stream was found unreliable for live progress narration in the +prior spike this adapter is ported from (CodeAlmanac, 2026-07-09); polling +OpenCode's own part/message tables is the proven alternative. Yoke doesn't +own this schema, so every query tolerates a missing file, missing table, or +corrupt database by returning an empty result instead of raising. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +SQLiteRow = sqlite3.Row + + +def query_readonly_or_empty( + path: Path, + sql: str, + params: tuple = (), +) -> tuple[SQLiteRow, ...]: + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + except sqlite3.Error: + return () + try: + return tuple(connection.execute(sql, params).fetchall()) + except sqlite3.Error: + return () + finally: + connection.close() diff --git a/src/yoke/providers/opencode/failures.py b/src/yoke/providers/opencode/failures.py new file mode 100644 index 0000000..6382b42 --- /dev/null +++ b/src/yoke/providers/opencode/failures.py @@ -0,0 +1,44 @@ +"""Classify OpenCode run failures into normalized Yoke failures.""" + +from __future__ import annotations + +from yoke.models import Failure +from yoke.providers.opencode.progress import OPENCODE_STUCK_TOOL_CALL_ISSUE_URL + + +def classify_opencode_failure(message: str, code: str | None = None) -> Failure: + if "not found on PATH" in message: + return Failure( + code="opencode.not_installed", + message="OpenCode was not found on PATH.", + fix=( + "Install OpenCode or update PATH so the `opencode` command " + "is available." + ), + raw=message, + ) + if ( + "did not report a listening port" in message + or "exited before it started listening" in message + ): + return Failure( + code="opencode.server_start_failed", + message=message, + fix="Run `opencode serve` directly to check for a startup error.", + raw=message, + ) + if "tool call has been stuck" in message: + return Failure( + code="opencode.stuck_tool_call", + message=message, + fix=( + "This is a known upstream OpenCode reliability issue " + f"({OPENCODE_STUCK_TOOL_CALL_ISSUE_URL}), not specific to this " + "run. Retrying often succeeds; a different model may avoid the " + "tool call shape that triggers it." + ), + raw=message, + ) + if "timed out" in message: + return Failure(code="opencode.timeout", message=message, raw=message) + return Failure(code=code or "opencode.request_failed", message=message, raw=message) diff --git a/src/yoke/providers/opencode/fields.py b/src/yoke/providers/opencode/fields.py new file mode 100644 index 0000000..9fd219b --- /dev/null +++ b/src/yoke/providers/opencode/fields.py @@ -0,0 +1,32 @@ +"""Loose JSON field helpers for OpenCode HTTP/DB payloads.""" + +from __future__ import annotations + +import json + +from pydantic import JsonValue + +from yoke.providers._fields import ( + JsonObject, + as_record, + is_record, + number_field, + string_field, +) + +__all__ = [ + "JsonObject", + "as_record", + "is_record", + "number_field", + "string_field", + "stringify_json_value", +] + + +def stringify_json_value(value: JsonValue | None) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return json.dumps(value, separators=(",", ":"), sort_keys=True) diff --git a/src/yoke/providers/opencode/hooks.py b/src/yoke/providers/opencode/hooks.py new file mode 100644 index 0000000..9ee0fd8 --- /dev/null +++ b/src/yoke/providers/opencode/hooks.py @@ -0,0 +1,195 @@ +"""Generated OpenCode plugin + local bridge for `tool.execute.before` hooks. + +Confirmed live in a 2026-07-12 spike: a generated `tool.execute.before` +plugin hook is awaited (blocking) by OpenCode before a tool runs, can mutate +`output.args` in place (the actually-executed tool call reflects the +mutation — replacing the whole `output.args` object does *not* work, only +per-key assignment does), and can block the call outright by throwing. The +hook runs inside the `opencode serve` child process, not this one, so it +needs a local HTTP round trip to reach a Python-side decision — this module +is that bridge. + +Opt-in only: OpencodeServer only deploys this plugin and starts the bridge +when a caller configures `ProviderOptions.opencode.request_handler`/ +`.policy` at session start (see `_maybe_deploy_hook_bridge` in +opencode_server.py). A tool call this adapter doesn't have an opinion on +defaults to allow-unchanged, not deny — unlike a permission request, hook +interception is something a caller opts into per tool, not a mandatory +approval gate. +""" + +from __future__ import annotations + +import json +import threading +from collections.abc import Callable +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from yoke.models import Event, EventKind, Request, RequestKind, Response, Tool +from yoke.providers.opencode.fields import JsonObject, as_record, string_field +from yoke.providers.opencode.parts import infer_opencode_tool_kind +from yoke.providers.opencode.permissions import policy_response + +OPENCODE_HOOK_PLUGIN_SOURCE = """\ +export const YokeToolHook = async () => { + const bridgeUrl = process.env.YOKE_HOOK_BRIDGE_URL; + if (!bridgeUrl) { + return {}; + } + return { + "tool.execute.before": async (input, output) => { + const res = await fetch(bridgeUrl + "/tool-hook", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: input.sessionID, + callID: input.callID, + tool: input.tool, + args: output.args, + }), + // A hung bridge (e.g. a caller's request_handler blocking + // indefinitely on I/O) would otherwise block this tool call + // forever with no way for the caller to recover. A timed-out + // fetch throws, which — same as any other throw here — blocks + // the call rather than silently letting it through. + signal: AbortSignal.timeout(30000), + }); + const decision = await res.json(); + if (decision.args) { + for (const key of Object.keys(decision.args)) { + output.args[key] = decision.args[key]; + } + } + if (decision.deny) { + throw new Error(decision.message || "Denied by Yoke."); + } + }, + }; +}; +""" + +ResolveCallback = Callable[[str, JsonObject], Response] + + +class OpencodeHookBridge: + """Local HTTP server the generated plugin calls into for each tool call. + + Process-scoped, not session-scoped: the plugin is addressed via an env + var fixed at `opencode serve` spawn time, and a fork shares its parent's + process/env, so there is exactly one bridge per process regardless of + how many sessions (forks) run on it. `resolve` is given the tool call's + own `sessionID` to route to the right session's current policy/event + stream. + """ + + def __init__(self, resolve: ResolveCallback) -> None: + bridge = self + self._resolve = resolve + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 - stdlib handler name + length = int(self.headers.get("Content-Length", 0)) + try: + payload = as_record(json.loads(self.rfile.read(length) or b"{}")) + except ValueError: + payload = {} + # A bare exception here (a bug in a caller's request_handler, + # or a Response.updated_input value json.dumps can't + # serialize) would otherwise reset the connection with no + # response — the plugin's `await res.json()` then throws + # inside tool.execute.before, which per its own semantics + # blocks the tool call. That's an indistinguishable silent + # deny with no REQUEST_RESOLVED event ever emitted. Turn it + # into an honest, visible one instead. + try: + session_id = string_field(payload, "sessionID") or "" + response = bridge._resolve(session_id, payload) + body = json.dumps( + { + "args": response.updated_input, + "deny": response.decision != "allow", + "message": response.message, + } + ).encode() + except Exception as error: # noqa: BLE001 - reported below + body = json.dumps( + { + "args": None, + "deny": True, + "message": f"Yoke hook bridge error: {error}", + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format_string: str, *args: object) -> None: + pass + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + + @property + def base_url(self) -> str: + host, port = self._server.server_address + return f"http://{host}:{port}" + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._server.shutdown() + self._server.server_close() + + +def hook_event(payload: JsonObject) -> Event: + """Build the provider-neutral event for one intercepted tool call.""" + + tool_name = string_field(payload, "tool") or "tool" + args = payload.get("args") + command = string_field(args, "command") if isinstance(args, dict) else None + tool = Tool( + kind=infer_opencode_tool_kind(tool_name), + title=tool_name, + command=command, + ) + message = f"OpenCode is about to run {tool_name}" + call_id = string_field(payload, "callID") + # Allow-unchanged by default: this bridge only exists because the + # caller opted in (see module docstring), so a tool this specific + # handler has no opinion on should pass through, not fail closed. + default = Response.allow() + return Event( + kind=EventKind.TOOL_REQUEST, + message=message, + tool_id=call_id, + tool_name=tool_name, + tool=tool, + request=Request( + kind=RequestKind.TOOL, + id=call_id, + method=tool_name, + message=message, + tool=tool, + input=args, + default=default, + raw=payload, + ), + response=default, + source_thread_id=string_field(payload, "sessionID"), + raw=payload, + ) + + +def resolve( + payload: JsonObject, request_handler: object | None +) -> tuple[Event, Response]: + """Resolve one intercepted tool call, returning the event and decision.""" + + event = hook_event(payload) + response = policy_response(event, request_handler) + return event, response diff --git a/src/yoke/providers/opencode/http.py b/src/yoke/providers/opencode/http.py new file mode 100644 index 0000000..06d5895 --- /dev/null +++ b/src/yoke/providers/opencode/http.py @@ -0,0 +1,269 @@ +"""Synchronous OpenCode HTTP client. + +Endpoints per https://opencode.ai/docs/server/. One short-lived +`httpx.Client` per call keeps this module free of connection-lifetime state; +the caller owns the server process lifetime. +""" + +from __future__ import annotations + +import httpx + +from yoke.providers.opencode.fields import JsonObject, as_record + +OPENCODE_ALLOW_ALL_PERMISSION: tuple[JsonObject, ...] = ( + {"permission": "*", "pattern": "*", "action": "allow"}, +) +OPENCODE_ASK_ALL_PERMISSION: tuple[JsonObject, ...] = ( + {"permission": "*", "pattern": "*", "action": "ask"}, +) + + +def get_providers(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]: + response = httpx.get(f"{base_url}/config/providers", timeout=timeout_seconds) + response.raise_for_status() + payload = as_record(response.json()) + providers = payload.get("providers") + if not isinstance(providers, list): + return () + return tuple(as_record(item) for item in providers if isinstance(item, dict)) + + +def create_session( + base_url: str, + cwd_directory: str, + title: str, + timeout_seconds: float, + *, + permission: tuple[JsonObject, ...] = OPENCODE_ALLOW_ALL_PERMISSION, +) -> JsonObject: + body: JsonObject = {"title": title} + if permission: + body["permission"] = list(permission) + response = httpx.post( + f"{base_url}/session", + params={"directory": cwd_directory}, + json=body, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def list_sessions( + base_url: str, + timeout_seconds: float, + *, + directory: str | None = None, +) -> tuple[JsonObject, ...]: + params = {"directory": directory} if directory is not None else None + response = httpx.get(f"{base_url}/session", params=params, timeout=timeout_seconds) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + return () + return tuple(as_record(item) for item in payload if isinstance(item, dict)) + + +def list_messages( + base_url: str, session_id: str, timeout_seconds: float +) -> tuple[JsonObject, ...]: + """List a session's stored messages via the documented history API. + + GET /session/:id/message (operationId session.messages), confirmed live + (v1.17.15) to return every message in chronological order. No `limit` is + passed here — OpenCode's own `limit` keeps the most *recent* N messages, + which doesn't compose with offset-based pagination over the full + (oldest-first) order; callers slice the full result locally instead. + """ + + response = httpx.get( + f"{base_url}/session/{session_id}/message", timeout=timeout_seconds + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + return () + return tuple(as_record(item) for item in payload if isinstance(item, dict)) + + +def read_session(base_url: str, session_id: str, timeout_seconds: float) -> JsonObject: + response = httpx.get(f"{base_url}/session/{session_id}", timeout=timeout_seconds) + response.raise_for_status() + return as_record(response.json()) + + +def rename_session( + base_url: str, + session_id: str, + title: str, + timeout_seconds: float, +) -> JsonObject: + response = httpx.patch( + f"{base_url}/session/{session_id}", + json={"title": title}, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def update_session_permission( + base_url: str, + session_id: str, + timeout_seconds: float, + *, + permission: tuple[JsonObject, ...], +) -> JsonObject: + """Set a session's permission ruleset via PATCH /session/:id. + + Confirmed live (v1.17.15): `POST /session/:id/fork` does not inherit the + parent's ruleset — a forked session starts with none at all (default + allow) regardless of how restrictive the parent was. This PATCH endpoint + is the only documented way to (re)apply one after the fact. + """ + + response = httpx.patch( + f"{base_url}/session/{session_id}", + json={"permission": list(permission)}, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def delete_session(base_url: str, session_id: str, timeout_seconds: float) -> None: + response = httpx.delete(f"{base_url}/session/{session_id}", timeout=timeout_seconds) + response.raise_for_status() + + +def fork_session( + base_url: str, + session_id: str, + timeout_seconds: float, + *, + message_id: str | None = None, +) -> JsonObject: + body: JsonObject = {"messageID": message_id} if message_id is not None else {} + response = httpx.post( + f"{base_url}/session/{session_id}/fork", + json=body, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def abort_session(base_url: str, session_id: str, timeout_seconds: float) -> None: + response = httpx.post( + f"{base_url}/session/{session_id}/abort", timeout=timeout_seconds + ) + response.raise_for_status() + + +def summarize_session( + base_url: str, + session_id: str, + provider_id: str, + model_id: str, + timeout_seconds: float, +) -> None: + response = httpx.post( + f"{base_url}/session/{session_id}/summarize", + json={"providerID": provider_id, "modelID": model_id}, + timeout=timeout_seconds, + ) + response.raise_for_status() + + +def post_message( + base_url: str, + session_id: str, + cwd_directory: str, + provider_id: str, + model_id: str, + prompt: str, + timeout_seconds: float, + *, + system: str | None = None, +) -> JsonObject: + body: JsonObject = { + "model": {"providerID": provider_id, "modelID": model_id}, + "parts": [{"type": "text", "text": prompt}], + } + if system is not None: + body["system"] = system + response = httpx.post( + f"{base_url}/session/{session_id}/message", + params={"directory": cwd_directory}, + json=body, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def list_permissions(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]: + """List pending permission requests across all sessions. + + GET /permission (operationId permission.list) — confirmed live: a bash + permission set to "ask" appeared here immediately and was still listed + right up until it was answered via respond_permission(). This is the + real, non-deprecated discovery mechanism; /session/:id/permissions/ + :permissionID (the endpoint this module used before) is marked + deprecated in OpenCode's own OpenAPI doc. + """ + + response = httpx.get(f"{base_url}/permission", timeout=timeout_seconds) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + return () + return tuple(as_record(item) for item in payload if isinstance(item, dict)) + + +def respond_permission( + base_url: str, + permission_id: str, + reply: str, + timeout_seconds: float, + *, + message: str | None = None, +) -> None: + """Answer a pending permission via POST /permission/:requestID/reply. + + `reply` is one of "once", "always", "reject" (OpenCode's own enum). + """ + + body: JsonObject = {"reply": reply} + if message is not None: + body["message"] = message + response = httpx.post( + f"{base_url}/permission/{permission_id}/reply", + json=body, + timeout=timeout_seconds, + ) + response.raise_for_status() + + +def set_auth( + base_url: str, + provider_id: str, + api_key: str, + timeout_seconds: float, +) -> None: + response = httpx.put( + f"{base_url}/auth/{provider_id}", + json={"type": "api", "key": api_key}, + timeout=timeout_seconds, + ) + response.raise_for_status() + + +def list_agents(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]: + response = httpx.get(f"{base_url}/agent", timeout=timeout_seconds) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + return () + return tuple(as_record(item) for item in payload if isinstance(item, dict)) diff --git a/src/yoke/providers/opencode/parts.py b/src/yoke/providers/opencode/parts.py new file mode 100644 index 0000000..3239596 --- /dev/null +++ b/src/yoke/providers/opencode/parts.py @@ -0,0 +1,238 @@ +"""Map OpenCode session 'part' JSON to normalized Yoke events.""" + +from __future__ import annotations + +from yoke.models import ( + AgentCall, + Event, + EventKind, + Surface, + Tool, + ToolKind, + ToolStatus, + Usage, +) +from yoke.providers.opencode.fields import ( + JsonObject, + as_record, + number_field, + string_field, + stringify_json_value, +) +from yoke.providers.opencode.usage import parse_opencode_usage + +_TOOL_TITLES = { + ToolKind.READ: "Reading file", + ToolKind.WRITE: "Writing file", + ToolKind.EDIT: "Editing file", + ToolKind.SEARCH: "Searching", + ToolKind.SHELL: "Running command", + ToolKind.WEB: "Web request", + ToolKind.AGENT: "Agent tool", +} + + +def map_opencode_part(part: JsonObject, session_id: str) -> tuple[Event, ...]: + part_type = string_field(part, "type") + if part_type == "text": + return _text_events(part) + if part_type == "reasoning": + return _reasoning_events(part) + if part_type == "tool": + return _tool_events(part, session_id) + if part_type == "patch": + return _patch_events(part) + if part_type == "step-finish": + return _step_finish_events(part) + return () + + +def _text_events(part: JsonObject) -> tuple[Event, ...]: + text = string_field(part, "text") + if text is None: + return () + return ( + Event( + kind=EventKind.TEXT, + surface=Surface.OPENCODE_SERVER, + message=text, + raw=part, + ), + ) + + +def _reasoning_events(part: JsonObject) -> tuple[Event, ...]: + text = string_field(part, "text") + if text is None: + return () + return ( + Event( + kind=EventKind.TOOL_SUMMARY, + surface=Surface.OPENCODE_SERVER, + message=text, + raw=part, + ), + ) + + +def _tool_events(part: JsonObject, session_id: str) -> tuple[Event, ...]: + tool_name = string_field(part, "tool") or "tool" + call_id = string_field(part, "callID") + state = as_record(part.get("state")) + tool = opencode_tool(tool_name, state) + agent = _task_agent_call(part, state, session_id) + use_event = Event( + kind=EventKind.TOOL_USE, + surface=Surface.OPENCODE_SERVER, + message=tool.title or tool_name, + tool_id=call_id, + tool_name=tool_name, + tool_input=stringify_json_value(state.get("input")), + tool=tool, + agent=agent, + source_thread_id=session_id, + raw=part, + ) + result_event = Event( + kind=EventKind.TOOL_RESULT, + surface=Surface.OPENCODE_SERVER, + message=tool.title or f"{tool_name} completed", + tool_id=call_id, + tool_name=tool_name, + tool=tool, + tool_result=state.get("output"), + tool_is_error=tool.status == ToolStatus.FAILED, + agent=agent, + source_thread_id=session_id, + raw=part, + ) + return (use_event, result_event) + + +def _task_agent_call( + part: JsonObject, + state: JsonObject, + session_id: str, +) -> AgentCall | None: + if string_field(part, "tool") != "task": + return None + metadata = as_record(state.get("metadata")) + child_session_id = string_field(metadata, "sessionId") + if child_session_id is None: + return None + input_record = as_record(state.get("input")) + prompt = string_field(input_record, "prompt") or string_field( + input_record, "description" + ) + status = string_field(state, "status") + action = "completed" if status in ("completed", "error") else "spawned" + return AgentCall( + action=action, + agent_type="task", + sender_thread_id=session_id, + new_thread_id=child_session_id, + prompt=prompt, + ) + + +def opencode_tool(tool_name: str, state: JsonObject) -> Tool: + metadata = as_record(state.get("metadata")) + input_record = as_record(state.get("input")) + kind = infer_opencode_tool_kind(tool_name) + return Tool( + kind=kind, + title=string_field(state, "title") or _TOOL_TITLES.get(kind, tool_name), + path=string_field(input_record, "filePath") + or string_field(input_record, "path"), + command=string_field(input_record, "command"), + status=opencode_tool_status(state), + exit_code=number_field(metadata, "exit"), + ) + + +def opencode_tool_status(state: JsonObject) -> ToolStatus: + status = string_field(state, "status") + if status == "error": + return ToolStatus.FAILED + return ToolStatus.COMPLETED + + +def infer_opencode_tool_kind(tool: str) -> ToolKind: + normalized = tool.lower() + if "read" in normalized: + return ToolKind.READ + if "write" in normalized: + return ToolKind.WRITE + if "edit" in normalized or "patch" in normalized: + return ToolKind.EDIT + if any(word in normalized for word in ("grep", "glob", "search", "find", "ls")): + return ToolKind.SEARCH + if "bash" in normalized or "shell" in normalized: + return ToolKind.SHELL + if "web" in normalized or "fetch" in normalized: + return ToolKind.WEB + if "task" in normalized or "agent" in normalized: + return ToolKind.AGENT + return ToolKind.UNKNOWN + + +def _patch_events(part: JsonObject) -> tuple[Event, ...]: + files = part.get("files") + if not isinstance(files, list) or len(files) == 0: + return () + names = ", ".join(str(item) for item in files) + return ( + Event( + kind=EventKind.TOOL_SUMMARY, + surface=Surface.OPENCODE_SERVER, + message=f"files changed: {names}", + raw=part, + ), + ) + + +def _step_finish_events(part: JsonObject) -> tuple[Event, ...]: + usage = parse_opencode_usage(part.get("tokens")) + if usage is None: + return () + return ( + Event( + kind=EventKind.CONTEXT_USAGE, + surface=Surface.OPENCODE_SERVER, + message=usage_message(usage), + usage=usage, + raw=part, + ), + ) + + +def usage_message(usage: Usage) -> str: + if usage.total_tokens is not None: + return f"usage: {usage.total_tokens} tokens" + return "usage updated" + + +def final_text_from_parts(parts: list[JsonObject]) -> str | None: + for part in reversed(parts): + if string_field(part, "type") == "text": + text = string_field(part, "text") + if text is not None: + return text + return None + + +def is_task_spawn(part: JsonObject) -> tuple[str, str] | None: + """(child_session_id, prompt) if this part spawns a sub-agent session.""" + + if string_field(part, "type") != "tool" or string_field(part, "tool") != "task": + return None + state = as_record(part.get("state")) + metadata = as_record(state.get("metadata")) + child_session_id = string_field(metadata, "sessionId") + if child_session_id is None: + return None + input_record = as_record(state.get("input")) + prompt = string_field(input_record, "prompt") or string_field( + input_record, "description" + ) + return child_session_id, (prompt or "") diff --git a/src/yoke/providers/opencode/permissions.py b/src/yoke/providers/opencode/permissions.py new file mode 100644 index 0000000..5d06108 --- /dev/null +++ b/src/yoke/providers/opencode/permissions.py @@ -0,0 +1,156 @@ +"""Poll OpenCode's pending-permission list and resolve it through a policy. + +`GET /permission` is a real, non-deprecated, polling-discoverable endpoint — +confirmed live in a 2026-07-12 spike: a session created with a bash +permission set to "ask" produced a pending entry here immediately, and the +in-flight `POST /session/:id/message` call (blocked waiting on that specific +permission) completed as soon as it was answered via +`POST /permission/:id/reply`. That disproves this adapter's earlier +assumption that a pending permission is "only learnable via SSE" — it fits +the same poll-not-SSE architecture as `OpencodeProgressWatchdog` +(progress.py), not a generated plugin or a local HTTP callback listener. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable + +from yoke.models import ( + Event, + EventKind, + Request, + RequestKind, + Response, + Tool, + ToolStatus, +) +from yoke.providers.opencode import http +from yoke.providers.opencode.fields import JsonObject, string_field +from yoke.providers.opencode.parts import infer_opencode_tool_kind + +Callback = Callable[[Event], None] + +OPENCODE_PERMISSION_POLL_INTERVAL_SECONDS = 1.0 +OPENCODE_PERMISSION_REQUEST_TIMEOUT_SECONDS = 10.0 + + +class OpencodePermissionWatchdog: + """Poll pending permissions for one session and resolve them via a handler.""" + + def __init__( + self, + base_url: str, + session_id: str, + on_event: Callback, + request_handler: object | None, + poll_interval_seconds: float = OPENCODE_PERMISSION_POLL_INTERVAL_SECONDS, + timeout_seconds: float = OPENCODE_PERMISSION_REQUEST_TIMEOUT_SECONDS, + seen_permission_ids: set[str] | None = None, + ) -> None: + self.base_url = base_url + self.session_id = session_id + self.on_event = on_event + self.request_handler = request_handler + self.poll_interval_seconds = poll_interval_seconds + self.timeout_seconds = timeout_seconds + self._seen: set[str] = ( + seen_permission_ids if seen_permission_ids is not None else set() + ) + + def run(self, stop_event: threading.Event) -> None: + while not stop_event.is_set(): + self._poll_once() + stop_event.wait(self.poll_interval_seconds) + # One final pass: a permission asked right before the sender thread + # returned could otherwise go unanswered and unreported. + self._poll_once() + + def _poll_once(self) -> None: + try: + pending = http.list_permissions(self.base_url, self.timeout_seconds) + except Exception: # noqa: BLE001 - transient poll errors retried next tick + return + for record in pending: + permission_id = string_field(record, "id") + if permission_id is None or permission_id in self._seen: + continue + if string_field(record, "sessionID") != self.session_id: + continue + self._resolve(record, permission_id) + + def _resolve(self, record: JsonObject, permission_id: str) -> None: + event = permission_event(record, permission_id) + response = policy_response(event, self.request_handler) + reply = "once" if response.decision == "allow" else "reject" + try: + http.respond_permission( + self.base_url, + permission_id, + reply, + self.timeout_seconds, + message=response.message, + ) + except Exception: # noqa: BLE001 - reply failures retried next poll tick + return + # Only mark seen once the reply actually lands — a transient reply + # failure used to be swallowed after the ID was recorded, so the + # watchdog never retried it and the in-flight message stayed blocked + # until its own outer timeout. + self._seen.add(permission_id) + self.on_event( + event.model_copy( + update={"kind": EventKind.REQUEST_RESOLVED, "response": response} + ) + ) + + +def permission_event(record: JsonObject, permission_id: str) -> Event: + """Build the provider-neutral event for one pending OpenCode permission.""" + + permission_kind = string_field(record, "permission") or "permission" + metadata = record.get("metadata") + command = ( + string_field(metadata, "command") if isinstance(metadata, dict) else None + ) + tool = Tool( + kind=infer_opencode_tool_kind(permission_kind), + title=permission_kind, + command=command, + status=ToolStatus.STARTED, + ) + message = f"OpenCode requested {permission_kind} permission" + # Fail closed, matching RequestPolicy's own default (policies.py): a + # permission this adapter can't resolve is denied, not silently left + # pending until the caller's timeout. + default = Response.deny(f"Denied by Yoke: no permission policy for {message!r}.") + return Event( + kind=EventKind.APPROVAL_REQUEST, + message=message, + tool_id=permission_id, + tool_name=permission_kind, + tool=tool, + request=Request( + kind=RequestKind.PERMISSION, + id=permission_id, + method=permission_kind, + message=message, + tool=tool, + input=record, + default=default, + raw=record, + ), + response=default, + source_thread_id=string_field(record, "sessionID"), + raw=record, + ) + + +def policy_response(event: Event, request_handler: object | None) -> Response: + """Return the handler's decision, or the event's default if none applies.""" + + default = event.response or Response.deny() + if request_handler is None or not callable(request_handler): + return default + response = request_handler(event, default) + return response if isinstance(response, Response) else default diff --git a/src/yoke/providers/opencode/process.py b/src/yoke/providers/opencode/process.py new file mode 100644 index 0000000..3c3f172 --- /dev/null +++ b/src/yoke/providers/opencode/process.py @@ -0,0 +1,125 @@ +"""Synchronous `opencode serve` process spawn. + +Mirrors codex_app/process.py's precedent: a synchronous, thread-backed +process wrapper that async adapter methods bridge to via `asyncio.to_thread`, +rather than a native asyncio subprocess rewrite. This lets the polling-based +progress design (progress.py) share the same threading model. +""" + +from __future__ import annotations + +import os +import queue +import re +import shutil +import subprocess +import threading +import time +from pathlib import Path + +from yoke.errors import YokeError + +OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS = 10.0 +OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS = 5.0 +_LISTENING_PATTERN = re.compile(r"listening on http://127\.0\.0\.1:(\d+)") + + +class OpencodeServerStartupError(YokeError): + pass + + +class OpencodeServerProcess: + def __init__(self, process: subprocess.Popen[str], base_url: str) -> None: + self.process = process + self.base_url = base_url + + def __enter__(self) -> OpencodeServerProcess: + return self + + def __exit__(self, *exc_info: object) -> None: + self.terminate() + + def terminate(self) -> None: + if self.process.poll() is not None: + return + self.process.terminate() + try: + self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + + +def start_opencode_server( + command: str, + cwd: Path, + startup_timeout_seconds: float = OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS, + env: dict[str, str] | None = None, +) -> OpencodeServerProcess: + # Windows: npm-installed opencode ships as a .cmd/.ps1 shim, and + # subprocess.Popen(shell=False) can't launch a bare command name through + # CreateProcess the way a shell would. + resolved = shutil.which(command) or command + # subprocess.Popen(env=...) replaces the environment wholesale, not + # merges it — passing a partial dict (e.g. just OPENCODE_CONFIG_DIR) + # would silently strip HOME/PATH and degrade opencode to whatever + # providers/config it can find without them. Merge onto a copy of the + # real environment instead, matching codex_app/process.py's precedent. + process_env = dict(os.environ) + if env is not None: + process_env.update(env) + process = subprocess.Popen( + (resolved, "serve", "--port", "0"), + cwd=cwd, + env=process_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + base_url = _wait_for_listening(process, startup_timeout_seconds) + except Exception: + process.terminate() + try: + process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + raise + return OpencodeServerProcess(process=process, base_url=base_url) + + +def _wait_for_listening(process: subprocess.Popen[str], timeout_seconds: float) -> str: + assert process.stdout is not None + lines: queue.Queue[str | None] = queue.Queue() + + def _reader() -> None: + assert process.stdout is not None + for line in process.stdout: + lines.put(line) + lines.put(None) + + # A background thread draining stdout, not a blocking readline() loop: + # readline() blocks past any wall-clock deadline check between calls, and + # select() on pipes isn't available on Windows. + threading.Thread(target=_reader, daemon=True).start() + + deadline = time.monotonic() + timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OpencodeServerStartupError( + "opencode serve did not report a listening port in time" + ) + try: + line = lines.get(timeout=remaining) + except queue.Empty as error: + raise OpencodeServerStartupError( + "opencode serve did not report a listening port in time" + ) from error + if line is None: + raise OpencodeServerStartupError( + "opencode serve exited before it started listening" + ) + match = _LISTENING_PATTERN.search(line) + if match is not None: + return f"http://127.0.0.1:{match.group(1)}" diff --git a/src/yoke/providers/opencode/progress.py b/src/yoke/providers/opencode/progress.py new file mode 100644 index 0000000..2caab01 --- /dev/null +++ b/src/yoke/providers/opencode/progress.py @@ -0,0 +1,182 @@ +"""Poll OpenCode's own SQLite db while a run is in flight. + +Emits normalized Events for new parts across the root session and any +sub-agent sessions discovered along the way, and detects a tool call stuck +past a threshold. Runs on a background thread (see opencode_server.py, which +bridges this into the async ProviderAdapter surface via asyncio.to_thread) +because OpenCode's own SSE stream was found unreliable for this purpose in +the prior spike this module is ported from. + +"Live" only covers terminal parts: a tool call still status: "running" +produces no TOOL_USE narration yet — it only feeds the stuck-call check. +""" + +from __future__ import annotations + +import json +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from yoke.errors import YokeError +from yoke.models import Event +from yoke.providers.opencode.db import query_readonly_or_empty +from yoke.providers.opencode.fields import ( + JsonObject, + as_record, + number_field, + string_field, + stringify_json_value, +) +from yoke.providers.opencode.parts import is_task_spawn, map_opencode_part + +Callback = Callable[[Event], None] + +# Joins message to filter to assistant-authored parts only — part rows alone +# don't carry role, and a session's own part table includes the user's/ +# prompt's echoed-back input parts too. +_PARTS_QUERY = """ +SELECT part.id AS id, part.data AS part_data, message.data AS message_data +FROM part +JOIN message ON message.id = part.message_id +WHERE part.session_id = ? +ORDER BY part.time_created +""" + +OPENCODE_POLL_INTERVAL_SECONDS = 2.0 +OPENCODE_STUCK_TOOL_CALL_SECONDS = 240.0 +# OpenCode's tool-execution layer has no internal timeout, so a glob/read/bash +# call can hang forever regardless of model — a confirmed upstream +# reliability gap, not fixable from this adapter, only detectable. +OPENCODE_STUCK_TOOL_CALL_ISSUE_URL = "github.com/anomalyco/opencode/issues/33541" + + +@dataclass +class OpencodeStuckToolCall: + tool_name: str + session_id: str + elapsed_seconds: float + tool_input: str | None = None + + +class OpencodeStuckToolCallError(YokeError): + def __init__(self, info: OpencodeStuckToolCall): + self.info = info + super().__init__( + f'OpenCode\'s "{info.tool_name}" tool call has been stuck for ' + f"{int(info.elapsed_seconds)}s+ with no response (session " + f"{info.session_id}) — this is a known upstream OpenCode " + f"reliability issue ({OPENCODE_STUCK_TOOL_CALL_ISSUE_URL}), not " + "specific to this run." + ) + + +class OpencodeProgressWatchdog: + def __init__( + self, + db_path: Path, + root_session_id: str, + on_event: Callback, + poll_interval_seconds: float = OPENCODE_POLL_INTERVAL_SECONDS, + stuck_after_seconds: float = OPENCODE_STUCK_TOOL_CALL_SECONDS, + known_sessions: set[str] | None = None, + seen_part_ids: set[str] | None = None, + ) -> None: + self.db_path = db_path + self.on_event = on_event + self.poll_interval_seconds = poll_interval_seconds + self.stuck_after_seconds = stuck_after_seconds + + # Callers that span multiple turns on the same session (see + # opencode_server.py's per-session _seen_part_ids/_known_sessions) + # pass their own sets here so a later turn doesn't re-emit an + # earlier turn's already-seen parts as if they were new. + self._known_sessions: set[str] = ( + known_sessions if known_sessions is not None else set() + ) + self._known_sessions.add(root_session_id) + self._seen_part_ids: set[str] = ( + seen_part_ids if seen_part_ids is not None else set() + ) + self.stuck_reason: OpencodeStuckToolCall | None = None + + def run(self, stop_event: threading.Event) -> None: + while not stop_event.is_set(): + self._poll_once() + if self.stuck_reason is not None: + return + stop_event.wait(self.poll_interval_seconds) + # One final pass: the DB is fully written by the time the caller sets + # stop_event (only done after the blocking send returns), so this + # picks up anything the last timed poll cycle missed. + self._poll_once() + + def _poll_once(self) -> None: + for session_id in tuple(self._known_sessions): + self._poll_session(session_id) + if self.stuck_reason is not None: + return + + def _poll_session(self, session_id: str) -> None: + rows = query_readonly_or_empty(self.db_path, _PARTS_QUERY, (session_id,)) + now_ms = int(time.time() * 1000) + for row in rows: + part_id = row["id"] + if part_id in self._seen_part_ids: + continue + part = _parse_part(row["part_data"]) + if part is None: + continue + message = _parse_part(row["message_data"]) + if message is not None and string_field(message, "role") != "assistant": + self._seen_part_ids.add(part_id) + continue + + spawn = is_task_spawn(part) + if spawn is not None: + child_session_id, _ = spawn + self._known_sessions.add(child_session_id) + + if string_field(part, "type") == "tool": + state = as_record(part.get("state")) + if string_field(state, "status") == "running": + self._check_stuck(session_id, part, state, now_ms) + if self.stuck_reason is not None: + return + continue # not terminal yet — re-check next poll + + self._seen_part_ids.add(part_id) + for event in map_opencode_part(part, session_id): + self.on_event(event) + + def _check_stuck( + self, + session_id: str, + part: JsonObject, + state: JsonObject, + now_ms: int, + ) -> None: + time_info = as_record(state.get("time")) + start_ms = number_field(time_info, "start") + if start_ms is None: + return + elapsed_seconds = (now_ms - start_ms) / 1000 + if elapsed_seconds >= self.stuck_after_seconds: + self.stuck_reason = OpencodeStuckToolCall( + tool_name=string_field(part, "tool") or "tool", + session_id=session_id, + elapsed_seconds=elapsed_seconds, + tool_input=stringify_json_value(state.get("input")), + ) + + +def _parse_part(value: object) -> JsonObject | None: + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/src/yoke/providers/opencode/usage.py b/src/yoke/providers/opencode/usage.py new file mode 100644 index 0000000..db5f369 --- /dev/null +++ b/src/yoke/providers/opencode/usage.py @@ -0,0 +1,22 @@ +"""Parse OpenCode's token usage payload into normalized Yoke usage.""" + +from __future__ import annotations + +from pydantic import JsonValue + +from yoke.models import Usage +from yoke.providers.opencode.fields import as_record, number_field + + +def parse_opencode_usage(value: JsonValue | None) -> Usage | None: + obj = as_record(value) + if len(obj) == 0: + return None + cache = as_record(obj.get("cache")) + return Usage( + input_tokens=number_field(obj, "input"), + cached_input_tokens=number_field(cache, "read"), + output_tokens=number_field(obj, "output"), + reasoning_output_tokens=number_field(obj, "reasoning"), + total_tokens=number_field(obj, "total"), + ) diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py new file mode 100644 index 0000000..a04440d --- /dev/null +++ b/src/yoke/providers/opencode_server.py @@ -0,0 +1,1077 @@ +"""OpenCode provider adapter. + +OpenCode has no Python SDK. This surface spawns `opencode serve --port 0` as +a child process and drives it over HTTP. Following the precedent set by +`CodexAppServer` (providers/codex_app/process.py), the process/HTTP/DB- +polling mechanics stay synchronous and thread-backed, and this adapter's +async `ProviderAdapter` methods bridge to them via `asyncio.to_thread`. +""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, field +from pathlib import Path + +from yoke.errors import UnsupportedFeature, YokeError +from yoke.models import ( + Access, + Approval, + Event, + EventKind, + Goal, + GoalRun, + Harness, + Login, + Model, + Permissions, + Provider, + Readiness, + Response, + Run, + RunStatus, + Session, + SessionHistory, + SessionList, + SessionMessage, + SessionSummary, + Surface, + Turn, + Workflow, + WorkflowRun, +) +from yoke.options import ( + ForkOptions, + GoalLoopOptions, + OpencodeOptions, + RunOptions, + SessionOptions, + WorkflowOptions, + opencode_request_handler, +) +from yoke.providers.opencode import http +from yoke.providers.opencode.failures import classify_opencode_failure +from yoke.providers.opencode.fields import JsonObject, as_record, string_field +from yoke.providers.opencode.hooks import ( + OPENCODE_HOOK_PLUGIN_SOURCE, + OpencodeHookBridge, +) +from yoke.providers.opencode.hooks import resolve as resolve_hook_request +from yoke.providers.opencode.parts import final_text_from_parts +from yoke.providers.opencode.permissions import OpencodePermissionWatchdog +from yoke.providers.opencode.process import ( + OpencodeServerProcess, + OpencodeServerStartupError, + start_opencode_server, +) +from yoke.providers.opencode.progress import ( + OPENCODE_POLL_INTERVAL_SECONDS, + OPENCODE_STUCK_TOOL_CALL_SECONDS, + OpencodeProgressWatchdog, + OpencodeStuckToolCallError, +) +from yoke.providers.opencode.usage import parse_opencode_usage +from yoke.providers.runtime_deployment import RuntimeDeployment, deploy_runtime +from yoke.surfaces import capabilities_for +from yoke.workflows import native_workflow_unsupported + +OPENCODE_COMMAND = "opencode" +OPENCODE_MODEL_SEPARATOR = "/" +OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS = 5.0 +OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS = 5.0 +OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS = 10.0 +OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS = 900.0 +OPENCODE_NOT_INSTALLED_MESSAGE = "opencode not found on PATH" +OPENCODE_SERVER_REPAIR = "run `opencode serve` directly to check for a startup error" +OPENCODE_PROVIDER_REPAIR = ( + "sign in with `opencode auth login` or configure a provider API key" +) +# How long the main thread waits between checks of the watchdog's +# stuck_reason while the sender thread is still alive. +OPENCODE_STUCK_CHECK_INTERVAL_SECONDS = 1.0 + + +def split_opencode_model(model: str) -> tuple[str, str]: + provider_id, separator, model_id = model.partition(OPENCODE_MODEL_SEPARATOR) + if separator == "" or provider_id == "" or model_id == "": + raise ValueError(f'opencode model must be "provider/model", got: {model!r}') + return provider_id, model_id + + +@dataclass +class _OpencodeSession: + process: OpencodeServerProcess + session_id: str + cwd: Path + environment: dict[str, str] | None + db_path: Path + # Persist across turns so a later send() doesn't re-poll and re-emit an + # earlier turn's already-seen parts through on_event. + known_sessions: set[str] = field(default_factory=set) + seen_part_ids: set[str] = field(default_factory=set) + # Persisted the same way as seen_part_ids, so a permission answered on an + # earlier turn isn't rediscovered and re-resolved on a later one. + seen_permission_ids: set[str] = field(default_factory=set) + # POST /session/:id/message's `system` field is per-message, not + # session-persistent (confirmed live) — sent on every turn from this, + # not tracked as a one-time flag. + instructions: str | None = None + # Session-level provider.opencode options (mirrors CodexAppServer's + # thread.provider_options): a later turn's RunOptions.provider.opencode + # overrides this, but falls back here when a turn doesn't set one. + provider_options: OpencodeOptions | dict[str, object] | None = None + # Set for the duration of one _send() call so a live tool.execute.before + # hook (OpencodeHookBridge — a long-lived, process-scoped server that + # outlives any one turn) can still emit its resolved event into whatever + # turn is currently in flight, and resolve against that turn's own + # RunOptions.provider.opencode override rather than the fixed + # session-start handler. Both None between turns and while idle. + current_emit: Callable[[Event], None] | None = None + current_request_handler: object | None = None + + +class OpencodeServer: + """Adapter for `opencode serve --port 0`.""" + + provider: Provider = "opencode" + surface = "opencode_server" + capabilities = capabilities_for(provider, surface) + + def __init__( + self, + command: str = OPENCODE_COMMAND, + *, + db_path: Path | None = None, + poll_interval_seconds: float = OPENCODE_POLL_INTERVAL_SECONDS, + stuck_after_seconds: float = OPENCODE_STUCK_TOOL_CALL_SECONDS, + ) -> None: + self.command = command + self._db_path_override = db_path + self.poll_interval_seconds = poll_interval_seconds + self.stuck_after_seconds = stuck_after_seconds + self._sessions: dict[str, _OpencodeSession] = {} + # A forked session shares its parent's server process (fork() opens + # a new session id on the same running `opencode serve` instance, + # not a new process), so termination is reference-counted rather + # than tied to any one session — mirrors CodexAppServer's + # _retain_process/_release_process. + self._process_refs: dict[int, int] = {} + # Keyed by id(process), not by session: a fork shares its parent's + # runtime deployment (skills dir, OPENCODE_CONFIG_DIR), so cleanup + # must wait for the last session referencing that process to close, + # not whichever session happens to close first. Mirrors + # CodexAppServer's own _deployments keyed by id(process). + self._deployments: dict[int, RuntimeDeployment] = {} + # Same id(process) keying as _deployments: one bridge per process, + # shared across forks, torn down alongside the deployment. + self._hook_bridges: dict[int, OpencodeHookBridge] = {} + + async def check(self, harness: Harness) -> Readiness: + return await asyncio.to_thread(self._check, harness.cwd, harness.environment) + + async def run(self, harness: Harness, prompt: str, options: RunOptions) -> Run: + # Regression: provider (needed to deploy the tool-hook bridge — + # OpencodeHookBridge only ever reads session-creation-time options, + # see _maybe_deploy_hook_bridge) was dropped here, matching neither + # CodexAppServer.run() nor this adapter's own per-turn permission + # resolution, which does see RunOptions.provider via + # opencode_options_for_run() in _send(). + session = await self.start( + harness, + SessionOptions( + model=options.model, + goal=options.goal, + inherit_goal=options.inherit_goal, + effort=options.effort, + permissions=options.permissions, + provider=options.provider, + ), + ) + try: + return await self.send(session, Turn(prompt=prompt), options) + finally: + await self.close(session) + + async def models(self, harness: Harness) -> tuple[Model, ...]: + return await asyncio.to_thread(self._models, harness.cwd, harness.environment) + + async def workflow( + self, + harness: Harness, + workflow: Workflow, + prompt: str, + options: WorkflowOptions, + ) -> WorkflowRun: + raise native_workflow_unsupported( + harness, + workflow, + options, + reason="OpenCode has no documented native workflow DSL.", + ) + + async def goal_loop(self, harness: Harness, options: GoalLoopOptions) -> GoalRun: + raise UnsupportedFeature("OpenCode has no goal-loop concept.") + + async def get_goal(self, session: Session) -> Goal | None: + raise UnsupportedFeature("OpenCode has no goal concept.") + + async def set_goal(self, session: Session, goal: Goal) -> Session: + raise UnsupportedFeature("OpenCode has no goal concept.") + + async def clear_goal(self, session: Session) -> Session: + raise UnsupportedFeature("OpenCode has no goal concept.") + + async def login( + self, + harness: Harness, + method: str, + *, + api_key: str | None = None, + ) -> Login: + if method != "api_key" or api_key is None: + raise UnsupportedFeature( + "OpenCode adapter only wires api_key login (PUT /auth/:id); " + "OAuth authorize/callback exists in the API but is not wired " + "in this adapter yet." + ) + provider_id = ( + harness.agent.model.split(OPENCODE_MODEL_SEPARATOR, 1)[0] + if (harness.agent.model and OPENCODE_MODEL_SEPARATOR in harness.agent.model) + else None + ) + if provider_id is None: + raise UnsupportedFeature( + "api_key login needs a provider id; set Harness.agent.model to " + '"provider/model" first.' + ) + await asyncio.to_thread( + self._login, harness.cwd, harness.environment, provider_id, api_key + ) + return Login( + provider=self.provider, + surface=self.surface, + method="api_key", + success=True, + ) + + async def list_sessions( + self, + harness: Harness, + *, + limit: int | None = None, + cursor: str | None = None, + cwd: str | Path | None = None, + include_worktrees: bool = True, + ) -> SessionList: + if cursor is not None: + raise UnsupportedFeature("OpenCode session listing is not cursor-paged.") + if not include_worktrees: + raise UnsupportedFeature( + "OpenCode session listing has no worktree-exclusion filter." + ) + sessions = await asyncio.to_thread( + self._list_sessions, harness.cwd, harness.environment, limit, cwd + ) + return SessionList( + provider=self.provider, surface=self.surface, sessions=sessions + ) + + async def read_session( + self, + harness: Harness, + session_id: str, + *, + include_messages: bool = True, + limit: int | None = None, + offset: int = 0, + ) -> SessionHistory: + return await asyncio.to_thread( + self._read_session, + harness.cwd, + harness.environment, + session_id, + include_messages, + limit, + offset, + ) + + async def rename(self, session: Session, title: str) -> SessionSummary: + internal = self._require_internal(session) + record = await asyncio.to_thread( + http.rename_session, + internal.process.base_url, + internal.session_id, + title, + OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + ) + return _session_summary(record, self.provider, self.surface) + + async def tag(self, session: Session, tag: str | None) -> SessionSummary: + raise UnsupportedFeature("OpenCode does not expose a session tag API.") + + async def fork(self, session: Session, options: ForkOptions) -> Session: + internal = self._require_internal(session) + record = await asyncio.to_thread( + http.fork_session, + internal.process.base_url, + internal.session_id, + OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + message_id=options.last_turn_id, + ) + forked_id = string_field(record, "id") + if forked_id is None: + raise YokeError("opencode fork did not return a session id") + # OpenCode's fork endpoint does not inherit the parent session's + # permission ruleset — confirmed live: a session created with + # bash denied produced a fork with no ruleset at all (default + # allow), so bash ran freely on the fork despite the returned + # Session.permissions still claiming the parent's restricted + # posture. Re-apply it explicitly; PATCH /session/:id is the only + # documented way to set a ruleset after creation. + if session.permissions is not None: + await asyncio.to_thread( + http.update_session_permission, + internal.process.base_url, + forked_id, + OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + permission=_session_permission_block(session.permissions), + ) + forked = _OpencodeSession( + process=internal.process, + session_id=forked_id, + cwd=internal.cwd, + environment=internal.environment, + db_path=internal.db_path, + # `system` is per-message, not persisted server-side (see _send), + # so the fork must keep sending it on every turn just like the + # parent did — carrying it over here, not marking it as already + # sent. + instructions=internal.instructions, + # Same reasoning as instructions: a fork should keep answering + # permissions the way the parent session was configured to, + # not silently fall back to "no handler configured" default-deny. + provider_options=internal.provider_options, + ) + self._retain_process(internal.process) + self._sessions[forked_id] = forked + return Session( + provider=self.provider, + surface=self.surface, + id=forked_id, + provider_session_id=forked_id, + agent=session.agent, + cwd=session.cwd, + permissions=session.permissions, + model=session.model, + ) + + async def interrupt(self, session: Session) -> None: + internal = self._require_internal(session) + await asyncio.to_thread( + http.abort_session, + internal.process.base_url, + internal.session_id, + OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + ) + + async def compact(self, session: Session) -> None: + internal = self._require_internal(session) + if session.model is None: + raise YokeError("opencode compact needs a session model to summarize with.") + provider_id, model_id = split_opencode_model(session.model) + await asyncio.to_thread( + http.summarize_session, + internal.process.base_url, + internal.session_id, + provider_id, + model_id, + OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, + ) + + async def start(self, harness: Harness, options: SessionOptions) -> Session: + deployment = deploy_runtime( + harness.agent, Provider.OPENCODE, harness.runtime_root + ) + try: + internal = await asyncio.to_thread( + self._start_session, harness, options, deployment + ) + except Exception: + deployment.cleanup() + raise + self._retain_process(internal.process) + self._deployments[id(internal.process)] = deployment + self._sessions[internal.session_id] = internal + return Session( + provider=self.provider, + surface=self.surface, + id=internal.session_id, + provider_session_id=internal.session_id, + agent=harness.agent, + cwd=harness.cwd, + permissions=_resolved_permissions(harness, options), + model=options.model or harness.agent.model, + runtime_root=harness.runtime_root, + ) + + async def send(self, session: Session, turn: Turn, options: RunOptions) -> Run: + internal = self._require_internal(session) + model = options.model or turn.model or session.model + if model is None: + raise YokeError('opencode send needs a model ("provider/model").') + return await asyncio.to_thread(self._send, internal, turn, model, options) + + async def stream( + self, + session: Session, + turn: Turn, + options: RunOptions, + ) -> AsyncIterator[Event]: + internal = self._require_internal(session) + model = options.model or turn.model or session.model + if model is None: + raise YokeError('opencode stream needs a model ("provider/model").') + loop = asyncio.get_running_loop() + queue: asyncio.Queue[Event | None] = asyncio.Queue() + user_on_event = options.on_event + + def relay(event: Event) -> None: + if user_on_event is not None: + user_on_event(event) + loop.call_soon_threadsafe(queue.put_nowait, event) + + # _send already emits every event through options.on_event as it + # happens (the DB-poll watchdog calls it live, turn by turn), so + # streaming just needs to relay those same events onto an asyncio + # queue instead of collecting them into a Run — no separate + # streaming implementation to keep in sync with _send's logic. + streaming_options = options.model_copy(update={"on_event": relay}) + send_task = asyncio.ensure_future( + asyncio.to_thread(self._send, internal, turn, model, streaming_options) + ) + send_task.add_done_callback( + lambda _task: loop.call_soon_threadsafe(queue.put_nowait, None) + ) + try: + while True: + event = await queue.get() + if event is None: + break + yield event + finally: + run = await send_task + if run.status == RunStatus.FAILED: + message = run.failure.message if run.failure is not None else run.output + raise YokeError(message) + + async def close(self, session: Session) -> None: + internal = self._sessions.pop(session.id, None) + if internal is None: + return + await asyncio.to_thread(self._release_process, internal.process) + + # -- synchronous internals, bridged via asyncio.to_thread above -- + + def _require_internal(self, session: Session) -> _OpencodeSession: + internal = self._sessions.get(session.id) + if internal is None: + raise YokeError(f"no live opencode session for {session.id!r}") + return internal + + def _retain_process(self, process: OpencodeServerProcess) -> None: + key = id(process) + self._process_refs[key] = self._process_refs.get(key, 0) + 1 + + def _release_process(self, process: OpencodeServerProcess) -> None: + key = id(process) + count = self._process_refs.get(key, 0) + if count <= 1: + self._process_refs.pop(key, None) + process.terminate() + deployment = self._deployments.pop(key, None) + if deployment is not None: + deployment.cleanup() + bridge = self._hook_bridges.pop(key, None) + if bridge is not None: + bridge.stop() + return + self._process_refs[key] = count - 1 + + def _check(self, cwd: Path, environment: dict[str, str]) -> Readiness: + try: + with self._brief_server(cwd, environment) as server: + providers = http.get_providers( + server.base_url, OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS + ) + except FileNotFoundError: + return Readiness( + provider=self.provider, + surface=self.surface, + available=False, + message=OPENCODE_NOT_INSTALLED_MESSAGE, + fix="Install OpenCode: npm install -g opencode-ai", + ) + except OpencodeServerStartupError as error: + return Readiness( + provider=self.provider, + surface=self.surface, + available=False, + message=str(error), + fix=OPENCODE_SERVER_REPAIR, + ) + if len(providers) == 0: + return Readiness( + provider=self.provider, + surface=self.surface, + available=False, + message="no opencode providers are configured", + fix=OPENCODE_PROVIDER_REPAIR, + ) + names = ", ".join( + string_field(item, "name") or string_field(item, "id") or "provider" + for item in providers + ) + return Readiness( + provider=self.provider, + surface=self.surface, + available=True, + message=f"opencode providers configured: {names}", + ) + + def _models(self, cwd: Path, environment: dict[str, str]) -> tuple[Model, ...]: + with self._brief_server(cwd, environment) as server: + providers = http.get_providers( + server.base_url, OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS + ) + models: list[Model] = [] + for provider in providers: + provider_id = string_field(provider, "id") + if provider_id is None: + continue + entries = provider.get("models") + model_ids = _model_ids(entries) + for model_id in model_ids: + models.append(Model(id=f"{provider_id}/{model_id}", raw=provider)) + return tuple(models) + + def _brief_server(self, cwd: Path, environment: dict[str, str]): + return start_opencode_server( + self.command, + cwd, + OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS, + env=environment, + ) + + def _login( + self, + cwd: Path, + environment: dict[str, str], + provider_id: str, + api_key: str, + ) -> None: + server = start_opencode_server( + self.command, + cwd, + OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS, + env=environment, + ) + try: + http.set_auth( + server.base_url, + provider_id, + api_key, + OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + ) + finally: + server.terminate() + + def _list_sessions( + self, + cwd: Path, + environment: dict[str, str], + limit: int | None, + directory: str | Path | None, + ) -> tuple[SessionSummary, ...]: + server = start_opencode_server( + self.command, + cwd, + OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS, + env=environment, + ) + try: + # Only filters by directory when a caller explicitly asks — + # preserves the existing global (unfiltered) default rather than + # silently narrowing every unscoped list_sessions() call to + # harness.cwd. + records = http.list_sessions( + server.base_url, + OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, + directory=str(directory) if directory is not None else None, + ) + finally: + server.terminate() + if limit is not None: + records = records[:limit] + return tuple( + _session_summary(record, self.provider, self.surface) for record in records + ) + + def _read_session( + self, + cwd: Path, + environment: dict[str, str], + session_id: str, + include_messages: bool, + limit: int | None, + offset: int, + ) -> SessionHistory: + server = start_opencode_server( + self.command, + cwd, + OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS, + env=environment, + ) + try: + record = http.read_session( + server.base_url, session_id, OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS + ) + messages: tuple[SessionMessage, ...] = () + if include_messages: + # Routed through the documented GET /session/:id/message API + # (session.messages) rather than the private SQLite schema — + # also the fix for limit/offset being accepted but silently + # ignored: OpenCode's own `limit` keeps the most *recent* N + # messages, which doesn't compose with offset over the full + # oldest-first order, so the full list is fetched and sliced + # locally instead. + entries = http.list_messages( + server.base_url, session_id, OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS + ) + if limit is not None: + entries = entries[offset : offset + limit] + else: + entries = entries[offset:] + messages = tuple( + _session_message(entry, self.provider, self.surface, session_id) + for entry in entries + ) + finally: + server.terminate() + summary = _session_summary(record, self.provider, self.surface) + return SessionHistory( + provider=self.provider, + surface=self.surface, + session=summary, + messages=messages, + ) + + def _start_session( + self, + harness: Harness, + options: SessionOptions, + deployment: RuntimeDeployment, + ) -> _OpencodeSession: + environment = dict(harness.environment) + # May set deployment.opencode_config_dir for the first time (no + # skills/agents/MCP configured, only hooks) — run before the + # OPENCODE_CONFIG_DIR check below so it's picked up either way. + hook_bridge = self._maybe_deploy_hook_bridge(options, deployment) + if deployment.opencode_config_dir is not None: + environment["OPENCODE_CONFIG_DIR"] = str(deployment.opencode_config_dir) + if deployment.opencode_config_content is not None: + environment["OPENCODE_CONFIG_CONTENT"] = deployment.opencode_config_content + if hook_bridge is not None: + environment["YOKE_HOOK_BRIDGE_URL"] = hook_bridge.base_url + try: + server = start_opencode_server( + self.command, + harness.cwd, + OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS, + env=environment, + ) + except Exception: + # Regression: the bridge previously started (it must be up + # before spawn, since its URL goes into the child's env) but + # this whole try/except only ever wrapped the create_session + # call below — a startup failure (missing binary, port + # exhaustion, timeout) leaked the bridge's thread and listening + # socket for the life of the process. + if hook_bridge is not None: + hook_bridge.stop() + raise + try: + record = http.create_session( + server.base_url, + str(harness.cwd), + harness.agent.description or "yoke run", + OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, + permission=_session_permission_block( + _resolved_permissions(harness, options) + ), + ) + session_id = string_field(record, "id") + if session_id is None: + raise OpencodeServerStartupError("opencode did not return a session id") + except Exception: + server.terminate() + if hook_bridge is not None: + hook_bridge.stop() + raise + if hook_bridge is not None: + self._hook_bridges[id(server)] = hook_bridge + return _OpencodeSession( + process=server, + session_id=session_id, + cwd=harness.cwd, + environment=environment or None, + db_path=self._resolve_db_path(), + instructions=harness.agent.instructions, + provider_options=opencode_options(options), + ) + + def _maybe_deploy_hook_bridge( + self, + options: SessionOptions, + deployment: RuntimeDeployment, + ) -> OpencodeHookBridge | None: + """Deploy the tool.execute.before hook plugin, only if opted into. + + No caller-configured request_handler/policy means no plugin file + and no bridge server — zero overhead for sessions that don't use + hooks, matching how MCP/skills/agents only ever write files a + caller's Agent actually asked for. + """ + + handler = opencode_request_handler(opencode_options(options)) + if handler is None: + return None + config_dir = deployment.root / "opencode_config" + plugin_path = config_dir / "plugin" / "yoke_tool_hook.js" + plugin_path.parent.mkdir(parents=True, exist_ok=True) + plugin_path.write_text(OPENCODE_HOOK_PLUGIN_SOURCE) + deployment.opencode_config_dir = config_dir + bridge = OpencodeHookBridge(resolve=self._resolve_hook_request) + bridge.start() + return bridge + + def _resolve_hook_request(self, session_id: str, payload: JsonObject) -> Response: + internal = self._sessions.get(session_id) + # Per-turn override, matching the permission watchdog's own + # opencode_options_for_run(options)-or-session-level fallback — set + # for the duration of the turn currently in flight (_send), already + # falls back to the session-level handler internally when a turn + # doesn't set its own. None outside any active turn. + handler = internal.current_request_handler if internal is not None else None + event, response = resolve_hook_request(payload, handler) + if internal is not None and internal.current_emit is not None: + internal.current_emit( + event.model_copy( + update={"kind": EventKind.REQUEST_RESOLVED, "response": response} + ) + ) + return response + + def _resolve_db_path(self) -> Path: + if self._db_path_override is not None: + return self._db_path_override + # Fixed, HOME-relative path confirmed live in the CodeAlmanac spike + # this adapter is ported from (2026-07-08) — not affected by + # OPENCODE_CONFIG_DIR, which only relocates skill/agent discovery. + return Path.home() / ".local" / "share" / "opencode" / "opencode.db" + + def _send( + self, + internal: _OpencodeSession, + turn: Turn, + model: str, + options: RunOptions, + ) -> Run: + provider_id, model_id = split_opencode_model(model) + prompt = turn.prompt + # OpenCode's POST /session/:id/message has a documented `system` + # field (confirmed live, v1.17.15) — pass instructions through it + # instead of prepending them to the prompt text, so system and user + # content stay separate. + # + # Regression: this used to be sent only once, on the session's + # first turn, on the (wrong) assumption that OpenCode retains it + # session-side like a persistent system prompt. Confirmed live + # (2026-07-14): it does not — LLMRequest.prepare() reads `system` + # from the *current* message only, so a second turn sent without it + # runs with no system prompt at all, and every fork (which marked + # instructions_sent=True to avoid "re-sending") never got the + # instructions in the first place. Sent on every turn instead; no + # instructions_sent state needed anymore. + system = internal.instructions + events: list = [] + on_event = options.on_event + + def emit(event) -> None: + events.append(event) + if on_event is not None: + on_event(event) + + # RunOptions.provider.opencode overrides the session-level handler + # for this turn only, same fallback order the permission watchdog + # already used — computed once here so hooks and permissions agree + # on which handler answers *this* turn instead of hooks silently + # keeping whatever handler was live at session-start time. + effective_options = ( + opencode_options_for_run(options) or internal.provider_options + ) + # Lets a live tool.execute.before hook call (OpencodeHookBridge, + # providers/opencode/hooks.py) emit its resolved event into *this* + # turn's stream, and resolve against *this* turn's handler — the + # bridge is a long-lived, process-scoped server (env-var-addressed + # at process spawn, shared across forks), so it can't bind to one + # turn's closures/options at construction time. + internal.current_emit = emit + internal.current_request_handler = opencode_request_handler(effective_options) + try: + return self._send_turn( + internal, + prompt, + system, + provider_id, + model_id, + options, + emit, + events, + effective_options, + ) + finally: + internal.current_emit = None + internal.current_request_handler = None + + def _send_turn( + self, + internal: _OpencodeSession, + prompt: str, + system: str | None, + provider_id: str, + model_id: str, + options: RunOptions, + emit: Callable[[Event], None], + events: list, + effective_options: OpencodeOptions | dict[str, object] | None, + ) -> Run: + # Emitted first and unconditionally, matching CodexAppServer's + # _send(): Run.provider_session_id scans events in reverse for the + # first one carrying provider_session_id, so callers (e.g. + # CodeAlmanac's transcript-ref linking) depend on this existing at + # all, not just on Session.provider_session_id being set. + emit( + Event( + kind=EventKind.PROVIDER_SESSION, + surface=Surface.OPENCODE_SERVER, + message=f"opencode provider session {internal.session_id}", + provider_session_id=internal.session_id, + ) + ) + + watchdog = OpencodeProgressWatchdog( + db_path=internal.db_path, + root_session_id=internal.session_id, + on_event=emit, + poll_interval_seconds=self.poll_interval_seconds, + stuck_after_seconds=self.stuck_after_seconds, + known_sessions=internal.known_sessions, + seen_part_ids=internal.seen_part_ids, + ) + stop_event = threading.Event() + watchdog_thread = threading.Thread( + target=watchdog.run, args=(stop_event,), daemon=True + ) + watchdog_thread.start() + + permission_watchdog = OpencodePermissionWatchdog( + base_url=internal.process.base_url, + session_id=internal.session_id, + on_event=emit, + request_handler=opencode_request_handler(effective_options), + poll_interval_seconds=self.poll_interval_seconds, + seen_permission_ids=internal.seen_permission_ids, + ) + permission_thread = threading.Thread( + target=permission_watchdog.run, args=(stop_event,), daemon=True + ) + permission_thread.start() + + message_result: dict[str, JsonObject | Exception] = {} + + def _post() -> None: + try: + message_result["response"] = http.post_message( + internal.process.base_url, + internal.session_id, + str(internal.cwd), + provider_id, + model_id, + prompt, + options.timeout_seconds or OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, + system=system, + ) + except Exception as error: # noqa: BLE001 - surfaced below + message_result["error"] = error + + sender_thread = threading.Thread(target=_post, daemon=True) + sender_thread.start() + + while sender_thread.is_alive(): + if watchdog.stuck_reason is not None: + # Unwinds and terminates the server below, killing the + # connection the sender thread is blocked on. + stop_event.set() + internal.process.terminate() + failure = classify_opencode_failure( + str(OpencodeStuckToolCallError(watchdog.stuck_reason)) + ) + return Run( + provider=self.provider, + surface=self.surface, + status=RunStatus.FAILED, + output=failure.message, + events=tuple(events), + failure=failure, + ) + sender_thread.join(timeout=OPENCODE_STUCK_CHECK_INTERVAL_SECONDS) + + stop_event.set() + watchdog_thread.join(timeout=self.poll_interval_seconds * 2 + 5) + permission_thread.join(timeout=self.poll_interval_seconds * 2 + 5) + + if "error" in message_result: + error = message_result["error"] + failure = classify_opencode_failure(str(error)) + return Run( + provider=self.provider, + surface=self.surface, + status=RunStatus.FAILED, + output=failure.message, + events=tuple(events), + failure=failure, + ) + response = as_record(message_result["response"]) + info = as_record(response.get("info")) + raw_parts = response.get("parts") + parts = ( + [as_record(part) for part in raw_parts] + if isinstance(raw_parts, list) + else [] + ) + text = final_text_from_parts(parts) + usage = parse_opencode_usage(info.get("tokens")) + return Run( + provider=self.provider, + surface=self.surface, + status=RunStatus.SUCCEEDED, + output=text or "opencode completed", + events=tuple(events), + usage=usage, + ) + + +def _resolved_permissions(harness: Harness, options: SessionOptions) -> Permissions: + return options.permissions or harness.permissions or harness.agent.permissions + + +def _session_permission_block(permissions: Permissions) -> tuple[JsonObject, ...]: + """Return the session-creation permission block for a resolved posture. + + `Approval.ASK` is the only posture this adapter can honor with a live + signal now that `GET /permission` + `POST /permission/:id/reply` are + wired (OpencodePermissionWatchdog, providers/opencode/permissions.py, + confirmed live in a 2026-07-12 spike) — AUTO and NEVER both mean "don't + ask", matching Codex's own approval_policy() mapping + (providers/codex_app/policy.py). + + `access`/`network` are also translated here, gating the same tool + categories `accessible_claude_tools()` (providers/claude.py) gates: + write/edit/apply_patch need WRITE or FULL, bash needs FULL, webfetch/ + websearch need `network`. Without this, `Permissions(access=READ, + network=False, approval=NEVER)` produced a bare `* = allow` rule and the + session reported read-only/no-network while every tool actually ran. + + Rule order matters and was confirmed live (2026-07-13 spike): OpenCode + resolves a later rule over an earlier one when both match, so the + wildcard base rule must come first and per-tool denies after it — a + deny listed *before* a trailing `* = allow` is silently overridden by + that later wildcard. + """ + + base_action = "ask" if permissions.approval is Approval.ASK else "allow" + rules: list[JsonObject] = [ + {"permission": "*", "pattern": "*", "action": base_action} + ] + if permissions.access not in (Access.WRITE, Access.FULL): + for tool in ("write", "edit", "apply_patch"): + rules.append({"permission": tool, "pattern": "*", "action": "deny"}) + if permissions.access is not Access.FULL: + rules.append({"permission": "bash", "pattern": "*", "action": "deny"}) + if not permissions.network: + for tool in ("webfetch", "websearch"): + rules.append({"permission": tool, "pattern": "*", "action": "deny"}) + return tuple(rules) + + +def opencode_options(options: SessionOptions) -> OpencodeOptions | dict[str, object]: + if options.provider is None: + return {} + return options.provider.opencode + + +def opencode_options_for_run( + options: RunOptions, +) -> OpencodeOptions | dict[str, object] | None: + if options.provider is None: + return None + return options.provider.opencode + + +def _model_ids(entries: object) -> tuple[str, ...]: + if isinstance(entries, dict): + return tuple(str(key) for key in entries) + if isinstance(entries, list): + ids = [] + for entry in entries: + record = as_record(entry) if isinstance(entry, dict) else {} + model_id = string_field(record, "id") if record else None + if model_id is not None: + ids.append(model_id) + return tuple(ids) + return () + + +def _session_summary( + record: JsonObject, + provider: Provider, + surface: str, +) -> SessionSummary: + session_id = string_field(record, "id") or "" + return SessionSummary( + provider=provider, + surface=surface, + id=session_id, + provider_session_id=session_id or None, + title=string_field(record, "title"), + raw=record, + ) + + +def _session_message( + entry: JsonObject, + provider: Provider, + surface: str, + session_id: str, +) -> SessionMessage: + info = as_record(entry.get("info")) + return SessionMessage( + provider=provider, + surface=surface, + session_id=session_id, + id=string_field(info, "id"), + role=string_field(info, "role"), + raw=entry, + ) diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py index c97285e..a914f40 100644 --- a/src/yoke/providers/runtime_deployment.py +++ b/src/yoke/providers/runtime_deployment.py @@ -16,6 +16,7 @@ codex_agent_toml, description_for, instructions_for, + option_mapping, slug, ) from yoke.providers.codex_app.prompts import native_subagents @@ -26,6 +27,7 @@ native_skill_text, skill_directory_name, ) +from yoke.providers.opencode.agents import opencode_agent_files @dataclass(slots=True) @@ -41,6 +43,8 @@ class RuntimeDeployment: generated_skill_root: Path | None = None claude_plugin_root: Path | None = None claude_plugin_name: str | None = None + opencode_config_dir: Path | None = None + opencode_config_content: str | None = None def cleanup(self) -> None: """Remove only this deployment, never authored files.""" @@ -74,6 +78,8 @@ def deploy_runtime( try: if provider is Provider.CODEX: _write_codex(agent, deployment) + elif provider is Provider.OPENCODE: + _write_opencode(agent, deployment) else: _write_claude(agent, deployment) return deployment @@ -105,10 +111,16 @@ def reclaim_stale_deployments(parent: Path) -> None: def runtime_owner_pid(name: str) -> int | None: parts = name.split("-", 3) - if len(parts) != 4 or parts[0] != "yoke" or parts[1] not in { - Provider.CLAUDE.value, - Provider.CODEX.value, - }: + if ( + len(parts) != 4 + or parts[0] != "yoke" + or parts[1] + not in { + Provider.CLAUDE.value, + Provider.CODEX.value, + Provider.OPENCODE.value, + } + ): return None try: pid = int(parts[2]) @@ -251,6 +263,68 @@ def _write_claude(agent: Agent, deployment: RuntimeDeployment) -> None: deployment.claude_plugin_name = deployment.root.name +def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None: + # OpenCode discovers skills and custom agents from a config directory + # the same shape as its own `.opencode/` project convention + # (skills//SKILL.md, agents/.md), pointed at via + # OPENCODE_CONFIG_DIR — no files land in the user's real project, unlike + # a naive `.opencode/` write into harness.cwd. + config_dir = deployment.root / "opencode_config" + skills = config_dir / "skills" + wrote_skills = bool(_write_skills(inline_skills(agent), Provider.OPENCODE, skills)) + wrote_agents = False + agent_files = opencode_agent_files(agent, directory="agents") + agent_paths = [file.path for file in agent_files] + if len(set(agent_paths)) != len(agent_paths): + # Mirrors _write_codex's own check: two subagent names that slugify + # to the same filename (e.g. "Code Review" and "code-review") would + # otherwise silently overwrite one another with no error. + raise YokeError( + "OpenCode subagents compile to colliding agents/.md paths" + ) + for file in agent_files: + path = config_dir / file.path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(file.text) + wrote_agents = True + wrote_plugins = False + for name, source in _opencode_plugin_sources(agent).items(): + path = config_dir / "plugin" / f"{slug(name)}.js" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + wrote_plugins = True + if wrote_skills or wrote_agents or wrote_plugins: + deployment.opencode_config_dir = config_dir + mcp_servers = option_mapping(agent, "mcp_servers") + if mcp_servers: + # Inline config, not a file: OPENCODE_CONFIG_CONTENT is merged over + # every other config source (global, project, OPENCODE_CONFIG_DIR) + # at OpenCode's highest precedence, so this doesn't collide with the + # skills directory above or clobber a real project's opencode.json. + deployment.opencode_config_content = json.dumps({"mcp": mcp_servers}) + + +def _opencode_plugin_sources(agent: Agent) -> dict[str, str]: + """Return caller-supplied OpenCode plugin JS source, keyed by name. + + Pure pass-through, same as mcp_servers: Yoke does not generate this + plugin's contents, only writes what `agent.options["opencode_plugins"]` + already contains into a file OpenCode auto-loads. The *generated* + tool.execute.before hook plugin (OpencodeHookBridge, + providers/opencode/hooks.py) is a different, session-options-driven + mechanism and writes its own separate file into the same directory. + """ + + value = agent.options.get("opencode_plugins") + if not isinstance(value, dict): + return {} + return { + str(name): source + for name, source in value.items() + if isinstance(name, str) and isinstance(source, str) + } + + def _write_skills( skills: tuple[Skill, ...], provider: Provider, diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py index 17c30d4..9c4b364 100644 --- a/src/yoke/surfaces.py +++ b/src/yoke/surfaces.py @@ -368,6 +368,8 @@ def feature_recipes( def default_surface(provider: Provider) -> Surface: if provider is Provider.CLAUDE: return Surface.CLAUDE_PYTHON_SDK + if provider is Provider.OPENCODE: + return Surface.OPENCODE_SERVER return Surface.CODEX_APP_SERVER @@ -400,6 +402,7 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: (Provider.CODEX, Surface.CODEX_PYTHON_SDK): Channel.SDK, (Provider.CODEX, Surface.CODEX_TYPESCRIPT_SDK): Channel.SDK, (Provider.CODEX, Surface.CODEX_APP_SERVER): Channel.APP_SERVER, + (Provider.OPENCODE, Surface.OPENCODE_SERVER): Channel.APP_SERVER, } @@ -411,6 +414,7 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: (Provider.CODEX, Surface.CODEX_PYTHON_SDK): "codex_app_server", (Provider.CODEX, Surface.CODEX_TYPESCRIPT_SDK): "codex_sdk", (Provider.CODEX, Surface.CODEX_APP_SERVER): "codex_app_server", + (Provider.OPENCODE, Surface.OPENCODE_SERVER): "opencode_server", } @@ -448,6 +452,13 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: "https://developers.openai.com/codex/app-server", "https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md", ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER): ( + "https://opencode.ai/docs/server/", + "https://opencode.ai/docs/skills/", + "https://opencode.ai/docs/mcp-servers/", + "https://opencode.ai/docs/config/", + "https://opencode.ai/docs/providers/", + ), } @@ -613,6 +624,61 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: "https://developers.openai.com/codex/app-server", "https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md", ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): ( + "https://opencode.ai/docs/server/", + "https://opencode.ai/docs/providers/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): ( + "https://opencode.ai/docs/skills/", + "https://opencode.ai/docs/config/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FILESYSTEM_AGENT): ( + "https://opencode.ai/docs/agents/", + "https://opencode.ai/docs/config/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.DECLARED_SUBAGENTS): ( + "https://opencode.ai/docs/agents/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INLINE_SUBAGENTS): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): ( + "https://opencode.ai/docs/server/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): ( + "https://opencode.ai/docs/mcp-servers/", + "https://opencode.ai/docs/config/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PLUGINS): ( + "https://opencode.ai/docs/plugins/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.HOOKS): ( + "https://opencode.ai/docs/plugins/", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): ( + "https://opencode.ai/docs/server/", + ), } @@ -766,6 +832,109 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: "Codex /goal is provider-native interactive behavior; Yoke's codex_cli " "adapter remains a bounded codex exec wrapper." ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): ( + "Harness.sessions() calls GET /session." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): ( + "Harness.read_session() calls GET /session/:id plus GET " + "/session/:id/message for stored history, sliced locally for " + "offset/limit since OpenCode's own `limit` keeps the most recent " + "N messages rather than the earliest N." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): ( + "Harness.rename_session() and Session.rename() call PATCH /session/:id." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): ( + "Session.compact() calls POST /session/:id/summarize." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): ( + "Session.fork() calls POST /session/:id/fork, then re-applies the " + "parent's permission ruleset via PATCH /session/:id — confirmed " + "live that fork does not inherit it otherwise (a forked session " + "starts with no ruleset at all, i.e. default allow)." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): ( + "Session.interrupt() calls POST /session/:id/abort." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): ( + "GET /config/providers lists provider/model pairs OpenCode can route to." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): ( + "harness.login('api_key', api_key=...) calls PUT /auth/:id. OAuth " + "authorize/callback exists in the API but is not wired in this " + "adapter yet." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): ( + "Yoke skills render as SKILL.md files under a Yoke-owned deployment " + "directory; OPENCODE_CONFIG_DIR points OpenCode at it without " + "touching the user's real project." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FILESYSTEM_AGENT): ( + "Direct Yoke subagents render as agents/.md markdown with " + "YAML frontmatter (description, mode: subagent, model, permission), " + "written under the same OPENCODE_CONFIG_DIR skills use. The " + "subagent's own access/network posture also compiles into a " + "permission: block — confirmed live that a per-agent `bash: deny` " + "genuinely blocks the tool for that agent specifically, mirroring " + "how codex_agent_toml() already sandboxes a subagent from its own " + "Permissions.access." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.DECLARED_SUBAGENTS): ( + "Same agents/.md files as FILESYSTEM_AGENT; OpenCode " + "auto-discovers them from OPENCODE_CONFIG_DIR, no explicit " + "registration call is needed." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INLINE_SUBAGENTS): ( + "OpenCode's built-in `task` tool spawns a child session; Yoke " + "normalizes the spawn/settle lifecycle into AgentCall event payloads." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): ( + "Yoke polls OpenCode's own SQLite part/message tables while a turn is " + "in flight and emits events as new terminal parts appear; OpenCode's " + "SSE stream was found unreliable for this in a prior live spike." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): ( + "Yoke renders agent.options['mcp_servers'] as {\"mcp\": {...}} JSON " + "and passes it via OPENCODE_CONFIG_CONTENT, OpenCode's highest-" + "precedence config source. OpenCode has no runtime add-server " + "endpoint, so servers must be known before the session starts." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PLUGINS): ( + "agent.options['opencode_plugins'] (name -> raw JS source) writes " + "plugin/.js files under OPENCODE_CONFIG_DIR; Yoke passes the " + "source through unmodified rather than generating or validating it." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.HOOKS): ( + "A generated tool.execute.before plugin calls a local HTTP bridge " + "(OpencodeHookBridge) that resolves each tool call through " + "ProviderOptions.opencode.request_handler/policy, the same " + "RequestPolicy/Response contract used for permissions — confirmed " + "live that argument mutation (Response.updated_input) and denial " + "both change what actually runs, not just what's reported." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): ( + "Translates approval, access, and network: Permissions.approval=" + "ASK passes an ask-all session permission block instead of " + "allow-all (OpencodePermissionWatchdog polls GET /permission and " + "resolves each pending request via POST /permission/:id/reply); " + "access/network gate write/edit/apply_patch, bash, and webfetch/" + "websearch per-tool, the same categories accessible_claude_tools() " + "gates for Claude. Re-applied via PATCH /session/:id on fork(), " + "since OpenCode's fork endpoint does not inherit the parent " + "session's ruleset." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.REQUEST_CALLBACKS): ( + "ProviderOptions.opencode.request_handler (or .policy, a " + "RequestPolicy) is called with the same (event, default) -> " + "Response contract as Codex app-server's request_handler." + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.REQUEST_EVENTS): ( + "GET /permission lists pending permissions across sessions — " + "confirmed live and non-deprecated, unlike " + "/session/:id/permissions/:permissionID which this adapter used " + "to target. Polled on its own thread alongside the DB-poll " + "progress watchdog, same poll-not-SSE architecture." + ), } @@ -984,6 +1153,43 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: (Provider.CODEX, Surface.CODEX_APP_SERVER, Feature.EXPERIMENTAL_API): ( "CodexAppServerOptions(experimental_api=True)", ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.ONE_SHOT): ( + 'await Harness(provider="opencode", agent=agent, cwd=repo).run(prompt)', + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION): ( + 'session = await Harness(provider="opencode", agent=agent, cwd=repo).start()', + "await session.run(prompt)", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): ( + "sessions = await harness.sessions()", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): ( + "history = await harness.read_session(session_id)", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): ( + "await session.compact()", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): ( + "renamed = await harness.rename_session(session_id, title)", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): ( + "RunOptions(on_event=callback)", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): ( + "models = await harness.models()", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): ( + 'await harness.login("api_key", api_key=...)', + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): ( + "await session.interrupt()", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): ( + "fork = await session.fork()", + ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): ( + "Agent(skills=[Skill(name=..., instructions=...)])", + ), } @@ -1533,4 +1739,136 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities: ), } ), + (Provider.OPENCODE, Surface.OPENCODE_SERVER): Capabilities.from_map( + { + Feature.ONE_SHOT: Support.NATIVE, + Feature.SESSION: Support.NATIVE, + Feature.SESSION_LIST: (Support.NATIVE, "GET /session."), + Feature.SESSION_READ: ( + Support.NATIVE, + "GET /session/:id plus a read-only poll of OpenCode's own " + "message table.", + ), + Feature.SESSION_RESUME: ( + Support.UNKNOWN, + "OpenCode sessions are addressed by id, but resuming a prior " + "session into a fresh Harness.start() call is unconfirmed.", + ), + Feature.SESSION_COMPACT: (Support.NATIVE, "POST /session/:id/summarize."), + Feature.SESSION_RENAME: (Support.NATIVE, "PATCH /session/:id."), + Feature.SESSION_TAG: ( + Support.UNSUPPORTED, + "No documented session tag concept.", + ), + Feature.STREAMING: ( + Support.EMULATED, + "Poll-based, not OpenCode's native SSE stream, which a prior " + "live spike found unreliable for this purpose.", + ), + Feature.RUN_EVENT_CALLBACKS: ( + Support.EMULATED, + "RunOptions.on_event is driven by the DB-poll watchdog, not a " + "native provider event stream.", + ), + Feature.STRUCTURED_OUTPUT: ( + Support.UNSUPPORTED, + "No documented schema-constrained output API.", + ), + Feature.MODELS: (Support.NATIVE, "GET /config/providers."), + Feature.LOGIN: ( + Support.NATIVE, + "PUT /auth/:id sets api_key credentials; OAuth authorize/" + "callback exists in the API but is not wired in this adapter.", + ), + Feature.PERMISSIONS: ( + Support.NATIVE, + "Permissions.approval=ASK passes an ask-all session " + "permission block (AUTO/NEVER still pass allow-all); " + "pending permissions are resolved live (see REQUEST_EVENTS).", + ), + Feature.REQUEST_CALLBACKS: ( + Support.COMPILED, + "ProviderOptions.opencode.request_handler/policy answer " + "pending permissions the same RequestPolicy/Response " + "contract Codex app-server and Claude use.", + ), + Feature.CODEX_PERMISSIONS: Support.UNSUPPORTED, + Feature.CLAUDE_PERMISSIONS: Support.UNSUPPORTED, + Feature.FILESYSTEM_AGENT: ( + Support.COMPILED, + "Direct Yoke subagents compile into agents/.md " + "markdown files with YAML frontmatter under the same " + "OPENCODE_CONFIG_DIR used for skills; nested subagents-of-" + "subagents are not compiled (OpenCode documents no nested " + "invocation model to target).", + ), + Feature.INLINE_SUBAGENTS: ( + Support.NATIVE, + "The built-in `task` tool spawns a child session, discovered " + "and normalized by the DB-poll watchdog.", + ), + Feature.DECLARED_SUBAGENTS: ( + Support.COMPILED, + "Yoke subagents compile to agents/.md with " + "`mode: subagent`; OpenCode auto-discovers them from " + "OPENCODE_CONFIG_DIR, invocation remains model-driven " + "(@mention or delegation).", + ), + Feature.COLLAB_AGENT_TOOLS: Support.UNSUPPORTED, + Feature.COLLABORATION_MODE: Support.UNSUPPORTED, + Feature.SKILLS: ( + Support.NATIVE, + "OPENCODE_CONFIG_DIR points OpenCode at a Yoke-generated " + "skills//SKILL.md directory without touching the " + "user's real project.", + ), + Feature.PLUGINS: ( + Support.COMPILED, + "agent.options['opencode_plugins'] (name -> raw JS source) " + "compiles into plugin/.js files under the same " + "OPENCODE_CONFIG_DIR skills/agents use — pure pass-through, " + "Yoke does not generate or validate the plugin's contents.", + ), + Feature.HOOKS: ( + Support.COMPILED, + "A generated tool.execute.before plugin (OpencodeHookBridge) " + "relays each tool call to a local HTTP bridge, resolved via " + "ProviderOptions.opencode.request_handler/policy — confirmed " + "live: argument mutation and denial both actually change " + "what runs. The bridge itself is deployed only once, when a " + "handler/policy is configured at session start (a later " + "per-turn RunOptions.provider.opencode cannot retroactively " + "deploy it); once deployed, each turn's own " + "RunOptions.provider.opencode *does* override which handler " + "answers, same fallback order as permission resolution.", + ), + Feature.MCP: ( + Support.COMPILED, + "agent.options['mcp_servers'] compiles into an " + "OPENCODE_CONFIG_CONTENT env var (`{\"mcp\": {...}}`), merged " + "over the user's own opencode.json at OpenCode's highest " + "config precedence — no runtime add-server API exists.", + ), + Feature.GOAL: Support.UNSUPPORTED, + Feature.GOAL_LOOP: Support.UNSUPPORTED, + Feature.MUTABLE_GOAL: Support.UNSUPPORTED, + Feature.READABLE_GOAL: Support.UNSUPPORTED, + Feature.INTERRUPT: (Support.NATIVE, "POST /session/:id/abort."), + Feature.FORK: (Support.NATIVE, "POST /session/:id/fork."), + Feature.WORKFLOW: ( + Support.EMULATED, + "Yoke executes workflow steps as multiple session sends.", + ), + Feature.NATIVE_WORKFLOW: Support.UNSUPPORTED, + Feature.EXPERIMENTAL_API: Support.UNSUPPORTED, + Feature.REQUEST_EVENTS: ( + Support.COMPILED, + "GET /permission (confirmed live, non-deprecated) is polled " + "the same way OpencodeProgressWatchdog polls SQLite; pending " + "permissions are resolved via POST /permission/:id/reply — " + "no SSE dependency needed, disproving this adapter's earlier " + "assumption that discovery required it.", + ), + } + ), } diff --git a/tests/test_opencode_agents.py b/tests/test_opencode_agents.py new file mode 100644 index 0000000..49842d3 --- /dev/null +++ b/tests/test_opencode_agents.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from yoke import Agent +from yoke.models import Access, Permissions +from yoke.providers.opencode.agents import opencode_agent_files, opencode_agent_markdown + + +def test_opencode_agent_files_compile_direct_subagents() -> None: + agent = Agent( + instructions="Coordinate the work.", + subagents={ + "docs_researcher": Agent( + description="Documentation specialist.", + instructions="Use primary docs and return links.", + model="gpt-5.4-mini", + ) + }, + ) + + files = opencode_agent_files(agent) + + assert len(files) == 1 + assert files[0].name == "docs_researcher" + assert files[0].path.as_posix() == ".opencode/agents/docs_researcher.md" + assert files[0].text == opencode_agent_markdown( + "docs_researcher", agent.subagents["docs_researcher"] + ) + + +def test_opencode_agent_markdown_renders_frontmatter_and_body() -> None: + # Default Permissions() is access=READ, network=False, so the default + # subagent below also gets a permission: block — matching how + # codex_agent_toml() already sandboxes a default-permissions subagent + # to "read-only" rather than leaving it unrestricted. + subagent = Agent( + description="Documentation specialist.", + instructions="Use primary docs and return links.", + model="gpt-5.4-mini", + ) + + text = opencode_agent_markdown("docs_researcher", subagent) + + assert text == ( + "---\n" + 'description: "Documentation specialist."\n' + "mode: subagent\n" + 'model: "gpt-5.4-mini"\n' + "permission:\n" + " write: deny\n" + " edit: deny\n" + " apply_patch: deny\n" + " bash: deny\n" + " webfetch: deny\n" + " websearch: deny\n" + "---\n" + "\n" + "Use primary docs and return links.\n" + ) + + +def test_opencode_agent_markdown_omits_permission_block_for_full_access() -> None: + subagent = Agent( + instructions="do anything", + permissions=Permissions(access=Access.FULL, network=True), + ) + + text = opencode_agent_markdown("unrestricted", subagent) + + assert "permission:" not in text + + +def test_opencode_agent_markdown_write_access_allows_edit_but_not_bash() -> None: + subagent = Agent( + instructions="edit only", + permissions=Permissions(access=Access.WRITE, network=True), + ) + + text = opencode_agent_markdown("editor", subagent) + + assert "permission:\n bash: deny\n" in text + assert "edit: deny" not in text + assert "write: deny" not in text + + +def test_opencode_agent_markdown_falls_back_without_description_or_model() -> None: + subagent = Agent(instructions="Review the patch for bugs.") + + text = opencode_agent_markdown("reviewer", subagent) + + assert 'description: "Yoke subagent reviewer."' in text + assert "mode: subagent" in text + assert "model:" not in text + assert text.endswith("Review the patch for bugs.\n") + + +def test_opencode_agent_files_ignores_nested_subagents_of_subagents() -> None: + agent = Agent( + instructions="root", + subagents={ + "planner": Agent( + instructions="plan", + subagents={"nested": Agent(instructions="should not compile")}, + ) + }, + ) + + files = opencode_agent_files(agent) + + assert [file.name for file in files] == ["planner"] diff --git a/tests/test_opencode_history.py b/tests/test_opencode_history.py new file mode 100644 index 0000000..0ff458b --- /dev/null +++ b/tests/test_opencode_history.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from yoke import Agent, Harness +from yoke.errors import UnsupportedFeature +from yoke.providers.opencode import http +from yoke.providers.opencode_server import OpencodeServer + + +@dataclass +class _FakeProcess: + base_url: str = "http://127.0.0.1:0" + + def terminate(self) -> None: + pass + + +def _harness(tmp_path: Path) -> Harness: + return Harness( + provider="opencode", + agent=Agent(instructions="x"), + cwd=tmp_path, + ) + + +def test_list_sessions_rejects_cursor( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + adapter = OpencodeServer() + with pytest.raises(UnsupportedFeature): + asyncio.run(adapter.list_sessions(_harness(tmp_path), cursor="abc")) + + +def test_list_sessions_rejects_excluding_worktrees( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + adapter = OpencodeServer() + with pytest.raises(UnsupportedFeature): + asyncio.run( + adapter.list_sessions(_harness(tmp_path), include_worktrees=False) + ) + + +def test_list_sessions_passes_explicit_cwd_as_directory_filter( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + captured: dict[str, object] = {} + + def fake_list_sessions(base_url, timeout, *, directory=None): + captured["directory"] = directory + return ({"id": "ses_1", "title": "one"},) + + monkeypatch.setattr(http, "list_sessions", fake_list_sessions) + adapter = OpencodeServer() + + result = asyncio.run( + adapter.list_sessions(_harness(tmp_path), cwd=tmp_path / "other") + ) + + assert captured["directory"] == str(tmp_path / "other") + assert result.sessions[0].id == "ses_1" + + +def test_list_sessions_does_not_filter_by_directory_when_cwd_omitted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + captured: dict[str, object] = {} + + def fake_list_sessions(base_url, timeout, *, directory=None): + captured["directory"] = directory + return () + + monkeypatch.setattr(http, "list_sessions", fake_list_sessions) + adapter = OpencodeServer() + + asyncio.run(adapter.list_sessions(_harness(tmp_path))) + + assert captured["directory"] is None + + +def test_read_session_fetches_messages_via_the_history_api_and_slices_locally( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + monkeypatch.setattr( + http, "read_session", lambda base_url, session_id, timeout: {"id": session_id} + ) + entries = tuple( + {"info": {"id": f"msg_{i}", "role": "user"}} for i in range(5) + ) + calls: list[str] = [] + + def fake_list_messages(base_url, session_id, timeout): + calls.append(session_id) + return entries + + monkeypatch.setattr(http, "list_messages", fake_list_messages) + adapter = OpencodeServer() + + history = asyncio.run( + adapter.read_session( + _harness(tmp_path), "ses_1", limit=2, offset=1 + ) + ) + + assert calls == ["ses_1"] + assert [m.id for m in history.messages] == ["msg_1", "msg_2"] + + +def test_read_session_skips_messages_when_not_requested( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + monkeypatch.setattr( + http, "read_session", lambda base_url, session_id, timeout: {"id": session_id} + ) + called = [] + monkeypatch.setattr( + http, + "list_messages", + lambda *args, **kwargs: called.append(1) or (), + ) + adapter = OpencodeServer() + + history = asyncio.run( + adapter.read_session(_harness(tmp_path), "ses_1", include_messages=False) + ) + + assert called == [] + assert history.messages == () diff --git a/tests/test_opencode_hook_bridge_wiring.py b/tests/test_opencode_hook_bridge_wiring.py new file mode 100644 index 0000000..3be927b --- /dev/null +++ b/tests/test_opencode_hook_bridge_wiring.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from yoke import Agent +from yoke.models import EventKind, Turn +from yoke.options import OpencodeOptions, ProviderOptions, RunOptions, SessionOptions +from yoke.policies import RequestPolicy +from yoke.providers.opencode import http +from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession +from yoke.providers.runtime_deployment import deploy_runtime + + +@dataclass +class _FakeProcess: + base_url: str = "http://127.0.0.1:0" + + def terminate(self) -> None: + pass + + +def test_maybe_deploy_hook_bridge_returns_none_without_a_handler( + tmp_path: Path, +) -> None: + adapter = OpencodeServer() + deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path) + try: + bridge = adapter._maybe_deploy_hook_bridge(SessionOptions(), deployment) + assert bridge is None + assert deployment.opencode_config_dir is None + finally: + deployment.cleanup() + + +def test_maybe_deploy_hook_bridge_writes_the_plugin_and_starts_a_server( + tmp_path: Path, +) -> None: + adapter = OpencodeServer() + deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path) + try: + options = SessionOptions( + provider=ProviderOptions( + opencode=OpencodeOptions(policy=RequestPolicy.allow_all()) + ) + ) + bridge = adapter._maybe_deploy_hook_bridge(options, deployment) + assert bridge is not None + assert deployment.opencode_config_dir is not None + plugin_path = ( + deployment.opencode_config_dir / "plugin" / "yoke_tool_hook.js" + ) + assert plugin_path.is_file() + assert "tool.execute.before" in plugin_path.read_text() + assert bridge.base_url.startswith("http://127.0.0.1:") + finally: + bridge.stop() + deployment.cleanup() + + +def test_resolve_hook_request_routes_to_the_turns_active_handler_and_emits( + tmp_path: Path, +) -> None: + adapter = OpencodeServer() + events = [] + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + provider_options=OpencodeOptions(policy=RequestPolicy.allow_all()), + current_emit=events.append, + current_request_handler=RequestPolicy.deny_all("no"), + ) + adapter._sessions["ses_test123"] = internal + + response = adapter._resolve_hook_request( + "ses_test123", + {"sessionID": "ses_test123", "callID": "call_1", "tool": "bash", "args": {}}, + ) + + assert response.decision == "deny" + assert len(events) == 1 + assert events[0].kind == EventKind.REQUEST_RESOLVED + assert events[0].response is response + + +def test_resolve_hook_request_ignores_session_level_policy_outside_a_turn( + tmp_path: Path, +) -> None: + # Regression: _resolve_hook_request used to fall back to + # internal.provider_options (fixed at session-start) directly, so a + # per-turn RunOptions.provider.opencode override was silently ignored + # for hooks even though the identical override worked for permissions. + # Resolution now always goes through internal.current_request_handler, + # set fresh by _send() each turn — outside any turn it's None, and a + # hook call in that state defaults to allow rather than reaching for + # the stale session-level policy. + adapter = OpencodeServer() + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + provider_options=OpencodeOptions(policy=RequestPolicy.deny_all("no")), + ) + adapter._sessions["ses_test123"] = internal + + response = adapter._resolve_hook_request( + "ses_test123", + {"sessionID": "ses_test123", "callID": "call_1", "tool": "bash", "args": {}}, + ) + + assert response.decision == "allow" + + +def test_resolve_hook_request_defaults_to_allow_for_an_unknown_session() -> None: + adapter = OpencodeServer() + + response = adapter._resolve_hook_request( + "ses_unknown", + {"sessionID": "ses_unknown", "callID": "call_1", "tool": "bash", "args": {}}, + ) + + assert response.decision == "allow" + + +def test_send_intercepts_a_tool_call_via_a_live_hook_bridge( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Exercises the real OpencodeHookBridge HTTP round trip (not a mocked + # http.list_permissions-style stub) by having the fake post_message + # itself act like the generated plugin: POST to the bridge's real + # base_url mid-turn, the way `tool.execute.before` would. + import httpx + + captured_reply = {} + + def fake_post_message( + base_url, session_id, cwd, provider_id, model_id, prompt, timeout, **kwargs + ): + reply = httpx.post( + f"{base_url}/tool-hook", + json={ + "sessionID": session_id, + "callID": "call_1", + "tool": "bash", + "args": {"command": "echo hi"}, + }, + timeout=5, + ).json() + captured_reply.update(reply) + return {"info": {}, "parts": []} + + monkeypatch.setattr(http, "post_message", fake_post_message) + + adapter = OpencodeServer(poll_interval_seconds=0.01) + deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path) + try: + options = SessionOptions( + provider=ProviderOptions( + opencode=OpencodeOptions( + policy=RequestPolicy.deny_tools("shell", message="no shell") + ) + ) + ) + bridge = adapter._maybe_deploy_hook_bridge(options, deployment) + assert bridge is not None + internal = _OpencodeSession( + process=type("P", (), {"base_url": bridge.base_url})(), + session_id="ses_hooktest", + cwd=tmp_path, + environment=None, + db_path=tmp_path / "opencode.db", + provider_options=OpencodeOptions( + policy=RequestPolicy.deny_tools("shell", message="no shell") + ), + ) + adapter._sessions["ses_hooktest"] = internal + + run = adapter._send( + internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions() + ) + finally: + bridge.stop() + deployment.cleanup() + + assert captured_reply["deny"] is True + assert captured_reply["message"] == "no shell" + resolved = [e for e in run.events if e.kind == EventKind.REQUEST_RESOLVED] + assert len(resolved) == 1 + assert resolved[0].response.decision == "deny" + + +def test_start_session_stops_the_hook_bridge_when_server_startup_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression: _maybe_deploy_hook_bridge runs (and starts the bridge) + # before start_opencode_server, since the bridge's URL must go into the + # child process's env. Only the http.create_session call afterward was + # wrapped in a try/except that stopped the bridge — a startup failure + # (missing binary, port exhaustion, timeout) leaked the bridge's thread + # and listening socket for the life of the process. + import httpx + + from yoke.providers.opencode.process import OpencodeServerStartupError + + real_deploy = OpencodeServer._maybe_deploy_hook_bridge + deployed_bridges = [] + + def spying_deploy(self, options, deployment): + bridge = real_deploy(self, options, deployment) + deployed_bridges.append(bridge) + return bridge + + def failing_start(*args, **kwargs): + raise OpencodeServerStartupError("opencode serve did not start") + + monkeypatch.setattr(OpencodeServer, "_maybe_deploy_hook_bridge", spying_deploy) + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", failing_start + ) + + adapter = OpencodeServer() + deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path) + harness_options = SessionOptions( + provider=ProviderOptions( + opencode=OpencodeOptions(policy=RequestPolicy.allow_all()) + ) + ) + from yoke import Harness + + harness = Harness( + provider="opencode", + agent=Agent(instructions="x"), + cwd=tmp_path / "workspace", + ) + try: + with pytest.raises(OpencodeServerStartupError): + adapter._start_session(harness, harness_options, deployment) + finally: + deployment.cleanup() + + assert len(deployed_bridges) == 1 + bridge = deployed_bridges[0] + assert bridge is not None + # If the bridge leaked, its ephemeral port would still be listening. + # bridge.stop() closes the socket, so a request against it now fails. + with pytest.raises(httpx.ConnectError): + httpx.post(f"{bridge.base_url}/tool-hook", json={}, timeout=2) diff --git a/tests/test_opencode_hooks.py b/tests/test_opencode_hooks.py new file mode 100644 index 0000000..91b5647 --- /dev/null +++ b/tests/test_opencode_hooks.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import httpx + +from yoke.models import EventKind, RequestKind, ToolKind +from yoke.policies import RequestPolicy +from yoke.providers.opencode.hooks import OpencodeHookBridge, hook_event, resolve + +_PAYLOAD = { + "sessionID": "ses_root", + "callID": "call_1", + "tool": "bash", + "args": {"command": "echo hi", "workdir": "/tmp"}, +} + + +def test_hook_event_builds_provider_neutral_tool_request() -> None: + event = hook_event(_PAYLOAD) + + assert event.kind == EventKind.TOOL_REQUEST + assert event.tool_id == "call_1" + assert event.tool_name == "bash" + assert event.tool is not None + assert event.tool.kind == ToolKind.SHELL + assert event.tool.command == "echo hi" + assert event.request is not None + assert event.request.kind == RequestKind.TOOL + assert event.source_thread_id == "ses_root" + # Opt-in-only feature: default is allow-unchanged, not deny. + assert event.response is not None + assert event.response.decision == "allow" + + +def test_resolve_defaults_to_allow_without_a_handler() -> None: + event, response = resolve(_PAYLOAD, None) + + assert response.decision == "allow" + assert response.updated_input is None + + +def test_resolve_honors_a_deny_all_policy() -> None: + _, response = resolve(_PAYLOAD, RequestPolicy.deny_all("no bash")) + + assert response.decision == "deny" + assert response.message == "no bash" + + +def test_resolve_can_return_updated_input_from_a_raw_handler() -> None: + def handler(event, default): + from yoke.models import Response + + return Response.allow(updated_input={"command": "echo modified"}) + + _, response = resolve(_PAYLOAD, handler) + + assert response.decision == "allow" + assert response.updated_input == {"command": "echo modified"} + + +def test_bridge_serializes_the_resolved_response_over_http() -> None: + captured = {} + + def fake_resolve(session_id, payload): + from yoke.models import Response + + captured["session_id"] = session_id + captured["payload"] = payload + return Response.allow(updated_input={"command": "echo modified"}) + + bridge = OpencodeHookBridge(resolve=fake_resolve) + bridge.start() + try: + response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5) + assert response.status_code == 200 + body = response.json() + assert body["args"] == {"command": "echo modified"} + assert body["deny"] is False + assert captured["session_id"] == "ses_root" + assert captured["payload"]["tool"] == "bash" + finally: + bridge.stop() + + +def test_bridge_reports_deny_and_message_over_http() -> None: + def fake_resolve(session_id, payload): + from yoke.models import Response + + return Response.deny("blocked") + + bridge = OpencodeHookBridge(resolve=fake_resolve) + bridge.start() + try: + response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5) + body = response.json() + assert body["deny"] is True + assert body["message"] == "blocked" + finally: + bridge.stop() + + +def test_bridge_reports_a_visible_deny_instead_of_resetting_the_connection() -> None: + # Regression: a bug in a caller's request_handler (or a + # Response.updated_input value json.dumps can't serialize) used to + # propagate out of do_POST uncaught, resetting the connection with no + # response at all. The generated plugin's `await res.json()` then + # throws, which — per tool.execute.before's own semantics — blocks the + # tool call with no context and no REQUEST_RESOLVED event. A real HTTP + # 200 with an honest deny message is strictly better: still fails + # closed, but distinguishable from a genuine policy denial. + def broken_resolve(session_id, payload): + raise RuntimeError("handler exploded") + + bridge = OpencodeHookBridge(resolve=broken_resolve) + bridge.start() + try: + response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5) + assert response.status_code == 200 + body = response.json() + assert body["deny"] is True + assert "handler exploded" in body["message"] + finally: + bridge.stop() diff --git a/tests/test_opencode_parts.py b/tests/test_opencode_parts.py new file mode 100644 index 0000000..6fb14ea --- /dev/null +++ b/tests/test_opencode_parts.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from yoke.models import EventKind, ToolKind, ToolStatus +from yoke.providers.opencode.parts import ( + final_text_from_parts, + infer_opencode_tool_kind, + is_task_spawn, + map_opencode_part, +) + + +def test_text_part_maps_to_text_event() -> None: + events = map_opencode_part({"type": "text", "text": "hello"}, "session-1") + + assert len(events) == 1 + assert events[0].kind == EventKind.TEXT + assert events[0].message == "hello" + + +def test_empty_text_part_maps_to_no_events() -> None: + assert map_opencode_part({"type": "text"}, "session-1") == () + + +def test_reasoning_part_maps_to_tool_summary() -> None: + events = map_opencode_part({"type": "reasoning", "text": "thinking"}, "session-1") + + assert len(events) == 1 + assert events[0].kind == EventKind.TOOL_SUMMARY + + +def test_tool_part_maps_to_use_and_result_events() -> None: + part = { + "type": "tool", + "tool": "read", + "callID": "call-1", + "state": { + "status": "completed", + "input": {"filePath": "src/foo.py"}, + "output": "contents", + }, + } + + events = map_opencode_part(part, "session-1") + + assert len(events) == 2 + use_event, result_event = events + assert use_event.kind == EventKind.TOOL_USE + assert use_event.tool.kind == ToolKind.READ + assert use_event.tool.path == "src/foo.py" + assert use_event.source_thread_id == "session-1" + assert result_event.kind == EventKind.TOOL_RESULT + assert result_event.tool_result == "contents" + assert result_event.tool_is_error is False + + +def test_failed_tool_part_marks_result_as_error() -> None: + part = { + "type": "tool", + "tool": "bash", + "state": {"status": "error", "input": {}, "output": "boom"}, + } + + _, result_event = map_opencode_part(part, "session-1") + + assert result_event.tool.status == ToolStatus.FAILED + assert result_event.tool_is_error is True + + +def test_task_tool_part_carries_agent_call() -> None: + part = { + "type": "tool", + "tool": "task", + "state": { + "status": "completed", + "input": {"prompt": "do the thing"}, + "metadata": {"sessionId": "child-1"}, + }, + } + + use_event, result_event = map_opencode_part(part, "parent-1") + + assert use_event.agent is not None + assert use_event.agent.new_thread_id == "child-1" + assert use_event.agent.sender_thread_id == "parent-1" + assert use_event.agent.prompt == "do the thing" + assert use_event.agent.action == "completed" + assert result_event.agent == use_event.agent + + +def test_non_task_tool_part_has_no_agent_call() -> None: + part = {"type": "tool", "tool": "read", "state": {"status": "completed"}} + + use_event, _ = map_opencode_part(part, "session-1") + + assert use_event.agent is None + + +def test_patch_part_maps_to_files_changed_summary() -> None: + events = map_opencode_part({"type": "patch", "files": ["a.py", "b.py"]}, "s") + + assert len(events) == 1 + assert "a.py" in events[0].message + assert "b.py" in events[0].message + + +def test_empty_patch_part_maps_to_no_events() -> None: + assert map_opencode_part({"type": "patch", "files": []}, "s") == () + + +def test_step_finish_part_maps_to_context_usage() -> None: + part = {"type": "step-finish", "tokens": {"input": 10, "output": 20, "total": 30}} + + events = map_opencode_part(part, "s") + + assert len(events) == 1 + assert events[0].kind == EventKind.CONTEXT_USAGE + assert events[0].usage.total_tokens == 30 + + +def test_unknown_part_type_maps_to_no_events() -> None: + assert map_opencode_part({"type": "unknown-thing"}, "s") == () + + +def test_infer_tool_kind_matches_common_tool_names() -> None: + assert infer_opencode_tool_kind("read") == ToolKind.READ + assert infer_opencode_tool_kind("bash") == ToolKind.SHELL + assert infer_opencode_tool_kind("glob") == ToolKind.SEARCH + assert infer_opencode_tool_kind("webfetch") == ToolKind.WEB + assert infer_opencode_tool_kind("task") == ToolKind.AGENT + assert infer_opencode_tool_kind("mystery") == ToolKind.UNKNOWN + + +def test_final_text_from_parts_returns_last_text_part() -> None: + parts = [ + {"type": "text", "text": "first"}, + {"type": "tool", "tool": "read"}, + {"type": "text", "text": "last"}, + ] + + assert final_text_from_parts(parts) == "last" + + +def test_final_text_from_parts_returns_none_when_no_text() -> None: + assert final_text_from_parts([{"type": "tool", "tool": "read"}]) is None + + +def test_is_task_spawn_detects_task_tool_with_session_id() -> None: + part = { + "type": "tool", + "tool": "task", + "state": { + "input": {"description": "spawn a helper"}, + "metadata": {"sessionId": "child-9"}, + }, + } + + result = is_task_spawn(part) + + assert result == ("child-9", "spawn a helper") + + +def test_is_task_spawn_returns_none_for_non_task_tool() -> None: + part = {"type": "tool", "tool": "read", "state": {}} + + assert is_task_spawn(part) is None diff --git a/tests/test_opencode_permission_approval.py b/tests/test_opencode_permission_approval.py new file mode 100644 index 0000000..4d364f8 --- /dev/null +++ b/tests/test_opencode_permission_approval.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from yoke import Agent, Harness +from yoke.models import Access, Approval, Permissions +from yoke.options import ForkOptions, OpencodeOptions, ProviderOptions, SessionOptions +from yoke.policies import RequestPolicy +from yoke.providers.opencode import http +from yoke.providers.opencode_server import ( + OpencodeServer, + _OpencodeSession, + _resolved_permissions, + _session_permission_block, + opencode_options, + opencode_options_for_run, +) + + +def test_session_permission_block_asks_for_approval_permissions() -> None: + # Default Permissions() is access=READ, network=False, so the base "ask" + # rule is followed by denies for every tool that access/network don't + # cover — this is the fix for the reviewed gap where only `approval` was + # translated and `Permissions(access=READ, network=False, approval=ASK)` + # used to produce a bare `* = ask` with everything else still runnable. + assert _session_permission_block(Permissions(approval=Approval.ASK)) == ( + {"permission": "*", "pattern": "*", "action": "ask"}, + {"permission": "write", "pattern": "*", "action": "deny"}, + {"permission": "edit", "pattern": "*", "action": "deny"}, + {"permission": "apply_patch", "pattern": "*", "action": "deny"}, + {"permission": "bash", "pattern": "*", "action": "deny"}, + {"permission": "webfetch", "pattern": "*", "action": "deny"}, + {"permission": "websearch", "pattern": "*", "action": "deny"}, + ) + + +@pytest.mark.parametrize("approval", [Approval.AUTO, Approval.NEVER]) +def test_session_permission_block_allows_all_for_full_access_and_network( + approval: Approval, +) -> None: + permissions = Permissions( + approval=approval, access=Access.FULL, network=True + ) + assert _session_permission_block(permissions) == http.OPENCODE_ALLOW_ALL_PERMISSION + + +@pytest.mark.parametrize("approval", [Approval.AUTO, Approval.NEVER]) +def test_session_permission_block_still_denies_by_access_when_not_asking( + approval: Approval, +) -> None: + # AUTO/NEVER only mean "don't ask" — they must not also bypass the + # access/network-derived denies, which are a separate safety boundary. + permissions = Permissions(approval=approval, access=Access.READ, network=False) + block = _session_permission_block(permissions) + assert block[0] == {"permission": "*", "pattern": "*", "action": "allow"} + denied = {rule["permission"] for rule in block[1:]} + assert denied == {"write", "edit", "apply_patch", "bash", "webfetch", "websearch"} + + +def test_session_permission_block_write_access_allows_edit_but_not_bash() -> None: + block = _session_permission_block( + Permissions(approval=Approval.NEVER, access=Access.WRITE, network=True) + ) + denied = {rule["permission"] for rule in block[1:]} + assert denied == {"bash"} + + +def test_resolved_permissions_prefers_run_then_harness_then_agent() -> None: + agent_permissions = Permissions(access=Access.READ) + harness_permissions = Permissions(access=Access.WRITE) + run_permissions = Permissions(access=Access.FULL) + harness = Harness( + provider="opencode", + agent=Agent(instructions="x", permissions=agent_permissions), + cwd=Path.cwd(), + permissions=harness_permissions, + ) + + assert _resolved_permissions(harness, SessionOptions()) is harness_permissions + assert ( + _resolved_permissions( + harness, SessionOptions(permissions=run_permissions) + ) + is run_permissions + ) + + bare_harness = Harness( + provider="opencode", + agent=Agent(instructions="x", permissions=agent_permissions), + cwd=Path.cwd(), + ) + assert _resolved_permissions(bare_harness, SessionOptions()) is agent_permissions + + +def test_opencode_options_helpers_read_provider_dot_opencode() -> None: + policy = RequestPolicy.allow_all() + session_options = SessionOptions( + provider=ProviderOptions(opencode=OpencodeOptions(policy=policy)) + ) + assert opencode_options(session_options).policy is policy + assert opencode_options(SessionOptions()) == {} + assert opencode_options_for_run(SessionOptions()) is None # type: ignore[arg-type] + + +@dataclass +class _FakeProcess: + base_url: str = "http://127.0.0.1:0" + terminated: bool = False + + def terminate(self) -> None: + self.terminated = True + + +def test_start_session_passes_ask_all_permission_block_for_approval_ask( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + lambda *args, **kwargs: _FakeProcess(), + ) + + def fake_create_session(base_url, cwd_directory, title, timeout, *, permission): + captured["permission"] = permission + return {"id": "ses_test"} + + monkeypatch.setattr(http, "create_session", fake_create_session) + + permissions = Permissions(approval=Approval.ASK, access=Access.FULL, network=True) + harness = Harness( + provider="opencode", + agent=Agent(instructions="x", permissions=permissions), + cwd=tmp_path / "workspace", + ) + adapter = OpencodeServer() + from yoke.providers.runtime_deployment import deploy_runtime + + deployment = deploy_runtime(harness.agent, "opencode", tmp_path) + try: + adapter._start_session(harness, SessionOptions(), deployment) + finally: + deployment.cleanup() + + assert captured["permission"] == http.OPENCODE_ASK_ALL_PERMISSION + + +def test_fork_carries_over_the_parents_permission_policy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + async def exercise() -> None: + adapter = OpencodeServer() + process = _FakeProcess() + policy = RequestPolicy.allow_all() + + def start_session(harness, options, deployment): + return _OpencodeSession( + process=process, + session_id="parent", + cwd=harness.cwd, + environment=None, + db_path=tmp_path / "opencode.db", + provider_options=OpencodeOptions(policy=policy), + ) + + def fake_fork_session(base_url, session_id, timeout, message_id=None): + return {"id": "forked"} + + adapter._start_session = start_session # type: ignore[method-assign] + monkeypatch.setattr( + "yoke.providers.opencode_server.http.fork_session", fake_fork_session + ) + monkeypatch.setattr( + "yoke.providers.opencode_server.http.update_session_permission", + lambda *args, **kwargs: {}, + ) + harness = Harness( + provider="opencode", + agent=Agent(instructions="be helpful"), + cwd=tmp_path / "workspace", + runtime_root=tmp_path / "runtime", + ) + + parent = await adapter.start(harness, SessionOptions()) + forked = await adapter.fork(parent, ForkOptions()) + + forked_internal = adapter._sessions[forked.id] + assert forked_internal.provider_options is not None + assert forked_internal.provider_options.policy is policy + + await adapter.close(forked) + await adapter.close(parent) + + asyncio.run(exercise()) + + +def test_fork_reapplies_the_parents_permission_ruleset( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + # Regression: OpenCode's POST /session/:id/fork does not inherit the + # parent's permission ruleset (confirmed live) — a forked session starts + # with none at all (default allow), silently dropping whatever + # restriction the parent enforced even though the returned + # Session.permissions still reports the parent's posture. + async def exercise() -> None: + adapter = OpencodeServer() + process = _FakeProcess() + + def start_session(harness, options, deployment): + return _OpencodeSession( + process=process, + session_id="parent", + cwd=harness.cwd, + environment=None, + db_path=tmp_path / "opencode.db", + ) + + def fake_fork_session(base_url, session_id, timeout, message_id=None): + return {"id": "forked"} + + captured: dict[str, object] = {} + + def fake_update_permission(base_url, session_id, timeout, *, permission): + captured["session_id"] = session_id + captured["permission"] = permission + return {} + + adapter._start_session = start_session # type: ignore[method-assign] + monkeypatch.setattr( + "yoke.providers.opencode_server.http.fork_session", fake_fork_session + ) + monkeypatch.setattr( + "yoke.providers.opencode_server.http.update_session_permission", + fake_update_permission, + ) + harness = Harness( + provider="opencode", + agent=Agent( + instructions="be helpful", + permissions=Permissions( + access=Access.READ, network=False, approval=Approval.NEVER + ), + ), + cwd=tmp_path / "workspace", + runtime_root=tmp_path / "runtime", + ) + + parent = await adapter.start(harness, SessionOptions()) + forked = await adapter.fork(parent, ForkOptions()) + + assert captured["session_id"] == "forked" + assert captured["permission"] == _session_permission_block(parent.permissions) + + await adapter.close(forked) + await adapter.close(parent) + + asyncio.run(exercise()) diff --git a/tests/test_opencode_permissions.py b/tests/test_opencode_permissions.py new file mode 100644 index 0000000..0cadaf6 --- /dev/null +++ b/tests/test_opencode_permissions.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import threading + +import pytest + +from yoke.models import EventKind, RequestKind, ToolKind, ToolStatus +from yoke.policies import RequestPolicy +from yoke.providers.opencode import http +from yoke.providers.opencode.permissions import ( + OpencodePermissionWatchdog, + permission_event, + policy_response, +) + +_RECORD = { + "id": "per_abc", + "sessionID": "ses_root", + "permission": "bash", + "patterns": ["echo hello"], + "metadata": {"command": "echo hello"}, + "always": ["echo *"], + "tool": {"messageID": "msg_1", "callID": "call_1"}, +} + + +def test_permission_event_builds_provider_neutral_request() -> None: + event = permission_event(_RECORD, "per_abc") + + assert event.kind == EventKind.APPROVAL_REQUEST + assert event.tool_id == "per_abc" + assert event.tool_name == "bash" + assert event.tool is not None + assert event.tool.kind == ToolKind.SHELL + assert event.tool.command == "echo hello" + assert event.tool.status == ToolStatus.STARTED + assert event.request is not None + assert event.request.kind == RequestKind.PERMISSION + assert event.request.id == "per_abc" + assert event.source_thread_id == "ses_root" + # Defaults to deny so an unresolved permission fails closed. + assert event.response is not None + assert event.response.decision == "deny" + + +def test_policy_response_defaults_to_deny_without_a_handler() -> None: + event = permission_event(_RECORD, "per_abc") + + response = policy_response(event, None) + + assert response.decision == "deny" + + +def test_policy_response_honors_an_allow_all_policy() -> None: + event = permission_event(_RECORD, "per_abc") + + response = policy_response(event, RequestPolicy.allow_all()) + + assert response.decision == "allow" + + +def test_policy_response_falls_back_to_default_when_handler_returns_none() -> None: + event = permission_event(_RECORD, "per_abc") + + response = policy_response(event, lambda evt, default: None) + + assert response.decision == "deny" + + +def test_watchdog_resolves_a_pending_permission_and_emits_a_resolved_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, "list_permissions", lambda base_url, timeout: (_RECORD,) + ) + replies: list[tuple[str, str]] = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda base_url, permission_id, reply, timeout, **kwargs: replies.append( + (permission_id, reply) + ), + ) + events = [] + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=events.append, + request_handler=RequestPolicy.allow_all(), + ) + + watchdog._poll_once() + + assert replies == [("per_abc", "once")] + assert len(events) == 1 + assert events[0].kind == EventKind.REQUEST_RESOLVED + assert events[0].response is not None + assert events[0].response.decision == "allow" + + +def test_watchdog_denies_by_default_and_replies_reject( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, "list_permissions", lambda base_url, timeout: (_RECORD,) + ) + replies: list[tuple[str, str]] = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda base_url, permission_id, reply, timeout, **kwargs: replies.append( + (permission_id, reply) + ), + ) + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=lambda event: None, + request_handler=None, + ) + + watchdog._poll_once() + + assert replies == [("per_abc", "reject")] + + +def test_watchdog_ignores_permissions_from_other_sessions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, "list_permissions", lambda base_url, timeout: (_RECORD,) + ) + resolved = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda *args, **kwargs: resolved.append(args), + ) + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_other", + on_event=lambda event: None, + request_handler=RequestPolicy.allow_all(), + ) + + watchdog._poll_once() + + assert resolved == [] + + +def test_watchdog_does_not_re_resolve_an_already_seen_permission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, "list_permissions", lambda base_url, timeout: (_RECORD,) + ) + calls = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda *args, **kwargs: calls.append(args), + ) + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=lambda event: None, + request_handler=RequestPolicy.allow_all(), + seen_permission_ids={"per_abc"}, + ) + + watchdog._poll_once() + + assert calls == [] + + +def test_watchdog_run_stops_promptly_and_does_a_final_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + polled = [] + monkeypatch.setattr( + http, + "list_permissions", + lambda base_url, timeout: polled.append(1) or (), + ) + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=lambda event: None, + request_handler=None, + poll_interval_seconds=0.01, + ) + stop_event = threading.Event() + stop_event.set() + + watchdog.run(stop_event) + + assert len(polled) == 1 + + +def test_watchdog_retries_a_permission_whose_reply_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression: the ID used to be added to `_seen` before the reply was + # attempted, so a transient respond_permission() failure was swallowed + # and the permission was never retried — the in-flight message stayed + # blocked until its own outer timeout instead of being re-resolved on + # the next poll tick. + monkeypatch.setattr( + http, "list_permissions", lambda base_url, timeout: (_RECORD,) + ) + attempts = [] + + def flaky_respond(base_url, permission_id, reply, timeout, **kwargs): + attempts.append(permission_id) + if len(attempts) == 1: + raise RuntimeError("transient network error") + + monkeypatch.setattr(http, "respond_permission", flaky_respond) + events = [] + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=events.append, + request_handler=RequestPolicy.allow_all(), + ) + + watchdog._poll_once() + assert attempts == ["per_abc"] + assert events == [] + + watchdog._poll_once() + assert attempts == ["per_abc", "per_abc"] + assert len(events) == 1 + + +def test_watchdog_swallows_transient_http_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def failing_list(base_url, timeout): + raise RuntimeError("connection reset") + + monkeypatch.setattr(http, "list_permissions", failing_list) + watchdog = OpencodePermissionWatchdog( + base_url="http://127.0.0.1:0", + session_id="ses_root", + on_event=lambda event: None, + request_handler=None, + ) + + watchdog._poll_once() # must not raise diff --git a/tests/test_opencode_progress.py b/tests/test_opencode_progress.py new file mode 100644 index 0000000..aeb2a74 --- /dev/null +++ b/tests/test_opencode_progress.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from pathlib import Path + +from yoke.models import EventKind +from yoke.providers.opencode.progress import ( + OpencodeProgressWatchdog, + OpencodeStuckToolCall, +) + + +def _make_db(path: Path) -> None: + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, " + "time_created INTEGER)" + ) + connection.execute( + "CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, " + "data TEXT, time_created INTEGER)" + ) + connection.commit() + connection.close() + + +def _insert_part( + path: Path, + *, + part_id: str, + session_id: str, + message_id: str, + role: str, + part: dict, + seq: int, +) -> None: + connection = sqlite3.connect(path) + connection.execute( + "INSERT OR IGNORE INTO message (id, session_id, data, time_created) " + "VALUES (?, ?, ?, ?)", + (message_id, session_id, json.dumps({"role": role}), seq), + ) + connection.execute( + "INSERT INTO part (id, session_id, message_id, data, time_created) " + "VALUES (?, ?, ?, ?, ?)", + (part_id, session_id, message_id, json.dumps(part), seq), + ) + connection.commit() + connection.close() + + +def test_watchdog_emits_events_for_assistant_parts(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="root", + message_id="m1", + role="assistant", + part={"type": "text", "text": "hello"}, + seq=1, + ) + + events = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=events.append, + poll_interval_seconds=0.01, + stuck_after_seconds=999, + ) + watchdog._poll_once() + + assert len(events) == 1 + assert events[0].kind == EventKind.TEXT + assert events[0].message == "hello" + + +def test_watchdog_ignores_user_authored_parts(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="root", + message_id="m1", + role="user", + part={"type": "text", "text": "prompt echo"}, + seq=1, + ) + + events = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=events.append, + poll_interval_seconds=0.01, + ) + watchdog._poll_once() + + assert events == [] + + +def test_watchdog_does_not_reemit_seen_parts(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="root", + message_id="m1", + role="assistant", + part={"type": "text", "text": "hello"}, + seq=1, + ) + + events = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=events.append, + poll_interval_seconds=0.01, + ) + watchdog._poll_once() + watchdog._poll_once() + + assert len(events) == 1 + + +def test_watchdog_does_not_reemit_prior_turn_parts_across_sessions( + tmp_path: Path, +) -> None: + # Regression: a live multi-turn smoke test found that a fresh watchdog + # per send() re-polled the whole session and re-emitted an earlier + # turn's already-seen parts as if they were new. Sharing known_sessions/ + # seen_part_ids across watchdog instances (as opencode_server.py's + # per-session _OpencodeSession does) must prevent that. + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="turn1-part", + session_id="root", + message_id="m1", + role="assistant", + part={"type": "text", "text": "turn one output"}, + seq=1, + ) + + known_sessions: set[str] = set() + seen_part_ids: set[str] = set() + first_turn_events = [] + first_watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=first_turn_events.append, + poll_interval_seconds=0.01, + known_sessions=known_sessions, + seen_part_ids=seen_part_ids, + ) + first_watchdog._poll_once() + assert len(first_turn_events) == 1 + + _insert_part( + db_path, + part_id="turn2-part", + session_id="root", + message_id="m2", + role="assistant", + part={"type": "text", "text": "turn two output"}, + seq=2, + ) + + second_turn_events = [] + second_watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=second_turn_events.append, + poll_interval_seconds=0.01, + known_sessions=known_sessions, + seen_part_ids=seen_part_ids, + ) + second_watchdog._poll_once() + + assert len(second_turn_events) == 1 + assert second_turn_events[0].message == "turn two output" + + +def test_watchdog_discovers_child_session_from_task_spawn(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="root", + message_id="m1", + role="assistant", + part={ + "type": "tool", + "tool": "task", + "state": { + "status": "completed", + "input": {"prompt": "help"}, + "metadata": {"sessionId": "child-1"}, + }, + }, + seq=1, + ) + _insert_part( + db_path, + part_id="p2", + session_id="child-1", + message_id="m2", + role="assistant", + part={"type": "text", "text": "child output"}, + seq=2, + ) + + events = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=events.append, + poll_interval_seconds=0.01, + ) + watchdog._poll_once() + # Child session was discovered mid-poll; a second pass picks up its parts. + watchdog._poll_once() + + assert "child-1" in watchdog._known_sessions + child_texts = [e.message for e in events if e.kind == EventKind.TEXT] + assert "child output" in child_texts + + +def test_watchdog_detects_stuck_tool_call(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + stale_start_ms = int(time.time() * 1000) - 1_000_000 + _insert_part( + db_path, + part_id="p1", + session_id="root", + message_id="m1", + role="assistant", + part={ + "type": "tool", + "tool": "bash", + "state": { + "status": "running", + "time": {"start": stale_start_ms}, + "input": {"command": "sleep 999"}, + }, + }, + seq=1, + ) + + events = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=events.append, + poll_interval_seconds=0.01, + stuck_after_seconds=1, + ) + watchdog._poll_once() + + assert isinstance(watchdog.stuck_reason, OpencodeStuckToolCall) + assert watchdog.stuck_reason.tool_name == "bash" + assert events == [] + + +def test_watchdog_run_stops_on_stop_event(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="root", + on_event=lambda event: None, + poll_interval_seconds=0.01, + ) + stop_event = threading.Event() + thread = threading.Thread(target=watchdog.run, args=(stop_event,)) + thread.start() + stop_event.set() + thread.join(timeout=1) + + assert not thread.is_alive() diff --git a/tests/test_opencode_provider_surface.py b/tests/test_opencode_provider_surface.py new file mode 100644 index 0000000..83b0c72 --- /dev/null +++ b/tests/test_opencode_provider_surface.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +from yoke import Agent, Channel, Harness, Provider, Session, Surface, adapter_for +from yoke.surfaces import report_for + + +def test_harness_accepts_opencode_provider_and_surface() -> None: + harness = Harness( + provider="opencode", + surface="opencode_server", + agent=Agent(instructions="test"), + cwd=Path.cwd(), + ) + + assert harness.provider is Provider.OPENCODE + assert harness.surface is Surface.OPENCODE_SERVER + assert adapter_for(harness.provider, harness.surface).surface == "opencode_server" + + +def test_harness_accepts_opencode_surface_aliases() -> None: + for alias in ("server", "app", "app_server", "http"): + harness = Harness( + provider="opencode", + surface=alias, + agent=Agent(instructions="test"), + cwd=Path.cwd(), + ) + assert harness.surface is Surface.OPENCODE_SERVER + + +def test_session_accepts_opencode_provider_surface() -> None: + session = Session(provider="opencode", surface="app", id="abc") + + assert session.provider is Provider.OPENCODE + assert session.surface is Surface.OPENCODE_SERVER + + +def test_opencode_default_surface_resolves_without_explicit_surface() -> None: + harness = Harness( + provider="opencode", + agent=Agent(instructions="test"), + cwd=Path.cwd(), + ) + + assert harness.surface is None + assert harness.require("models").surface is Surface.OPENCODE_SERVER + + +def test_opencode_surface_report_is_app_server_channel_and_runnable() -> None: + report = report_for(Provider.OPENCODE, Surface.OPENCODE_SERVER) + + assert report.channel == str(Channel.APP_SERVER) + assert report.runnable is True + features = {row.feature: row.support for row in report.features} + assert features["session"] == "native" + assert features["streaming"] == "emulated" + assert features["request_events"] == "compiled" diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py new file mode 100644 index 0000000..a8f55be --- /dev/null +++ b/tests/test_opencode_runtime_deployment.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from yoke import Agent, Harness, Skill +from yoke.errors import YokeError +from yoke.options import ForkOptions, SessionOptions +from yoke.providers.opencode import http +from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession +from yoke.providers.runtime_deployment import deploy_runtime + + +def agent_with_skill() -> Agent: + return Agent( + instructions="be helpful", + skills=(Skill.from_text("check sources", name="sources"),), + ) + + +class FakeOpencodeProcess: + def __init__(self) -> None: + self.base_url = "http://127.0.0.1:0" + self.terminated = False + + def terminate(self) -> None: + self.terminated = True + + +def test_opencode_runtime_compiles_native_skill(tmp_path: Path) -> None: + deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is not None + skill_file = deployment.opencode_config_dir / "skills" / "sources" / "SKILL.md" + assert skill_file.is_file() + finally: + deployment.cleanup() + assert not deployment.root.exists() + + +def test_opencode_runtime_skips_config_dir_without_skills(tmp_path: Path) -> None: + deployment = deploy_runtime(Agent(instructions="none"), "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is None + finally: + deployment.cleanup() + + +def test_opencode_runtime_writes_caller_supplied_plugin_source( + tmp_path: Path, +) -> None: + agent = Agent( + instructions="x", + options={ + "opencode_plugins": { + "my_plugin": "export const MyPlugin = async () => ({});" + } + }, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is not None + plugin_path = deployment.opencode_config_dir / "plugin" / "my_plugin.js" + assert plugin_path.is_file() + assert plugin_path.read_text() == "export const MyPlugin = async () => ({});" + finally: + deployment.cleanup() + + +def test_opencode_runtime_ignores_non_string_plugin_entries(tmp_path: Path) -> None: + agent = Agent( + instructions="x", + options={"opencode_plugins": {"bad": 123, "ok": "export const X = 1;"}}, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is not None + plugin_dir = deployment.opencode_config_dir / "plugin" + assert not (plugin_dir / "bad.js").exists() + assert (plugin_dir / "ok.js").is_file() + finally: + deployment.cleanup() + + +def test_opencode_runtime_compiles_mcp_servers_into_config_content( + tmp_path: Path, +) -> None: + agent = Agent( + instructions="be helpful", + options={ + "mcp_servers": { + "docs": { + "type": "remote", + "url": "https://developers.example.com/mcp", + } + } + }, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + assert deployment.opencode_config_content == ( + '{"mcp": {"docs": {"type": "remote", ' + '"url": "https://developers.example.com/mcp"}}}' + ) + finally: + deployment.cleanup() + + +def test_opencode_runtime_skips_config_content_without_mcp_servers( + tmp_path: Path, +) -> None: + deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path) + try: + assert deployment.opencode_config_content is None + finally: + deployment.cleanup() + + +def test_opencode_runtime_compiles_direct_subagent_as_filesystem_agent( + tmp_path: Path, +) -> None: + agent = Agent( + instructions="root", + subagents={ + "reviewer": Agent( + description="Find correctness issues.", + instructions="Review the patch for bugs.", + ) + }, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is not None + agent_file = deployment.opencode_config_dir / "agents" / "reviewer.md" + assert agent_file.is_file() + text = agent_file.read_text() + assert 'description: "Find correctness issues."' in text + assert "mode: subagent" in text + assert "Review the patch for bugs." in text + finally: + deployment.cleanup() + + +def test_opencode_runtime_sets_config_dir_for_subagents_without_skills( + tmp_path: Path, +) -> None: + agent = Agent( + instructions="root", + subagents={"reviewer": Agent(instructions="review")}, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + assert deployment.opencode_config_dir is not None + finally: + deployment.cleanup() + + +def test_opencode_runtime_rejects_colliding_subagent_filenames(tmp_path: Path) -> None: + # Regression: two subagent names that slugify to the same + # agents/.md path used to silently overwrite one another with no + # error — _write_codex already guards the equivalent case for Codex. + agent = Agent( + instructions="root", + subagents={ + "code review": Agent(instructions="a"), + "code-review": Agent(instructions="b"), + }, + ) + with pytest.raises(YokeError): + deploy_runtime(agent, "opencode", tmp_path) + + +def test_opencode_shared_process_keeps_runtime_until_last_release( + tmp_path: Path, +) -> None: + adapter = OpencodeServer() + process = FakeOpencodeProcess() + deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path) + adapter._deployments[id(process)] = deployment + adapter._retain_process(process) + adapter._retain_process(process) + + adapter._release_process(process) + assert deployment.root.exists() + assert not process.terminated + + adapter._release_process(process) + assert not deployment.root.exists() + assert process.terminated + + +def test_opencode_adapter_cleans_runtime_on_close(tmp_path: Path) -> None: + async def exercise() -> None: + adapter = OpencodeServer() + process = FakeOpencodeProcess() + + def start_session(harness, options, deployment): + return _OpencodeSession( + process=process, + session_id="session-1", + cwd=harness.cwd, + environment=None, + db_path=tmp_path / "opencode.db", + instructions=harness.agent.instructions, + ) + + adapter._start_session = start_session # type: ignore[method-assign] + runtime_root = tmp_path / "runtime" + harness = Harness( + provider="opencode", + agent=agent_with_skill(), + cwd=tmp_path / "workspace", + runtime_root=runtime_root, + ) + + session = await adapter.start(harness, SessionOptions()) + assert list(runtime_root.iterdir()) + assert adapter._deployments + + await adapter.close(session) + assert not list(runtime_root.iterdir()) + assert process.terminated + + asyncio.run(exercise()) + + +def test_opencode_closing_a_fork_does_not_delete_the_parents_live_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + # Regression: fork() used to store `deployment=None` on the forked + # session's own dataclass field, and close() cleaned up whichever + # session object happened to hold a deployment reference. Closing a + # fork while the parent (sharing the same live process) was still open + # worked by accident only because the fork's own deployment was None; + # closing the *parent* first while a fork was still open would delete + # the skills directory the still-running shared process depends on. + # Deployment cleanup must be tied to the process refcount, not to + # either session object. + async def exercise() -> None: + adapter = OpencodeServer() + process = FakeOpencodeProcess() + + def start_session(harness, options, deployment): + return _OpencodeSession( + process=process, + session_id="parent", + cwd=harness.cwd, + environment=None, + db_path=tmp_path / "opencode.db", + instructions=harness.agent.instructions, + ) + + def fake_fork_session(base_url, session_id, timeout, message_id=None): + return {"id": "forked"} + + adapter._start_session = start_session # type: ignore[method-assign] + monkeypatch.setattr( + "yoke.providers.opencode_server.http.fork_session", fake_fork_session + ) + monkeypatch.setattr( + "yoke.providers.opencode_server.http.update_session_permission", + lambda *args, **kwargs: {}, + ) + runtime_root = tmp_path / "runtime" + harness = Harness( + provider="opencode", + agent=agent_with_skill(), + cwd=tmp_path / "workspace", + runtime_root=runtime_root, + ) + + parent = await adapter.start(harness, SessionOptions()) + forked = await adapter.fork(parent, ForkOptions()) + assert list(runtime_root.iterdir()) + + # Close the parent first — the fork is still open and shares the + # same process, so the runtime must survive. + await adapter.close(parent) + assert list(runtime_root.iterdir()) + assert not process.terminated + + await adapter.close(forked) + assert not list(runtime_root.iterdir()) + assert process.terminated + + asyncio.run(exercise()) + + +def test_opencode_fork_carries_over_parent_instructions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + # Regression: fork() previously omitted `instructions` entirely, so a + # forked session's own turns ran with no system prompt at all, since + # the parent's instructions were dropped rather than copied over. + async def exercise() -> None: + adapter = OpencodeServer() + process = FakeOpencodeProcess() + + def start_session(harness, options, deployment): + return _OpencodeSession( + process=process, + session_id="parent", + cwd=harness.cwd, + environment=None, + db_path=tmp_path / "opencode.db", + instructions=harness.agent.instructions, + ) + + def fake_fork_session(base_url, session_id, timeout, message_id=None): + return {"id": "forked"} + + adapter._start_session = start_session # type: ignore[method-assign] + monkeypatch.setattr( + "yoke.providers.opencode_server.http.fork_session", fake_fork_session + ) + monkeypatch.setattr( + "yoke.providers.opencode_server.http.update_session_permission", + lambda *args, **kwargs: {}, + ) + harness = Harness( + provider="opencode", + agent=agent_with_skill(), + cwd=tmp_path / "workspace", + runtime_root=tmp_path / "runtime", + ) + + parent = await adapter.start(harness, SessionOptions()) + forked = await adapter.fork(parent, ForkOptions()) + + forked_internal = adapter._sessions[forked.id] + assert forked_internal.instructions == "be helpful" + + await adapter.close(forked) + await adapter.close(parent) + + asyncio.run(exercise()) + + +def test_opencode_start_session_passes_mcp_config_content_env_var( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured_env: dict[str, str] = {} + + def fake_start_opencode_server(command, cwd, timeout, env=None): + captured_env.update(env or {}) + return FakeOpencodeProcess() + + monkeypatch.setattr( + "yoke.providers.opencode_server.start_opencode_server", + fake_start_opencode_server, + ) + monkeypatch.setattr( + http, "create_session", lambda *args, **kwargs: {"id": "ses_test"} + ) + + agent = Agent( + instructions="be helpful", + options={ + "mcp_servers": { + "docs": {"type": "remote", "url": "https://example.com/mcp"} + } + }, + ) + deployment = deploy_runtime(agent, "opencode", tmp_path) + try: + harness = Harness( + provider="opencode", + agent=agent, + cwd=tmp_path / "workspace", + ) + adapter = OpencodeServer() + adapter._start_session(harness, SessionOptions(), deployment) + assert ( + captured_env["OPENCODE_CONFIG_CONTENT"] + == deployment.opencode_config_content + ) + finally: + deployment.cleanup() diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py new file mode 100644 index 0000000..6aef503 --- /dev/null +++ b/tests/test_opencode_send.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from yoke.errors import YokeError +from yoke.models import EventKind, RunStatus, Turn +from yoke.options import OpencodeOptions, ProviderOptions, RunOptions +from yoke.policies import RequestPolicy +from yoke.providers.opencode import http +from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession + + +@dataclass +class _FakeProcess: + base_url: str = "http://127.0.0.1:0" + + +def test_send_emits_provider_session_event_first( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression: Run.provider_session_id scans events in reverse for one + # carrying provider_session_id. Without emitting this event, CodeAlmanac + # (and any other Yoke consumer) can never populate a transcript + # reference for an opencode run — confirmed missing during the + # slice-143 codealmanac integration pass. + monkeypatch.setattr( + http, + "post_message", + lambda *args, **kwargs: { + "info": {"tokens": {"input": 1, "output": 1, "total": 2}}, + "parts": [{"type": "text", "text": "hello"}], + }, + ) + server = OpencodeServer() + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + ) + + run = server._send( + internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions() + ) + + assert run.events[0].kind == EventKind.PROVIDER_SESSION + assert run.events[0].provider_session_id == "ses_test123" + assert run.provider_session_id == "ses_test123" + assert run.output == "hello" + + +def test_send_passes_agent_instructions_as_system_on_every_turn( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # OpenCode's POST /session/:id/message has a documented `system` field + # (confirmed live, v1.17.15) — Agent.instructions goes through it rather + # than being prepended to the prompt text, keeping system and user + # content separate. + # + # Regression: this used to be sent only on the session's first turn, on + # the assumption OpenCode retains it session-side like Claude/Codex's + # native system-prompt fields. Confirmed live it does not — + # LLMRequest.prepare() reads `system` from the current message only, so + # a second turn without it ran with no system prompt at all, and every + # forked session (which never resends it) got none whatsoever. Sent on + # every turn now. + sent: list[tuple[str, str | None]] = [] + + def fake_post_message( + base_url, + session_id, + cwd, + provider_id, + model_id, + prompt, + timeout, + *, + system=None, + ): + sent.append((prompt, system)) + return {"info": {}, "parts": [{"type": "text", "text": "ok"}]} + + monkeypatch.setattr(http, "post_message", fake_post_message) + server = OpencodeServer() + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + instructions="You are a careful maintainer.", + ) + + server._send( + internal, + turn=Turn(prompt="turn one"), + model="openai/gpt-5", + options=RunOptions(), + ) + server._send( + internal, + turn=Turn(prompt="turn two"), + model="openai/gpt-5", + options=RunOptions(), + ) + + assert sent[0] == ("turn one", "You are a careful maintainer.") + assert sent[1] == ("turn two", "You are a careful maintainer.") + + +def test_send_resends_instructions_after_a_failed_turn( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def failing_post_message(*args, **kwargs): + raise RuntimeError("connection reset") + + monkeypatch.setattr(http, "post_message", failing_post_message) + server = OpencodeServer() + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + instructions="You are a careful maintainer.", + ) + + run = server._send( + internal, + turn=Turn(prompt="turn one"), + model="openai/gpt-5", + options=RunOptions(), + ) + + assert run.status == RunStatus.FAILED + + sent: list[tuple[str, str | None]] = [] + + def fake_post_message( + base_url, + session_id, + cwd, + provider_id, + model_id, + prompt, + timeout, + *, + system=None, + ): + sent.append((prompt, system)) + return {"info": {}, "parts": [{"type": "text", "text": "ok"}]} + + monkeypatch.setattr(http, "post_message", fake_post_message) + server._send( + internal, + turn=Turn(prompt="retry"), + model="openai/gpt-5", + options=RunOptions(), + ) + + assert sent == [("retry", "You are a careful maintainer.")] + + +def test_send_resolves_a_pending_permission_via_the_run_level_policy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, + "post_message", + lambda *args, **kwargs: {"info": {}, "parts": []}, + ) + monkeypatch.setattr( + http, + "list_permissions", + lambda base_url, timeout: ( + { + "id": "per_1", + "sessionID": "ses_test123", + "permission": "bash", + "metadata": {"command": "echo hi"}, + }, + ), + ) + replies: list[tuple[str, str]] = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda base_url, permission_id, reply, timeout, **kwargs: replies.append( + (permission_id, reply) + ), + ) + server = OpencodeServer(poll_interval_seconds=0.01) + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + ) + + run = server._send( + internal, + turn=Turn(prompt="hi"), + model="openai/gpt-5", + options=RunOptions( + provider=ProviderOptions( + opencode=OpencodeOptions(policy=RequestPolicy.allow_all()) + ) + ), + ) + + assert replies == [("per_1", "once")] + resolved = [e for e in run.events if e.kind == EventKind.REQUEST_RESOLVED] + assert len(resolved) == 1 + assert resolved[0].response is not None + assert resolved[0].response.decision == "allow" + + +def test_send_falls_back_to_the_session_level_policy_set_at_start( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, + "post_message", + lambda *args, **kwargs: {"info": {}, "parts": []}, + ) + monkeypatch.setattr( + http, + "list_permissions", + lambda base_url, timeout: ( + {"id": "per_2", "sessionID": "ses_test123", "permission": "bash"}, + ), + ) + replies: list[tuple[str, str]] = [] + monkeypatch.setattr( + http, + "respond_permission", + lambda base_url, permission_id, reply, timeout, **kwargs: replies.append( + (permission_id, reply) + ), + ) + server = OpencodeServer(poll_interval_seconds=0.01) + internal = _OpencodeSession( + process=_FakeProcess(), + session_id="ses_test123", + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + provider_options=OpencodeOptions(policy=RequestPolicy.allow_all()), + ) + + server._send( + internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions() + ) + + assert replies == [("per_2", "once")] + + +def _make_db(path: Path) -> None: + import sqlite3 + + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, " + "time_created INTEGER)" + ) + connection.execute( + "CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, " + "data TEXT, time_created INTEGER)" + ) + connection.commit() + connection.close() + + +def _insert_stuck_tool_part(path: Path, *, session_id: str) -> None: + import json + import sqlite3 + + connection = sqlite3.connect(path) + connection.execute( + "INSERT OR IGNORE INTO message (id, session_id, data, time_created) " + "VALUES (?, ?, ?, ?)", + ("m1", session_id, json.dumps({"role": "assistant"}), 1), + ) + stuck_start_ms = int((time.time() - 10_000) * 1000) + connection.execute( + "INSERT INTO part (id, session_id, message_id, data, time_created) " + "VALUES (?, ?, ?, ?, ?)", + ( + "p1", + session_id, + "m1", + json.dumps( + { + "type": "tool", + "tool": "bash", + "state": {"status": "running", "time": {"start": stuck_start_ms}}, + } + ), + 1, + ), + ) + connection.commit() + connection.close() + + +def test_send_reacts_to_a_stuck_tool_call_by_terminating_and_returning_failed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression/coverage gap: only the watchdog itself was tested for + # stuck-tool-call detection in isolation. This exercises _send's actual + # reaction — stop the watchdog, terminate the process, classify the + # failure, and return a FAILED Run — which was previously untested at + # the adapter level. + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_stuck_tool_part(db_path, session_id="ses_stuck") + + class _TrackingProcess: + def __init__(self) -> None: + self.base_url = "http://127.0.0.1:0" + self.terminated = False + + def terminate(self) -> None: + self.terminated = True + + def hanging_post_message(*args, **kwargs): + # Blocks long enough for the watchdog (polling every 0.01s with a + # 0.01s stuck threshold against an already-old part row) to detect + # the stuck state and cause _send to unwind before this returns. + time.sleep(2) + return {"info": {}, "parts": []} + + monkeypatch.setattr(http, "post_message", hanging_post_message) + server = OpencodeServer( + poll_interval_seconds=0.01, + stuck_after_seconds=0.01, + ) + process = _TrackingProcess() + internal = _OpencodeSession( + process=process, + session_id="ses_stuck", + cwd=Path.cwd(), + environment=None, + db_path=db_path, + ) + + run = server._send( + internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions() + ) + + assert run.status == RunStatus.FAILED + assert run.failure is not None + assert process.terminated is True + + +def test_stream_yields_events_live_and_ends_without_a_final_run( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http, + "post_message", + lambda *args, **kwargs: { + "info": {"tokens": {"input": 1, "output": 1, "total": 2}}, + "parts": [{"type": "text", "text": "hello"}], + }, + ) + server = OpencodeServer() + session_id = "ses_stream" + server._sessions[session_id] = _OpencodeSession( + process=_FakeProcess(), + session_id=session_id, + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + ) + session = _session_for(server, session_id) + + async def exercise() -> list: + events = [] + async for event in server.stream( + session, Turn(prompt="hi"), RunOptions(model="openai/gpt-5") + ): + events.append(event) + return events + + events = asyncio.run(exercise()) + assert events[0].kind == EventKind.PROVIDER_SESSION + assert events[0].provider_session_id == session_id + + +def test_stream_raises_on_failure_instead_of_silently_ending( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def failing_post_message(*args, **kwargs): + raise RuntimeError("opencode request failed") + + monkeypatch.setattr(http, "post_message", failing_post_message) + server = OpencodeServer() + session_id = "ses_stream_fail" + server._sessions[session_id] = _OpencodeSession( + process=_FakeProcess(), + session_id=session_id, + cwd=Path.cwd(), + environment=None, + db_path=tmp_path / "opencode.db", + ) + session = _session_for(server, session_id) + + async def exercise() -> None: + async for _event in server.stream( + session, Turn(prompt="hi"), RunOptions(model="openai/gpt-5") + ): + pass + + with pytest.raises(YokeError): + asyncio.run(exercise()) + + +def _session_for(server: OpencodeServer, session_id: str): + from yoke.models import Session + + return Session( + provider=server.provider, + surface=server.surface, + id=session_id, + provider_session_id=session_id, + )