opencode-python-sdk 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/PKG-INFO +50 -1
  2. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/README.md +49 -0
  3. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/__init__.py +14 -1
  4. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_base_client.py +24 -3
  5. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_client.py +67 -2
  6. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_events.py +5 -2
  7. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_rpc.py +9 -2
  8. opencode_python_sdk-0.2.0/src/opencode/lib/__init__.py +5 -0
  9. opencode_python_sdk-0.2.0/src/opencode/lib/_ready.py +91 -0
  10. opencode_python_sdk-0.2.0/src/opencode/lib/_session.py +231 -0
  11. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/agent.py +13 -11
  12. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/command.py +9 -7
  13. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/config.py +12 -10
  14. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/credential.py +13 -11
  15. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/debug.py +9 -7
  16. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/event.py +9 -5
  17. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/experimental.py +31 -29
  18. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/file.py +15 -13
  19. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/form.py +9 -7
  20. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/generate.py +9 -7
  21. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/integration.py +28 -26
  22. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/location.py +9 -7
  23. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/mcp.py +13 -11
  24. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/message.py +9 -8
  25. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/migration.py +10 -12
  26. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/model.py +12 -10
  27. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/permission.py +29 -27
  28. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/plugin.py +12 -10
  29. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/project.py +14 -12
  30. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/provider.py +13 -11
  31. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/pty.py +23 -21
  32. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/reference.py +9 -7
  33. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/rpc.py +9 -7
  34. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/server.py +14 -12
  35. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/session.py +92 -90
  36. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/shell.py +19 -17
  37. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/skill.py +9 -7
  38. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/vcs.py +22 -20
  39. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/voice.py +9 -7
  40. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/websearch.py +13 -11
  41. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/worktree.py +13 -11
  42. opencode_python_sdk-0.2.0/src/opencode/testing.py +508 -0
  43. opencode_python_sdk-0.2.0/tests/test_helpers.py +252 -0
  44. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_live.py +22 -1
  45. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_models.py +9 -0
  46. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/.gitignore +0 -0
  47. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/CHANGELOG.md +0 -0
  48. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/LICENSE +0 -0
  49. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/api.md +0 -0
  50. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/pyproject.toml +0 -0
  51. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/.generated.json +0 -0
  52. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_constants.py +0 -0
  53. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_errors.py +0 -0
  54. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_exceptions.py +0 -0
  55. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_models.py +0 -0
  56. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_pagination.py +0 -0
  57. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_pty.py +0 -0
  58. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_qs.py +0 -0
  59. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_resource.py +0 -0
  60. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_server.py +0 -0
  61. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_streaming.py +0 -0
  62. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_types.py +0 -0
  63. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_unions.py +0 -0
  64. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_version.py +0 -0
  65. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/py.typed +0 -0
  66. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/__init__.py +0 -0
  67. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/types/__init__.py +0 -0
  68. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/types/_generated.py +0 -0
  69. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/__init__.py +0 -0
  70. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/conftest.py +0 -0
  71. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_async_client.py +0 -0
  72. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_client.py +0 -0
  73. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_docs_coverage.py +0 -0
  74. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_encoding.py +0 -0
  75. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_events.py +0 -0
  76. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_pagination.py +0 -0
  77. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_pty.py +0 -0
  78. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_rpc.py +0 -0
  79. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencode-python-sdk
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Typed Python client for the OpenCode 2.x server API
5
5
  Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
6
6
  Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
@@ -350,6 +350,55 @@ client.session.get("ses_...", timeout=5.0) # per call
350
350
 
351
351
  The default timeout is 10 minutes with a 10 second connect timeout.
352
352
 
