opencode-python-sdk 0.1.2__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 (46) hide show
  1. opencode/__init__.py +62 -1
  2. opencode/_base_client.py +24 -3
  3. opencode/_client.py +90 -2
  4. opencode/_events.py +732 -0
  5. opencode/_rpc.py +489 -0
  6. opencode/_streaming.py +22 -1
  7. opencode/lib/__init__.py +5 -0
  8. opencode/lib/_ready.py +91 -0
  9. opencode/lib/_session.py +231 -0
  10. opencode/resources/agent.py +13 -11
  11. opencode/resources/command.py +9 -7
  12. opencode/resources/config.py +12 -10
  13. opencode/resources/credential.py +13 -11
  14. opencode/resources/debug.py +9 -7
  15. opencode/resources/event.py +9 -5
  16. opencode/resources/experimental.py +31 -29
  17. opencode/resources/file.py +15 -13
  18. opencode/resources/form.py +9 -7
  19. opencode/resources/generate.py +9 -7
  20. opencode/resources/integration.py +28 -26
  21. opencode/resources/location.py +9 -7
  22. opencode/resources/mcp.py +13 -11
  23. opencode/resources/message.py +9 -8
  24. opencode/resources/migration.py +10 -12
  25. opencode/resources/model.py +12 -10
  26. opencode/resources/permission.py +29 -27
  27. opencode/resources/plugin.py +12 -10
  28. opencode/resources/project.py +14 -12
  29. opencode/resources/provider.py +13 -11
  30. opencode/resources/pty.py +23 -21
  31. opencode/resources/reference.py +9 -7
  32. opencode/resources/rpc.py +9 -7
  33. opencode/resources/server.py +14 -12
  34. opencode/resources/session.py +92 -90
  35. opencode/resources/shell.py +19 -17
  36. opencode/resources/skill.py +9 -7
  37. opencode/resources/vcs.py +22 -20
  38. opencode/resources/voice.py +9 -7
  39. opencode/resources/websearch.py +13 -11
  40. opencode/resources/worktree.py +13 -11
  41. opencode/testing.py +508 -0
  42. {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/METADATA +126 -1
  43. opencode_python_sdk-0.2.0.dist-info/RECORD +62 -0
  44. opencode_python_sdk-0.1.2.dist-info/RECORD +0 -56
  45. {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/WHEEL +0 -0
  46. {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
opencode/testing.py ADDED
@@ -0,0 +1,508 @@
1
+ """An in-process fake OpenCode server for your own tests.
2
+
3
+ :class:`FakeServer` is an ``httpx`` transport that answers like an OpenCode server, keeping sessions, messages and
4
+ events in memory. Plug it into a client and test code that uses the SDK without starting ``opencode serve`` or
5
+ calling a model::
6
+
7
+ from opencode import Opencode
8
+ from opencode.testing import FakeServer
9
+
10
+ fake = FakeServer(reply="Done.")
11
+ client = Opencode.fake(fake) # or Opencode(transport=fake)
12
+
13
+ result = client.session.run("Fix the bug", agent="build")
14
+ assert result.text == "Done."
15
+ assert fake.prompts == ["Fix the bug"]
16
+
17
+ It covers sessions (create, get, list, remove, switch agent or model), prompts, waiting, interrupts, messages,
18
+ agents, MCP servers, server info and the event stream. Every payload is built from the generated models, so it
19
+ has the shape the real server sends. Other routes answer ``404``. Use :meth:`FakeServer.fail` to make a route fail.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import itertools
25
+ import json
26
+ import os
27
+ import re
28
+ import time
29
+ from collections.abc import Callable, Iterable, Mapping
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ import httpx
34
+
35
+ from ._models import BaseModel
36
+ from .types import (
37
+ AgentInfo,
38
+ AgentListResponse,
39
+ LocationPublicRef,
40
+ McpListResponse,
41
+ McpServer,
42
+ ModelRef,
43
+ ServerConnectedEvent,
44
+ ServerInfo,
45
+ SessionCreatedEvent,
46
+ SessionIdleEvent,
47
+ SessionInboxUser,
48
+ SessionInfo,
49
+ SessionInterruptResponse,
50
+ SessionMessageAssistant,
51
+ SessionMessageAssistantText,
52
+ SessionMessageIdle,
53
+ SessionMessageUser,
54
+ TokenUsageInfo,
55
+ )
56
+
57
+ __all__ = ["DEFAULT_MODEL", "FakeServer"]
58
+
59
+ DEFAULT_MODEL = ModelRef(id="fake-model", provider_id="fake")
60
+ """Model reported for sessions created without one."""
61
+
62
+ Reply = str | Callable[[str], str]
63
+ """Assistant reply: fixed text, or a function from the prompt text to the reply."""
64
+
65
+
66
+ @dataclass
67
+ class _Failure:
68
+ method: str
69
+ path: re.Pattern[str]
70
+ status: int
71
+ body: object
72
+ exception: BaseException | None
73
+ times: int | None
74
+
75
+
76
+ class FakeServer(httpx.MockTransport):
77
+ """An in-memory OpenCode server, usable as the ``transport`` of :class:`opencode.Opencode` and
78
+ :class:`opencode.AsyncOpencode`.
79
+
80
+ Args:
81
+ reply: What the assistant answers to each prompt: text, or a function of the prompt text.
82
+ agents: Agent ids :meth:`opencode.Opencode.agent.list` reports. Add more later with :meth:`add_agent`.
83
+ mcp_servers: MCP server names mapped to their status (``connected``, ``pending``, ``failed``, ...).
84
+ directory: Project directory reported as every session's location.
85
+ cost: Cost in USD reported for each assistant reply.
86
+ tokens: Input and output tokens reported for each assistant reply.
87
+
88
+ Attributes:
89
+ sessions: Sessions by id.
90
+ messages: Messages of each session, oldest first.
91
+ events: Events the stream replays after ``server.connected``.
92
+ requests: Every request received, in order.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ reply: Reply = "OK",
99
+ agents: Iterable[str] = ("build", "plan"),
100
+ mcp_servers: Mapping[str, str] | None = None,
101
+ directory: str = "/fake/project",
102
+ cost: float = 0.001,
103
+ tokens: tuple[int, int] = (10, 5),
104
+ ) -> None:
105
+ super().__init__(self._handle)
106
+ self.reply: Reply = reply
107
+ self.directory = directory
108
+ self.cost = cost
109
+ self.tokens = tokens
110
+ self.sessions: dict[str, SessionInfo] = {}
111
+ self.messages: dict[str, list[BaseModel]] = {}
112
+ self.inbox: dict[str, list[SessionInboxUser]] = {}
113
+ self.events: list[BaseModel] = []
114
+ self.requests: list[httpx.Request] = []
115
+ self._agents: list[tuple[str, int]] = [(agent, 0) for agent in agents]
116
+ self._mcp: dict[str, tuple[str, int]] = {name: (status, 0) for name, status in (mcp_servers or {}).items()}
117
+ self._polls = {"agent": 0, "mcp": 0}
118
+ self._failures: list[_Failure] = []
119
+ self._ids = itertools.count(1)
120
+
121
+ # -- configuration --------------------------------------------------------------------------------------
122
+
123
+ def add_agent(self, agent: str, *, after_polls: int = 0) -> None:
124
+ """Lists ``agent`` once ``GET /api/agent`` has been called ``after_polls`` more times, like an agent that
125
+ loads after boot."""
126
+ self._agents.append((agent, self._polls["agent"] + after_polls))
127
+
128
+ def set_mcp_status(self, name: str, status: str = "connected", *, after_polls: int = 0) -> None:
129
+ """Reports MCP server ``name`` with ``status`` once ``GET /api/mcp`` has been called ``after_polls`` more
130
+ times."""
131
+ self._mcp[name] = (status, self._polls["mcp"] + after_polls)
132
+
133
+ def fail(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ *,
138
+ status: int = 500,
139
+ body: object = None,
140
+ exception: BaseException | None = None,
141
+ times: int | None = 1,
142
+ ) -> None:
143
+ """Makes matching requests fail.
144
+
145
+ Args:
146
+ method: HTTP method, such as ``POST``.
147
+ path: Regular expression matched against the whole request path, such as ``/api/session/[^/]+/prompt``.
148
+ status: Status of the error response.
149
+ body: JSON error body. Defaults to an untagged body, so the error class follows ``status``.
150
+ exception: Raise this from the transport instead of answering, for example ``httpx.ConnectError(...)``
151
+ or ``KeyboardInterrupt()``.
152
+ times: How many requests fail; ``None`` for all of them.
153
+ """
154
+ if body is None:
155
+ body = {"message": f"Injected failure ({status})"}
156
+ self._failures.append(_Failure(method.upper(), re.compile(path), status, body, exception, times))
157
+
158
+ @property
159
+ def prompts(self) -> list[str]:
160
+ """Text of every prompt received, in order."""
161
+ texts: list[str] = []
162
+ for request in self.requests:
163
+ if request.method == "POST" and request.url.path.endswith("/prompt"):
164
+ texts.append(json.loads(request.content)["text"])
165
+ return texts
166
+
167
+ # -- helpers --------------------------------------------------------------------------------------------
168
+
169
+ def _id(self, prefix: str) -> str:
170
+ return f"{prefix}_{next(self._ids):026d}"
171
+
172
+ @staticmethod
173
+ def _now() -> float:
174
+ return float(time.time_ns() // 1_000_000)
175
+
176
+ @property
177
+ def _location(self) -> LocationPublicRef:
178
+ return LocationPublicRef(directory=self.directory)
179
+
180
+ def _session(self, session_id: str) -> SessionInfo:
181
+ try:
182
+ return self.sessions[session_id]
183
+ except KeyError:
184
+ raise _ErrorResponse(
185
+ 404,
186
+ {
187
+ "_tag": "SessionNotFoundError",
188
+ "sessionID": session_id,
189
+ "message": f"Session not found: {session_id}",
190
+ },
191
+ ) from None
192
+
193
+ @staticmethod
194
+ def _body(request: httpx.Request) -> dict[str, Any]:
195
+ if not request.content:
196
+ return {}
197
+ try:
198
+ payload = json.loads(request.content)
199
+ except ValueError:
200
+ raise _invalid("Request body is not JSON") from None
201
+ if not isinstance(payload, dict):
202
+ raise _invalid("Request body must be an object")
203
+ return dict(payload) # pyright: ignore[reportUnknownArgumentType]
204
+
205
+ def _record(self, event: BaseModel) -> None:
206
+ self.events.append(event)
207
+
208
+ def _run(self, session: SessionInfo, text: str) -> None:
209
+ """Answers one prompt synchronously: assistant reply, idle marker, usage, events."""
210
+ reply = self.reply(text) if callable(self.reply) else self.reply
211
+ now = self._now()
212
+ input_tokens, output_tokens = self.tokens
213
+ usage = {"input": input_tokens, "output": output_tokens, "reasoning": 0, "cache": {"read": 0, "write": 0}}
214
+ self.messages[session.id] += [
215
+ SessionMessageAssistant.model_validate(
216
+ {
217
+ "id": self._id("msg"),
218
+ "type": "assistant",
219
+ "time": {"created": now, "completed": now},
220
+ "agent": session.agent or "build",
221
+ "model": (session.model or DEFAULT_MODEL).to_dict(),
222
+ "content": [SessionMessageAssistantText(type="text", text=reply).to_dict()],
223
+ "finish": "stop",
224
+ "cost": self.cost,
225
+ "tokens": usage,
226
+ }
227
+ ),
228
+ SessionMessageIdle.model_validate(
229
+ {"id": self._id("msg"), "type": "idle", "time": {"created": now}, "outcome": "succeeded"}
230
+ ),
231
+ ]
232
+ session.cost += self.cost
233
+ session.tokens = TokenUsageInfo.model_validate(
234
+ {
235
+ "input": session.tokens.input + input_tokens,
236
+ "output": session.tokens.output + output_tokens,
237
+ "reasoning": session.tokens.reasoning,
238
+ "cache": session.tokens.cache.to_dict(),
239
+ }
240
+ )
241
+ session.outcome = "succeeded"
242
+ session.time.updated = session.time.idle = now
243
+ self._record(
244
+ SessionIdleEvent.model_validate(
245
+ {"id": self._id("evt"), "created": now, "type": "session.idle", "data": {"sessionID": session.id}}
246
+ )
247
+ )
248
+
249
+ # -- routes ---------------------------------------------------------------------------------------------
250
+
251
+ def _server_info(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
252
+ info = ServerInfo.model_validate(
253
+ {
254
+ "version": "fake",
255
+ "pid": os.getpid(),
256
+ "urls": [str(request.url.copy_with(path="/"))],
257
+ "paths": {"tmp": "/tmp"},
258
+ }
259
+ )
260
+ return _json(info)
261
+
262
+ def _agent_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
263
+ self._polls["agent"] += 1
264
+ visible = [agent for agent, after in self._agents if self._polls["agent"] > after]
265
+ agents = [
266
+ AgentInfo.model_validate(
267
+ {
268
+ "id": agent,
269
+ "name": agent,
270
+ "mode": "primary",
271
+ "hidden": False,
272
+ "request": {"settings": {}, "headers": {}, "body": {}},
273
+ "permissions": [],
274
+ }
275
+ )
276
+ for agent in dict.fromkeys(visible)
277
+ ]
278
+ return _json(AgentListResponse(location=self._location, data=agents))
279
+
280
+ def _mcp_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
281
+ self._polls["mcp"] += 1
282
+ servers = [
283
+ McpServer.model_validate(
284
+ {"name": name, "status": _mcp_status(status if self._polls["mcp"] > after else "pending")}
285
+ )
286
+ for name, (status, after) in self._mcp.items()
287
+ ]
288
+ return _json(McpListResponse(location=self._location, data=servers))
289
+
290
+ def _session_create(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
291
+ body = self._body(request)
292
+ now = self._now()
293
+ session_id = body.get("id") or self._id("ses")
294
+ if session_id in self.sessions:
295
+ raise _ErrorResponse(409, {"_tag": "ConflictError", "message": f"Session already exists: {session_id}"})
296
+ session = SessionInfo.model_validate(
297
+ {
298
+ "id": session_id,
299
+ "parentID": body.get("parentID"),
300
+ "projectID": "prj_fake",
301
+ "agent": body.get("agent"),
302
+ "model": body.get("model") or DEFAULT_MODEL.to_dict(),
303
+ "cost": 0,
304
+ "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}},
305
+ "time": {"created": now, "updated": now},
306
+ "title": body.get("title"),
307
+ "metadata": body.get("metadata"),
308
+ "location": self._location.to_dict(),
309
+ }
310
+ )
311
+ self.sessions[session.id] = session
312
+ self.messages[session.id] = []
313
+ self.inbox[session.id] = []
314
+ self._record(
315
+ SessionCreatedEvent.model_validate(
316
+ {
317
+ "id": self._id("evt"),
318
+ "created": now,
319
+ "type": "session.created",
320
+ "durable": {"aggregate_id": session.id, "seq": 0, "version": 1},
321
+ "data": {
322
+ "sessionID": session.id,
323
+ "projectID": session.project_id,
324
+ "location": {"directory": self.directory},
325
+ "slug": session.id,
326
+ "title": session.title,
327
+ "agent": session.agent,
328
+ "version": "fake",
329
+ },
330
+ }
331
+ )
332
+ )
333
+ return _json({"data": session.to_dict()})
334
+
335
+ def _session_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
336
+ sessions = list(self.sessions.values())
337
+ return _page(request, [session.to_dict() for session in sessions], newest_first_default=True)
338
+
339
+ def _session_get(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
340
+ return _json({"data": self._session(match["id"]).to_dict()})
341
+
342
+ def _session_remove(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
343
+ self._session(match["id"])
344
+ del self.sessions[match["id"]]
345
+ self.messages.pop(match["id"], None)
346
+ self.inbox.pop(match["id"], None)
347
+ return httpx.Response(204)
348
+
349
+ def _session_agent(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
350
+ session = self._session(match["id"])
351
+ agent = self._body(request).get("agent")
352
+ if not isinstance(agent, str):
353
+ raise _invalid("agent is required", field="agent")
354
+ session.agent = agent
355
+ return httpx.Response(204)
356
+
357
+ def _session_model(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
358
+ session = self._session(match["id"])
359
+ session.model = ModelRef.model_validate(self._body(request).get("model"))
360
+ return httpx.Response(204)
361
+
362
+ def _session_prompt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
363
+ session = self._session(match["id"])
364
+ body = self._body(request)
365
+ text = body.get("text")
366
+ if not isinstance(text, str):
367
+ raise _invalid("text is required", field="text")
368
+ now = self._now()
369
+ message_id = body.get("id") or self._id("msg")
370
+ payload = {key: body[key] for key in ("text", "files", "agents", "skills", "metadata") if key in body}
371
+ queued = SessionInboxUser.model_validate(
372
+ {
373
+ "id": message_id,
374
+ "sessionID": session.id,
375
+ "time": {"created": now},
376
+ "type": "user",
377
+ "payload": payload,
378
+ "delivery": body.get("delivery") or "queue",
379
+ }
380
+ )
381
+ if body.get("resume") is False:
382
+ self.inbox[session.id].append(queued)
383
+ else:
384
+ self.messages[session.id].append(
385
+ SessionMessageUser.model_validate(
386
+ {
387
+ "id": message_id,
388
+ "type": "user",
389
+ "time": {"created": now},
390
+ "text": text,
391
+ "metadata": body.get("metadata"),
392
+ }
393
+ )
394
+ )
395
+ self._run(session, text)
396
+ return _json({"data": queued.to_dict()})
397
+
398
+ def _session_wait(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
399
+ self._session(match["id"])
400
+ return httpx.Response(204)
401
+
402
+ def _session_interrupt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
403
+ self._session(match["id"])
404
+ return _json(SessionInterruptResponse(interrupted=False))
405
+
406
+ def _message_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
407
+ self._session(match["id"])
408
+ wanted = request.url.params.get("type")
409
+ items = [
410
+ m.to_dict() for m in self.messages[match["id"]] if wanted is None or getattr(m, "type", None) == wanted
411
+ ]
412
+ return _page(request, items, newest_first_default=False)
413
+
414
+ def _event_stream(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
415
+ connected = ServerConnectedEvent.model_validate({"id": self._id("evt"), "type": "server.connected", "data": {}})
416
+ chunks = [f"data: {json.dumps(event.to_dict())}\n\n" for event in [connected, *self.events]]
417
+ return httpx.Response(200, headers={"content-type": "text/event-stream"}, content="".join(chunks).encode())
418
+
419
+ _ROUTES: tuple[tuple[str, str, Callable[..., httpx.Response]], ...] = (
420
+ ("GET", r"/api/info", _server_info),
421
+ ("GET", r"/api/agent", _agent_list),
422
+ ("GET", r"/api/mcp", _mcp_list),
423
+ ("GET", r"/api/event", _event_stream),
424
+ ("POST", r"/api/session", _session_create),
425
+ ("GET", r"/api/session", _session_list),
426
+ ("GET", r"/api/session/(?P<id>[^/]+)", _session_get),
427
+ ("DELETE", r"/api/session/(?P<id>[^/]+)", _session_remove),
428
+ ("POST", r"/api/session/(?P<id>[^/]+)/agent", _session_agent),
429
+ ("POST", r"/api/session/(?P<id>[^/]+)/model", _session_model),
430
+ ("POST", r"/api/session/(?P<id>[^/]+)/prompt", _session_prompt),
431
+ ("POST", r"/api/session/(?P<id>[^/]+)/interrupt", _session_interrupt),
432
+ ("GET", r"/api/session/(?P<id>[^/]+)/message", _message_list),
433
+ ("POST", r"/api/experimental/session/(?P<id>[^/]+)/wait", _session_wait),
434
+ )
435
+
436
+ def _handle(self, request: httpx.Request) -> httpx.Response:
437
+ self.requests.append(request)
438
+ path = request.url.path
439
+ for failure in self._failures:
440
+ if failure.method == request.method and failure.path.fullmatch(path) and failure.times != 0:
441
+ if failure.times is not None:
442
+ failure.times -= 1
443
+ if failure.exception is not None:
444
+ raise failure.exception
445
+ return httpx.Response(failure.status, json=failure.body)
446
+ for method, pattern, handler in self._ROUTES:
447
+ match = re.fullmatch(pattern, path)
448
+ if match and method == request.method:
449
+ try:
450
+ return handler(self, request, match)
451
+ except _ErrorResponse as error:
452
+ return httpx.Response(error.status, json=error.body)
453
+ return httpx.Response(
454
+ 404, json={"_tag": "NotFoundError", "message": f"FakeServer does not implement {request.method} {path}"}
455
+ )
456
+
457
+
458
+ class _ErrorResponse(Exception):
459
+ def __init__(self, status: int, body: dict[str, object]) -> None:
460
+ super().__init__(body.get("message"))
461
+ self.status = status
462
+ self.body = body
463
+
464
+
465
+ def _mcp_status(status: str) -> dict[str, object]:
466
+ if status == "failed":
467
+ return {"status": status, "error": "Fake MCP server failed to start"}
468
+ return {"status": status}
469
+
470
+
471
+ def _invalid(message: str, *, field: str | None = None) -> _ErrorResponse:
472
+ return _ErrorResponse(400, {"_tag": "InvalidRequestError", "message": message, "field": field})
473
+
474
+
475
+ def _json(payload: BaseModel | dict[str, Any]) -> httpx.Response:
476
+ return httpx.Response(200, json=payload.to_dict() if isinstance(payload, BaseModel) else payload)
477
+
478
+
479
+ def _page(request: httpx.Request, items: list[dict[str, Any]], *, newest_first_default: bool) -> httpx.Response:
480
+ """Cursor pagination over ``items`` (oldest first). Cursors encode the order and offset, like the server's."""
481
+ params = request.url.params
482
+ cursor = params.get("cursor")
483
+ if cursor is not None:
484
+ if "order" in params:
485
+ return httpx.Response(
486
+ 400, json={"_tag": "InvalidRequestError", "message": "order cannot be combined with cursor"}
487
+ )
488
+ order, _, offset_text = cursor.partition(":")
489
+ if order not in ("asc", "desc") or not offset_text.isdigit():
490
+ return httpx.Response(400, json={"_tag": "InvalidCursorError", "message": f"Invalid cursor: {cursor}"})
491
+ offset = int(offset_text)
492
+ else:
493
+ order = params.get("order") or ("desc" if newest_first_default else "asc")
494
+ offset = 0
495
+ limit = int(params.get("limit") or 50)
496
+ ordered = list(reversed(items)) if order == "desc" else items
497
+ data = ordered[offset : offset + limit]
498
+ end = offset + len(data)
499
+ return httpx.Response(
500
+ 200,
501
+ json={
502
+ "data": data,
503
+ "cursor": {
504
+ "previous": f"{order}:{max(offset - limit, 0)}" if offset > 0 else None,
505
+ "next": f"{order}:{end}" if end < len(ordered) else None,
506
+ },
507
+ },
508
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencode-python-sdk
3
- Version: 0.1.2
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/
@@ -206,6 +206,82 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
206
206
  not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
207
207
  they stay open between events.
208
208
 
209
+ ### Shared event stream
210
+
211
+ `client.events` shares one connection among every subscriber, like `client.event` in the TypeScript client. The
212
+ connection opens with the first subscriber and closes when the last one leaves. Each subscriber has its own queue of
213
+ up to 4096 events; one that falls further behind fails with `EventOverflowError` without affecting the others. The
214
+ latest `server.connected` event is replayed to subscribers that join later.
215
+
216
+ ```python
217
+ from opencode.types import SessionIdleEvent
218
+
219
+ with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
220
+ client.session.prompt(session.id, text="Hello")
221
+ event = idle.get(timeout=300) # typed as SessionIdleEvent
222
+ ```
223
+
224
+ - Filters chain: `of_type(*classes)` narrows the yielded type, `for_session(id)` keeps one session's events, and
225
+ `where(predicate)` takes any test. Entering the `with` block joins the stream, so nothing published after that is
226
+ missed.
227
+ - `subscribe(on_activity=callback)` calls `callback` for every chunk received, keepalive comments included, as a
228
+ liveness signal.
229
+ - `close()` (or leaving the `with` block) ends the subscription, also from another thread. In async code, cancel a wait
230
+ with `anyio.fail_after`, `asyncio.timeout` or a trio cancel scope; the subscription stays usable.
231
+ - Reconnection is opt-in: `EventHub(client, reconnect=True)` (or `AsyncEventHub`, or a `Reconnect(...)` policy) reopens
232
+ the connection with exponential backoff after connection errors, 5xx and 429. Subscribers see `server.connected`
233
+ once. **The stream is volatile**: events published while disconnected are lost, so re-read state through the API
234
+ after a reconnect when it matters.
235
+
236
+ The async hub works the same way under asyncio and trio:
237
+
238
+ ```python
239
+ async with client.events.for_session(session.id) as events:
240
+ async for event in events:
241
+ print(event.type)
242
+ ```
243
+
244
+ `client.event.subscribe()` still opens a dedicated connection.
245
+
246
+ ### Plugin RPC
247
+
248
+ Declare a plugin RPC once as a class, then call it with typed inputs and outputs:
249
+
250
+ ```python
251
+ from pydantic import BaseModel
252
+ from opencode import RpcCallError, RpcDefinition, rpc_event, rpc_method
253
+
254
+
255
+ class AddInput(BaseModel):
256
+ amount: int
257
+
258
+
259
+ class Total(BaseModel):
260
+ total: int
261
+
262
+
263
+ class Counter(RpcDefinition, id="counter"):
264
+ add = rpc_method(AddInput, Total)
265
+ reset = rpc_method(None, Total)
266
+ changed = rpc_event(Total)
267
+
268
+
269
+ counter = client.rpc(Counter)
270
+ try:
271
+ print(counter.call(Counter.add, AddInput(amount=2)).total)
272
+ except RpcCallError as error:
273
+ print(error.type, error.message, error.data)
274
+
275
+ with counter.subscribe(Counter.changed) as changes: # only rpc.counter.changed, with validated data
276
+ for change in changes:
277
+ print(change.data.total)
278
+ ```
279
+
280
+ Any type pydantic validates works for inputs, outputs and event data. `rpc_method(..., errors={"not_found": Model})`
281
+ validates the `data` of declared errors. Failures raise `RpcCallError`; crashes and outputs that do not match the
282
+ declared type raise `RpcCallInternalError`. `counter.events()` yields every raw event of the RPC. The untyped
283
+ `client.rpc.call(rpc_id, method, input=...)` is still available.
284
+
209
285
  ## Terminals
210
286
 
211
287
  `client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
@@ -274,6 +350,55 @@ client.session.get("ses_...", timeout=5.0) # per call
274
350
 
275
351
  The default timeout is 10 minutes with a 10 second connect timeout.
276
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
+
277
402
  ## Running a server
278
403
 
279
404
  `OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password: