Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,55 @@ client.session.get("ses_...", timeout=5.0) # per call

The default timeout is 10 minutes with a 10 second connect timeout.

## Running an agent headless

`client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
asynchronously after the server boots. Both have async versions on `AsyncOpencode`.

```python
from opencode import Opencode
from opencode.types import ModelRef, PromptInputFileAttachment

with Opencode("http://127.0.0.1:4096", password="...") as client:
client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
result = client.session.run(
"Review the attached diff",
agent="reviewer",
model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
timeout=600,
)
print(result.outcome, result.text)
print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
```

If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
on the server before re-raising, so the agent does not keep running unattended.

## Testing your code

`opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
messages and events in memory and answers with the same payloads as the real server. No process, no model.

```python
from opencode import Opencode
from opencode.testing import FakeServer


def test_summarize() -> None:
fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
client = Opencode.fake(fake) # same as Opencode(transport=fake)

assert client.session.run("README").text == "summary of 'README'"
assert fake.prompts == ["README"]
```

`fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
does not implement answer `404`.

## Running a server

`OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
Expand Down
22 changes: 17 additions & 5 deletions codegen/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,14 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
lines += [
" return self._client.page(",
*request_options,
f" response_type={_resource_type(operation.response_type or 'Any')},",
f" response_type={_resource_type(operation.response_type or 'Any')!r},",
" )",
]
elif operation.kind == "stream":
lines += [
f" return {awaiting}self._client.stream(",
*request_options,
f" item_type={_resource_type(operation.response_type or 'Any')},",
f" item_type={_resource_type(operation.response_type or 'Any')!r},",
" )",
]
elif operation.kind == "void":
Expand All @@ -371,7 +371,7 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
lines += [
f" return cast({returns!r}, {awaiting}self._client.request(",
*request_options,
f" response_type={_resource_type(operation.response_type or 'Any')},",
f" response_type={_resource_type(operation.response_type or 'Any')!r},",
*([" unwrap=True,"] if operation.unwrap else []),
" ))",
]
Expand All @@ -382,6 +382,7 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
# The mixins subclass the resource base classes and cover endpoints the generator cannot express (WebSockets).
MIXINS: dict[tuple[str, ...], tuple[str, str, str]] = {
("pty",): ("_pty", "PtyAttachMixin", "AsyncPtyAttachMixin"),
("session",): ("lib._session", "SessionHelpers", "AsyncSessionHelpers"),
("experimental", "persistentPty"): ("_pty", "PersistentPtyAttachMixin", "AsyncPersistentPtyAttachMixin"),
}