353
+ ## Running an agent headless
354
+
355
+ `client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
356
+ reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
357
+ final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
358
+ asynchronously after the server boots. Both have async versions on `AsyncOpencode`.
359
+
360
+ ```python
361
+ from opencode import Opencode
362
+ from opencode.types import ModelRef, PromptInputFileAttachment
363
+
364
+ with Opencode("http://127.0.0.1:4096", password="...") as client:
365
+ client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
366
+ result = client.session.run(
367
+ "Review the attached diff",
368
+ agent="reviewer",
369
+ model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
370
+ files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
371
+ timeout=600,
372
+ )
373
+ print(result.outcome, result.text)
374
+ print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
375
+ ```
376
+
377
+ If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
378
+ on the server before re-raising, so the agent does not keep running unattended.
379
+
380
+ ## Testing your code
381
+
382
+ `opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
383
+ messages and events in memory and answers with the same payloads as the real server. No process, no model.
384
+
385
+ ```python
386
+ from opencode import Opencode
387
+ from opencode.testing import FakeServer
388
+
389
+
390
+ def test_summarize() -> None:
391
+ fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
392
+ client = Opencode.fake(fake) # same as Opencode(transport=fake)
393
+
394
+ assert client.session.run("README").text == "summary of 'README'"
395
+ assert fake.prompts == ["README"]
396
+ ```
397
+
398
+ `fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
399
+ and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
400
+ does not implement answer `404`.
401
+
353
402
  ## Running a server
354
403
 
355
404
  `OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
@@ -313,6 +313,55 @@ client.session.get("ses_...", timeout=5.0) # per call
313
313
 
314
314
  The default timeout is 10 minutes with a 10 second connect timeout.
315
315
 
316
+ ## Running an agent headless
317
+
318
+ `client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
319
+ reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
320
+ final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
321
+ asynchronously after the server boots. Both have async versions on `AsyncOpencode`.
322
+
323
+ ```python
324
+ from opencode import Opencode
325
+ from opencode.types import ModelRef, PromptInputFileAttachment
326
+
327
+ with Opencode("http://127.0.0.1:4096", password="...") as client:
328
+ client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
329
+ result = client.session.run(
330
+ "Review the attached diff",
331
+ agent="reviewer",
332
+ model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
333
+ files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
334
+ timeout=600,
335
+ )
336
+ print(result.outcome, result.text)
337
+ print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
338
+ ```
339
+
340
+ If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
341
+ on the server before re-raising, so the agent does not keep running unattended.
342
+
343
+ ## Testing your code
344
+
345
+ `opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
346
+ messages and events in memory and answers with the same payloads as the real server. No process, no model.
347
+
348
+ ```python
349
+ from opencode import Opencode
350
+ from opencode.testing import FakeServer
351
+
352
+
353
+ def test_summarize() -> None:
354
+ fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
355
+ client = Opencode.fake(fake) # same as Opencode(transport=fake)
356
+
357
+ assert client.session.run("README").text == "summary of 'README'"
358
+ assert fake.prompts == ["README"]
359
+ ```
360
+
361
+ `fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
362
+ and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
363
+ does not implement answer `404`.
364
+
316
365
  ## Running a server
317
366
 
318
367
  `OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
@@ -14,7 +14,8 @@ Quick start::
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from . import types
17
+ from typing import TYPE_CHECKING, Any
18
+
18
19
  from ._client import AsyncOpencode, Opencode
19
20
 
20
21
  # <generated:error-imports>
@@ -108,6 +109,9 @@ from ._rpc import (
108
109
  rpc_method,
109
110
  )
110
111
 
112
+ if TYPE_CHECKING:
113
+ from . import types
114
+
111
115
  # <generated:api-version>
112
116
  API_VERSION = "2.1.2"
113
117
  """The OpenCode server version this client was generated from."""
@@ -206,3 +210,12 @@ __all__ = [
206
210
  "WorktreeError",
207
211
  # </generated:error-exports>
208
212
  ]
213
+
214
+
215
+ def __getattr__(name: str) -> Any:
216
+ # `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
217
+ if name == "types":
218
+ import importlib
219
+
220
+ return importlib.import_module(".types", __name__)
221
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -7,6 +7,7 @@ transient failures, and turns responses into typed values or errors.
7
7
  from __future__ import annotations
8
8
 
9
9
  import base64
10
+ import builtins
10
11
  import email.utils
11
12
  import functools
12
13
  import json
@@ -60,8 +61,18 @@ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
60
61
  return pydantic.TypeAdapter(tp)
61
62
 
62
63
 
64
+ @functools.cache
65
+ def _resolve_type(expr: str) -> Any:
66
+ # Generated resources name their response types as strings, so `import opencode` does not build every model.
67
+ from . import types
68
+
69
+ return eval(expr, {**vars(types), "builtins": builtins}) # expressions come from the generator
70
+
71
+
63
72
  def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
