opencode-python-sdk 0.1.3__py3-none-any.whl → 0.2.0__py3-none-any.whl

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 (45) hide show
  1. opencode/__init__.py +14 -1
  2. opencode/_base_client.py +24 -3
  3. opencode/_client.py +67 -2
  4. opencode/_events.py +5 -2
  5. opencode/_rpc.py +9 -2
  6. opencode/lib/__init__.py +5 -0
  7. opencode/lib/_ready.py +91 -0
  8. opencode/lib/_session.py +231 -0
  9. opencode/resources/agent.py +13 -11
  10. opencode/resources/command.py +9 -7
  11. opencode/resources/config.py +12 -10
  12. opencode/resources/credential.py +13 -11
  13. opencode/resources/debug.py +9 -7
  14. opencode/resources/event.py +9 -5
  15. opencode/resources/experimental.py +31 -29
  16. opencode/resources/file.py +15 -13
  17. opencode/resources/form.py +9 -7
  18. opencode/resources/generate.py +9 -7
  19. opencode/resources/integration.py +28 -26
  20. opencode/resources/location.py +9 -7
  21. opencode/resources/mcp.py +13 -11
  22. opencode/resources/message.py +9 -8
  23. opencode/resources/migration.py +10 -12
  24. opencode/resources/model.py +12 -10
  25. opencode/resources/permission.py +29 -27
  26. opencode/resources/plugin.py +12 -10
  27. opencode/resources/project.py +14 -12
  28. opencode/resources/provider.py +13 -11
  29. opencode/resources/pty.py +23 -21
  30. opencode/resources/reference.py +9 -7
  31. opencode/resources/rpc.py +9 -7
  32. opencode/resources/server.py +14 -12
  33. opencode/resources/session.py +92 -90
  34. opencode/resources/shell.py +19 -17
  35. opencode/resources/skill.py +9 -7
  36. opencode/resources/vcs.py +22 -20
  37. opencode/resources/voice.py +9 -7
  38. opencode/resources/websearch.py +13 -11
  39. opencode/resources/worktree.py +13 -11
  40. opencode/testing.py +508 -0
  41. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.0.dist-info}/METADATA +50 -1
  42. opencode_python_sdk-0.2.0.dist-info/RECORD +62 -0
  43. opencode_python_sdk-0.1.3.dist-info/RECORD +0 -58
  44. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.0.dist-info}/WHEEL +0 -0
  45. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py CHANGED