Expand Down Expand Up @@ -452,7 +453,7 @@ def render_resource_module(
[*_resource_classes(resource, errors, is_async=False), *_resource_classes(resource, errors, is_async=True)]
)
uses = _names_in(source)
typing_names = sorted({"Any", "Literal", "cast"} & uses)
typing_names = sorted(({"Any", "Literal", "cast"} & uses) | ({"TYPE_CHECKING"} if type_names else set[str]()))
abc_names = sorted({"Mapping", "Sequence"} & uses)
runtime: list[str] = []
if "encode_path_segment" in uses:
Expand Down Expand Up @@ -480,7 +481,18 @@ def render_resource_module(
*(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []),
"from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout"
+ (", OpenEnum" if "OpenEnum" in uses else ""),
*(["from ..types import (", *[f" {name}," for name in type_names], ")"] if type_names else []),
# Models load on first use (see ``type_adapter``), so importing the client does not build them all.
*(
[
"",
"if TYPE_CHECKING:",
" from ..types import (",
*[f" {name}," for name in type_names],
" )",
]
if type_names
else []
),
"",
f"__all__ = [{', '.join(repr(prefix + resource.class_name + 'Resource') for prefix in ('', 'Async'))}]\n\n",
source,
Expand Down
75 changes: 75 additions & 0 deletions docs/guides/headless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Running agents headless

The common embedded use of OpenCode is: start the server, wait until the agents you configured have loaded, run
one prompt per unit of work, and read the result. Two helpers cover that on both clients.

## Wait until the server is ready

Agents load and MCP servers connect in the background after `opencode serve` starts listening, so the first
requests can arrive before your agent exists. `wait_until_ready` polls until everything you name is there:

```python
client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
```

It treats connection errors and `503` answers as "still booting". After `timeout` seconds it raises
`TimeoutError` naming what is still missing.

## Run a prompt to completion

```python
from opencode.types import ModelRef

result = client.session.run(
"Review the staged changes",
agent="reviewer",
model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"),
timeout=600,
)
print(result.outcome) # "succeeded", "failed" or "interrupted"
print(result.text) # text of the last assistant message
print(result.cost, result.tokens.input, result.tokens.output)
```

`run` creates a session with the agent and model, sends the prompt, blocks on `session.wait` until the session is
idle, then returns a [`opencode.lib.RunResult`](../reference/testing.md#opencode.lib.RunResult):

- `messages`: the messages this run added, oldest first, starting with the prompt
- `text`, `outcome`, `error`: the final answer and how the run ended
- `cost`, `tokens`: usage of this run; `session.cost` and `session.tokens` are the session totals

Set `model` together with `agent` when creating a session: otherwise the session can run on a different model
than the agent's.

Pass `session_id=` to continue a session. `agent` and `model` then switch the session before the prompt, and
`messages` holds only the new messages.

### Interrupts

If waiting ends early, through `KeyboardInterrupt`, cancelling the task (async), the `timeout`, or a connection
error, `run` calls `session.interrupt` before re-raising, so the agent does not keep running on the server.

### Files

`files` takes `PromptInputFileAttachment`s. Images and other inline content can be `data:` URLs:

```python
import base64
from opencode.types import PromptInputFileAttachment

png = base64.b64encode(open("screenshot.png", "rb").read()).decode()
client.session.run(
"What is wrong in this screenshot?",
files=[PromptInputFileAttachment(uri=f"data:image/png;base64,{png}", name="screenshot.png")],
)
```

## Async

The same helpers exist on `AsyncOpencode`:

```python
async with AsyncOpencode(url, password=password) as client:
await client.wait_until_ready(agents=["build"])
result = await client.session.run("Summarize README.md", agent="build")
```
2 changes: 1 addition & 1 deletion docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ entry; the common ones also have a guide. CI fails if a group on the client is m
| `client.reference` | References | - | [`ReferenceResource`](../reference/resources.md#opencode.resources.reference.ReferenceResource) |
| `client.rpc` | Plugin RPC | [Plugin RPC](plugin-rpc.md) | [`RpcResource`](../reference/resources.md#opencode.resources.rpc.RpcResource) |
| `client.server` | Server info and health | - | [`ServerResource`](../reference/resources.md#opencode.resources.server.ServerResource) |
| `client.session` | Sessions, prompts and their sub-resources | [Guide](sessions.md) | [`SessionResource`](../reference/resources.md#opencode.resources.session.SessionResource) |
| `client.session` | Sessions, prompts and their sub-resources; `run` for one-call prompts | [Guide](sessions.md), [headless](headless.md) | [`SessionResource`](../reference/resources.md#opencode.resources.session.SessionResource) |
| `client.shell` | Shells | - | [`ShellResource`](../reference/resources.md#opencode.resources.shell.ShellResource) |
| `client.skill` | Skills | - | [`SkillResource`](../reference/resources.md#opencode.resources.skill.SkillResource) |
| `client.vcs` | Version control status and branches | - | [`VcsResource`](../reference/resources.md#opencode.resources.vcs.VcsResource) |
Expand Down
57 changes: 57 additions & 0 deletions docs/guides/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Testing with the fake server

`opencode.testing.FakeServer` lets you test code that uses the SDK without starting `opencode serve` or calling
a model. It is an `httpx` transport (an `httpx.MockTransport`) that keeps sessions, messages and events in
memory and answers with payloads built from the generated models.

```python
from opencode import Opencode
from opencode.testing import FakeServer


def test_review() -> None:
fake = FakeServer(reply=lambda prompt: "LGTM" if "diff" in prompt else "?")
client = Opencode.fake(fake) # same as Opencode(transport=fake)

result = client.session.run("Review this diff", agent="build")

assert result.text == "LGTM"
assert fake.prompts == ["Review this diff"]
```

`AsyncOpencode.fake(fake)` works the same way.

## What it implements

Server info, agents, MCP servers, the event stream, and sessions: create, get, list, remove, switch agent or
model, prompt (with `resume=False` queueing the prompt without running it), wait, interrupt and messages, with
cursor pagination. Each prompt gets an assistant reply, an idle marker, cost and token usage. Other routes
answer `404` with a message naming the route.

## Simulating a slow boot

```python
fake = FakeServer(agents=["build"])
fake.add_agent("reviewer", after_polls=3) # listed from the 4th GET /api/agent
fake.set_mcp_status("github", "connected", after_polls=2)
Opencode.fake(fake).wait_until_ready(agents=["reviewer"], mcp_servers=["github"])
```

## Injecting failures

```python
fake.fail(
"POST",
r"/api/session/.+/prompt",
status=409,
body={"_tag": "SessionBusyError", "sessionID": "ses_1", "message": "busy"},
)
fake.fail("GET", "/api/info", exception=httpx.ConnectError("refused"), times=2)
```

`path` is a regular expression matched against the whole path. `times=None` fails every matching request.

## Inspecting state

`fake.requests` records every `httpx.Request`; `fake.sessions`, `fake.messages`, `fake.inbox` and `fake.events`
hold the server state and can be changed directly.
4 changes: 2 additions & 2 deletions docs/reference/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

::: opencode.Opencode
options:
members: [with_options, close]
members: [with_options, close, wait_until_ready, fake]
show_bases: false

::: opencode.AsyncOpencode
options:
members: [with_options, close]
members: [with_options, close, wait_until_ready, fake]
show_bases: false

## Module constants
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Helpers and testing

## `session.run` result

::: opencode.lib.RunResult

## Fake server

::: opencode.testing.FakeServer
options:
members: [add_agent, set_mcp_status, fail, prompts]

::: opencode.testing.DEFAULT_MODEL
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ nav:
- Guides:
- guides/index.md
- Sessions and prompts: guides/sessions.md
- Running agents headless: guides/headless.md
- Testing with the fake server: guides/testing.md
- Async: guides/async.md
- Models and unions: guides/models.md
- Pagination: guides/pagination.md
Expand All @@ -155,6 +157,7 @@ nav:
- Shared events and RPC: reference/events.md
- Terminals: reference/terminals.md
- Exceptions: reference/exceptions.md
- Helpers and testing: reference/testing.md
- Types: reference/types/index.md
- Project:
- How it's built: project/how-its-built.md
Expand Down
15 changes: 14 additions & 1 deletion src/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

from __future__ import annotations

from . import types
from typing import TYPE_CHECKING, Any

from ._client import AsyncOpencode, Opencode

# <generated:error-imports>
Expand Down Expand Up @@ -108,6 +109,9 @@
rpc_method,
)

if TYPE_CHECKING:
from . import types

# <generated:api-version>
API_VERSION = "2.1.2"
"""The OpenCode server version this client was generated from."""
Expand Down Expand Up @@ -206,3 +210,12 @@
"WorktreeError",
# </generated:error-exports>
]


def __getattr__(name: str) -> Any:
# `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
if name == "types":
import importlib

return importlib.import_module(".types", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Loading
Loading