64
- """Returns a cached ``TypeAdapter`` for a response type."""
73
+ """Returns a cached ``TypeAdapter`` for a response type, or for a type expression over ``opencode.types``."""
74
+ if isinstance(tp, str):
75
+ tp = _resolve_type(tp)
65
76
  try:
66
77
  return _cached_adapter(tp)
67
78
  except TypeError: # unhashable type expression
@@ -307,6 +318,7 @@ class SyncAPIClient(_BaseClient):
307
318
  default_headers: Mapping[str, str] | None,
308
319
  default_query: Mapping[str, object] | None,
309
320
  http_client: httpx.Client | None,
321
+ transport: httpx.BaseTransport | None = None,
310
322
  ) -> None:
311
323
  super().__init__(
312
324
  base_url=base_url,
@@ -319,7 +331,11 @@ class SyncAPIClient(_BaseClient):
319
331
  default_query=default_query,
320
332
  )
321
333
  self._owns_http = http_client is None
322
- self._http = http_client or httpx.Client(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
334
+ if http_client is not None and transport is not None:
335
+ raise ValueError("Pass either http_client or transport, not both")
336
+ self._http = http_client or httpx.Client(
337
+ limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
338
+ )
323
339
 
324
340
  def close(self) -> None:
325
341
  """Closes the connection pool, unless it was passed in by the caller."""
@@ -421,6 +437,7 @@ class AsyncAPIClient(_BaseClient):
421
437
  default_headers: Mapping[str, str] | None,
422
438
  default_query: Mapping[str, object] | None,
423
439
  http_client: httpx.AsyncClient | None,
440
+ transport: httpx.AsyncBaseTransport | None = None,
424
441
  ) -> None:
425
442
  super().__init__(
426
443
  base_url=base_url,
@@ -433,7 +450,11 @@ class AsyncAPIClient(_BaseClient):
433
450
  default_query=default_query,
434
451
  )
435
452
  self._owns_http = http_client is None