@@ -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}")
opencode/_base_client.py CHANGED
@@ -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."""
opencode/_client.py CHANGED
@@ -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
  *,
opencode/_events.py CHANGED
@@ -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
opencode/_rpc.py CHANGED
@@ -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"]
opencode/lib/_ready.py ADDED
@@ -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)
@@ -0,0 +1,231 @@
1
+ """``client.session.run``: create or reuse a session, prompt it, wait until idle, and collect the result.
2
+
3
+ The generated ``SessionResource`` / ``AsyncSessionResource`` inherit from the classes here (see
4
+ ``codegen/render.py``, ``HELPER_BASES``), so these methods sit next to the generated ones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ import anyio
14
+
15
+ from .._resource import AsyncAPIResource, SyncAPIResource
16
+ from .._types import NOT_GIVEN, NotGiven, Timeout
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+ from ..resources.session import AsyncSessionResource, SessionResource
22
+ from ..types import (
23
+ AgentID,
24
+ ModelRef,
25
+ PromptInputFileAttachment,
26
+ SessionID,
27
+ SessionInfo,
28
+ SessionMessageAssistant,
29
+ SessionMessageInfo,
30
+ SessionStructuredError,
31
+ TokenUsageInfo,
32
+ )
33
+
34
+ __all__ = ["AsyncSessionHelpers", "RunResult", "SessionHelpers"]
35
+
36
+
37
+ @dataclass
38
+ class RunResult:
39
+ """What :meth:`SessionHelpers.run` returns."""
40
+
41
+ session: SessionInfo
42
+ """The session as it was after the run. ``session.cost`` and ``session.tokens`` cover the whole session."""
43
+ messages: list[SessionMessageInfo] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
44
+ """Messages this run added, oldest first, starting with the prompt."""
45
+
46
+ @property
47
+ def assistant_messages(self) -> list[SessionMessageAssistant]:
48
+ """The assistant messages of this run, oldest first."""
49
+ from ..types import SessionMessageAssistant
50
+
51
+ return [message for message in self.messages if isinstance(message, SessionMessageAssistant)]
52
+
53
+ @property
54
+ def text(self) -> str:
55
+ """Text of the last assistant message, or ``""`` when the agent produced none."""
56
+ from ..types import SessionMessageAssistantText
57
+
58
+ for message in reversed(self.assistant_messages):
59
+ parts = [item.text for item in message.content if isinstance(item, SessionMessageAssistantText)]
60
+ if parts:
61
+ return "".join(parts)
62
+ return ""
63
+
64
+ @property
65
+ def outcome(self) -> str | None:
66
+ """``succeeded``, ``failed`` or ``interrupted``, from the run's idle marker or the session."""
67
+ from ..types import SessionMessageIdle
68
+
69
+ for message in reversed(self.messages):
70
+ if isinstance(message, SessionMessageIdle):
71
+ return message.outcome
72
+ return self.session.outcome
73
+
74
+ @property
75
+ def error(self) -> SessionStructuredError | None:
76
+ """Why the run failed, from its idle marker or last assistant message, or ``None``."""
77
+ from ..types import SessionMessageAssistant, SessionMessageIdle
78
+
79
+ for message in reversed(self.messages):
80
+ if isinstance(message, (SessionMessageIdle, SessionMessageAssistant)) and message.error is not None:
81
+ return message.error
82
+ return None
83
+
84
+ @property
85
+ def cost(self) -> float:
86
+ """Cost of this run in USD, summed over its assistant messages."""
87
+ return sum(message.cost or 0.0 for message in self.assistant_messages)
88
+
89
+ @property
90
+ def tokens(self) -> TokenUsageInfo:
91
+ """Token usage of this run, summed over its assistant messages."""
92
+ from ..types import TokenUsageInfo, TokenUsageInfoCache
93
+
94
+ total = TokenUsageInfo(input=0, output=0, reasoning=0, cache=TokenUsageInfoCache(read=0, write=0))
95
+ for message in self.assistant_messages:
96
+ usage = message.tokens
97
+ if usage is None:
98
+ continue
99
+ total.input += usage.input
100
+ total.output += usage.output
101
+ total.reasoning += usage.reasoning
102
+ total.cache.read += usage.cache.read
103
+ total.cache.write += usage.cache.write
104
+ return total
105
+
106
+
107
+ def _new_messages(newest_first: list[SessionMessageInfo], marker: str | None) -> list[SessionMessageInfo]:
108
+ from ..types import SessionMessageUser
109
+
110
+ collected: list[SessionMessageInfo] = []
111
+ for message in newest_first:
112
+ if marker is not None and message.id == marker:
113
+ break
114
+ collected.append(message)
115
+ collected.reverse()
116
+ # Drop anything that was already queued before our prompt.
117
+ for index, message in enumerate(collected):
118
+ if isinstance(message, SessionMessageUser):
119
+ return collected[index:]
120
+ return collected
121
+
122
+
123
+ class SessionHelpers(SyncAPIResource):
124
+ """Hand-written helpers on ``client.session``."""
125
+
126
+ def run(
127
+ self,
128
+ text: str,
129
+ *,
130
+ agent: AgentID | None = None,
131
+ model: ModelRef | None = None,
132
+ files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN,
133
+ session_id: SessionID | None = None,
134
+ timeout: Timeout = None,
135
+ ) -> RunResult:
136
+ """Runs one prompt to completion and returns what the agent produced.
137
+
138
+ Creates a session with ``agent`` and ``model`` (or reuses ``session_id``, switching its agent and model when
139
+ given), sends ``text``, blocks until the session is idle, and reads back the new messages and the session's
140
+ usage.
141
+
142
+ If the wait is interrupted (``KeyboardInterrupt``, a timeout, or any other error), the session is
143
+ interrupted on the server before the error propagates, so no agent keeps running unattended.
144
+
145
+ Args:
146
+ text: The prompt.
147
+ agent: Agent for a new session, or to switch an existing one to.
148
+ model: Model for a new session, or to switch an existing one to. Set it with ``agent`` so the session
149
+ does not start on another model.
150
+ files: File attachments; images can be ``data:`` URLs.
151
+ session_id: Continue this session instead of creating one.
152
+ timeout: Seconds to wait for the session to become idle. ``None`` waits forever.
153
+
154
+ Example::
155
+
156
+ result = client.session.run("Summarize README.md", agent="build")
157
+ print(result.text, result.cost)
158
+ """
159
+ resource = cast("SessionResource", self)
160
+ if session_id is None:
161
+ session = resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN)
162
+ session_id = session.id
163
+ marker = None
164
+ else:
165
+ if agent is not None:
166
+ resource.switch_agent(session_id, agent=agent)
167
+ if model is not None:
168
+ resource.switch_model(session_id, model=model)
169
+ latest = self._client.message.list(session_id, order="desc", limit=1).data
170
+ marker = latest[0].id if latest else None
171
+
172
+ resource.prompt(session_id, text=text, files=files)
173
+ try:
174
+ resource.wait(session_id, timeout=timeout)
175
+ except BaseException:
176
+ with contextlib.suppress(Exception):
177
+ resource.interrupt(session_id)
178
+ raise
179
+
180
+ newest_first: list[SessionMessageInfo] = []
181
+ for message in self._client.message.list(session_id, order="desc"):
182
+ if marker is not None and message.id == marker:
183
+ break
184
+ newest_first.append(message)
185
+ return RunResult(session=resource.get(session_id), messages=_new_messages(newest_first, marker))
186
+
187
+
188
+ class AsyncSessionHelpers(AsyncAPIResource):
189
+ """Hand-written helpers on ``client.session`` of :class:`opencode.AsyncOpencode`."""
190
+
191
+ async def run(
192
+ self,
193
+ text: str,
194
+ *,
195
+ agent: AgentID | None = None,
196
+ model: ModelRef | None = None,
197
+ files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN,
198
+ session_id: SessionID | None = None,
199
+ timeout: Timeout = None,
200
+ ) -> RunResult:
201
+ """Runs one prompt to completion. See :meth:`SessionHelpers.run`.
202
+
203
+ Cancelling the task interrupts the session on the server before the cancellation propagates.
204
+ """
205
+ resource = cast("AsyncSessionResource", self)
206
+ if session_id is None:
207
+ session = await resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN)
208
+ session_id = session.id
209
+ marker = None
210
+ else:
211
+ if agent is not None:
212
+ await resource.switch_agent(session_id, agent=agent)
213
+ if model is not None:
214
+ await resource.switch_model(session_id, model=model)
215
+ latest = (await self._client.message.list(session_id, order="desc", limit=1)).data
216
+ marker = latest[0].id if latest else None
217
+
218
+ await resource.prompt(session_id, text=text, files=files)
219
+ try:
220
+ await resource.wait(session_id, timeout=timeout)
221
+ except BaseException:
222
+ with anyio.CancelScope(shield=True), contextlib.suppress(Exception):
223
+ await resource.interrupt(session_id)
224
+ raise
225
+
226
+ newest_first: list[SessionMessageInfo] = []
227
+ async for message in self._client.message.list(session_id, order="desc"):
228
+ if marker is not None and message.id == marker:
229
+ break
230
+ newest_first.append(message)
231
+ return RunResult(session=await resource.get(session_id), messages=_new_messages(newest_first, marker))