436
- self._http = http_client or httpx.AsyncClient(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
453
+ if http_client is not None and transport is not None:
454
+ raise ValueError("Pass either http_client or transport, not both")
455
+ self._http = http_client or httpx.AsyncClient(
456
+ limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
457
+ )
437
458
 
438
459
  async def close(self) -> None:
439
460
  """Closes the connection pool, unless it was passed in by the caller."""
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from functools import cached_property
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from typing_extensions import Self
7
7
 
@@ -14,12 +14,17 @@ from .resources import AsyncResources, SyncResources
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  import os
17
- from collections.abc import Mapping
17
+ from collections.abc import Iterable, Mapping
18
18
 
19
19
  import httpx
20
20
 
21
+ from .testing import FakeServer
22
+
21
23
  __all__ = ["AsyncOpencode", "Opencode"]
22
24
 
25
+ FAKE_BASE_URL = "http://opencode.fake"
26
+ """Base URL of clients made by ``Opencode.fake()``."""
27
+
23
28
 
24
29
  class Opencode(SyncAPIClient, SyncResources):
25
30
  """Synchronous client for an OpenCode server.
@@ -38,6 +43,8 @@ class Opencode(SyncAPIClient, SyncResources):
38
43
  default_query: Query parameters sent with every request.
39
44
  http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
40
45
  ownership and must close it.
46
+ transport: An ``httpx`` transport for the client's own connection pool, such as
47
+ :class:`opencode.testing.FakeServer`. Cannot be combined with ``http_client``.
41
48
 
42
49
  Example::
43
50
 
@@ -60,6 +67,7 @@ class Opencode(SyncAPIClient, SyncResources):
60
67
  default_headers: Mapping[str, str] | None = None,
61
68
  default_query: Mapping[str, object] | None = None,
62
69
  http_client: httpx.Client | None = None,
70
+ transport: httpx.BaseTransport | None = None,
63
71
  ) -> None:
64
72
  super().__init__(
65
73
  base_url=base_url,
@@ -71,6 +79,7 @@ class Opencode(SyncAPIClient, SyncResources):
71
79
  default_headers=default_headers,
72
80
  default_query=default_query,
73
81
  http_client=http_client,
82
+ transport=transport,
74
83
  )
75
84
 
76
85
  @cached_property
@@ -83,6 +92,33 @@ class Opencode(SyncAPIClient, SyncResources):
83
92
  """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
84
93
  return TypedRpcResource(self)
85
94
 
95
+ @classmethod
96
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
97
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
98
+
99
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
100
+ """
101
+ from .testing import FakeServer
102
+
103
+ kwargs.setdefault("password", "")
104
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
105
+
106
+ def wait_until_ready(
107
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
108
+ ) -> None:
109
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
110
+ connected.
111
+
112
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
113
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
114
+
115
+ Raises:
116
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
117
+ """
118
+ from .lib._ready import wait_until_ready
119
+
120
+ wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
121
+
86
122
  def with_options(
87
123
  self,
88
124
  *,
@@ -138,6 +174,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
138
174
  default_headers: Mapping[str, str] | None = None,
139
175
  default_query: Mapping[str, object] | None = None,
140
176
  http_client: httpx.AsyncClient | None = None,
177
+ transport: httpx.AsyncBaseTransport | None = None,
141
178
  ) -> None:
142
179
  super().__init__(
143
180
  base_url=base_url,
@@ -149,6 +186,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
149
186
  default_headers=default_headers,
150
187
  default_query=default_query,
151
188
  http_client=http_client,
189
+ transport=transport,
152
190
  )
153
191
 
154
192
  @cached_property
@@ -161,6 +199,33 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
161
199
  """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
162
200
  return AsyncTypedRpcResource(self)
163
201
 
202
+ @classmethod
203
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
204
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
205
+
206
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
207
+ """
208
+ from .testing import FakeServer
209
+
210
+ kwargs.setdefault("password", "")
211
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
212
+
213
+ async def wait_until_ready(
214
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
215
+ ) -> None:
216
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
217
+ connected.
218
+
219
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
220
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
221
+
222
+ Raises:
223
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
224
+ """
225
+ from .lib._ready import async_wait_until_ready
226
+
227
+ await async_wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
228
+
164
229
  def with_options(
165
230
  self,
166
231
  *,
@@ -31,7 +31,6 @@ import anyio
31
31
  from typing_extensions import Self
32
32
 
33
33
  from ._exceptions import APIConnectionError, APIStatusError, OpencodeError
34
- from .types import ServerConnectedEvent
35
34
 
36
35
  if TYPE_CHECKING:
37
36
  from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
@@ -39,7 +38,7 @@ if TYPE_CHECKING:
39
38
 
40
39
  from ._client import AsyncOpencode, Opencode
41
40
  from ._streaming import AsyncStream, Stream
42
- from .types import Event
41
+ from .types import Event, ServerConnectedEvent
43
42
 
44
43
  __all__ = [
45
44
  "SUBSCRIBER_CAPACITY",
@@ -302,6 +301,8 @@ class EventHub:
302
301
 
303
302
  def _read(self, connection: _SyncConnection) -> None:
304
303
  """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
304
+ from .types import ServerConnectedEvent
305
+
305
306
  stream = self._client.event.subscribe()
306
307
  with self._lock:
307
308
  connection.stream = stream
@@ -605,6 +606,8 @@ class AsyncEventHub:
605
606
 
606
607
  async def _read(self, connection: _AsyncConnection) -> None:
607
608
  """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
609
+ from .types import ServerConnectedEvent
610
+
608
611
  stream: AsyncStream[Event] = await self._client.event.subscribe()
609
612
  try:
610
613
  stream.on_activity = connection.activity
@@ -44,7 +44,6 @@ from ._errors import RpcError, RpcInternalError
44
44
  from ._exceptions import OpencodeError, validation_summary
45
45
  from ._types import NOT_GIVEN, NotGiven
46
46
  from .resources.rpc import AsyncRpcResource, RpcResource
47
- from .types import RpcEvent
48
47
 
49
48
  if TYPE_CHECKING:
50
49
  from collections.abc import Mapping
@@ -53,7 +52,7 @@ if TYPE_CHECKING:
53
52
  from ._client import AsyncOpencode, Opencode
54
53
  from ._events import AsyncSubscription, Subscription
55
54
  from ._types import Headers, Timeout
56
- from .types import Location
55
+ from .types import Location, RpcEvent
57
56
 
58
57
  __all__ = [
59
58
  "AsyncRpcClient",
@@ -327,11 +326,15 @@ class RpcClient(Generic[_D]):
327
326
 
328
327
  def events(self) -> Subscription[RpcEvent]:
329
328
  """Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
329
+ from .types import RpcEvent
330
+
330
331
  prefix = f"rpc.{self.definition.id}."
331
332
  return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
332
333
 
333
334
  def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]:
334
335
  """Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
336
+ from .types import RpcEvent
337
+
335
338
  _check_event(self.definition, event)
336
339
  wire = self.definition.event_type(event)
337
340
  return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
@@ -396,11 +399,15 @@ class AsyncRpcClient(Generic[_D]):
396
399
 
397
400
  def events(self) -> AsyncSubscription[RpcEvent]:
398
401
  """Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
402
+ from .types import RpcEvent
403
+
399
404
  prefix = f"rpc.{self.definition.id}."
400
405
  return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
401
406
 
402
407
  def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]:
403
408
  """Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
409
+ from .types import RpcEvent
410
+
404
411
  _check_event(self.definition, event)
405
412
  wire = self.definition.event_type(event)
406
413
  return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
@@ -0,0 +1,5 @@
1
+ """Hand-written helpers layered over the generated client."""
2
+
3
+ from ._session import RunResult
4
+
5
+ __all__ = ["RunResult"]
@@ -0,0 +1,91 @@
1
+ """``client.wait_until_ready``: poll until agents and MCP servers have loaded.
2
+
3
+ OpenCode loads agents and connects MCP servers in the background after it starts listening, so a freshly booted
4
+ server can answer requests before the agent a caller needs exists.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import TYPE_CHECKING
11
+
12
+ import anyio
13
+
14
+ from .._exceptions import APIConnectionError, ServiceUnavailableError
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable
18
+
19
+ from .._client import AsyncOpencode, Opencode
20
+ from ..types import AgentListResponse, McpListResponse
21
+
22
+ __all__ = ["POLL_INTERVAL", "async_wait_until_ready", "wait_until_ready"]
23
+
24
+ POLL_INTERVAL = 0.25
25
+ """Seconds between polls."""
26
+
27
+ _TRANSIENT = (APIConnectionError, ServiceUnavailableError)
28
+
29
+
30
+ def _missing(
31
+ agents: frozenset[str],
32
+ mcp_servers: frozenset[str],
33
+ agent_list: AgentListResponse | None,
34
+ mcp_list: McpListResponse | None,
35
+ ) -> list[str]:
36
+ missing: list[str] = []
37
+ if agents:
38
+ present = {agent.id for agent in agent_list.data} if agent_list is not None else set[str]()
39
+ missing += [f"agent {name!r}" for name in sorted(agents - present)]
40
+ if mcp_servers:
41
+ connected = (
42
+ {server.name for server in mcp_list.data if server.status.status == "connected"}
43
+ if mcp_list is not None
44
+ else set[str]()
45
+ )
46
+ missing += [f"MCP server {name!r}" for name in sorted(mcp_servers - connected)]
47
+ return missing
48
+
49
+
50
+ def wait_until_ready(
51
+ client: Opencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
52
+ ) -> None:
53
+ wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers)
54
+ deadline = time.monotonic() + timeout
55
+ while True:
56
+ agent_list = mcp_list = None
57
+ missing = ["server"]
58
+ try:
59
+ client.server.info()
60
+ agent_list = client.agent.list() if wanted_agents else None
61
+ mcp_list = client.mcp.list() if wanted_mcp else None
62
+ missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list)
63
+ except _TRANSIENT:
64
+ pass
65
+ if not missing:
66
+ return
67
+ if time.monotonic() >= deadline:
68
+ raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}")
69
+ time.sleep(POLL_INTERVAL)
70
+
71
+
72
+ async def async_wait_until_ready(
73
+ client: AsyncOpencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
74
+ ) -> None:
75
+ wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers)
76
+ deadline = time.monotonic() + timeout
77
+ while True:
78
+ agent_list = mcp_list = None
79
+ missing = ["server"]
80
+ try:
81
+ await client.server.info()
82
+ agent_list = await client.agent.list() if wanted_agents else None
83
+ mcp_list = await client.mcp.list() if wanted_mcp else None
84
+ missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list)
85
+ except _TRANSIENT:
86
+ pass
87
+ if not missing:
88
+ return
89
+ if time.monotonic() >= deadline:
90
+ raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}")
91
+ await anyio.sleep(POLL_INTERVAL)