opencode-python-sdk 0.1.3__py3-none-any.whl → 0.2.2__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 (47) hide show
  1. opencode/__init__.py +19 -1
  2. opencode/_base_client.py +24 -3
  3. opencode/_client.py +173 -2
  4. opencode/_events.py +5 -2
  5. opencode/_rpc.py +9 -2
  6. opencode/_service.py +1018 -0
  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/service.py +50 -0
  42. opencode/testing.py +508 -0
  43. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/METADATA +71 -1
  44. opencode_python_sdk-0.2.2.dist-info/RECORD +64 -0
  45. opencode_python_sdk-0.1.3.dist-info/RECORD +0 -58
  46. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/WHEEL +0 -0
  47. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.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.3
3
+ Version: 0.2.2
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:
@@ -370,6 +419,27 @@ with server:
370
419
  The server runs in `--stdio` mode, so it also exits when the Python process dies. `AsyncOpencodeServer` is the
371
420
  async equivalent.
372
421
 
422
+ ### The background service
423
+
424
+ OpenCode can also run one shared background server per user (`opencode serve --service`), which registers itself in
425
+ `$XDG_STATE_HOME/opencode/service.json`. `Opencode.from_service()` connects to it, starting it first if it is not
426
+ running:
427
+
428
+ ```python
429
+ from opencode import Opencode, service
430
+
431
+ with Opencode.from_service(version="2.1.2") as client: # replaces a service running another version
432
+ print(client.server.info().version)
433
+
434
+ endpoint = service.discover() # an Endpoint, or None; never starts anything
435
+ service.stop(pty="handoff") # hand persistent terminals to the next service instead of closing them
436
+ ```
437
+
438
+ `service.ensure()` returns the `Endpoint` (URL and credentials) without creating a client, and `service.headers()`
439
+ gives its `Authorization` header for other HTTP clients. Concurrent callers are safe: they race to start the service
440
+ and all get the one that won. `await AsyncOpencode.from_service()`, `service.async_discover()`,
441
+ `service.async_ensure()` and `service.async_stop()` are the async equivalents.
442
+
373
443
  ## Advanced
374
444
 
375
445
  ```python
@@ -0,0 +1,64 @@
1
+ opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
2
+ opencode/__init__.py,sha256=ZknWy2NBxHe4C6prS9gW56FBu2UoV-OLcMsMY4hnEKI,5759
3
+ opencode/_base_client.py,sha256=oA7Yah1WYyw16WrF_x76T1uZE5Arwt4jQMK8gyrL_4Y,21008
4
+ opencode/_client.py,sha256=nUkvwHuKRpISQgy4-d9c62nq9zMtzENsuqbQE4KKwAw,15402
5
+ opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
6
+ opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
7
+ opencode/_events.py,sha256=Pg6zgPT37GHRITM5HvuxC6hmdZVrd2msyfsFQPoOAcQ,30179
8
+ opencode/_exceptions.py,sha256=kqQiaSM6zbfyiXXddwapIymW7J61ZlAa767Yko5Y9EI,8720
9
+ opencode/_models.py,sha256=2esvD2CmslOHCbuLNkN0sGDcgMAqeoMmB7hZMPzo1uA,1988
10
+ opencode/_pagination.py,sha256=uvB-0IwfkGqbYIYiUW_ax_oAE-ldZJSWHtjluVcZlNk,7052
11
+ opencode/_pty.py,sha256=wdqBF7K40ikzas8i203pem2iov8nbgYDC-RqH6ZX41E,23429
12
+ opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
13
+ opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
14
+ opencode/_rpc.py,sha256=WrPQ_hRhsneXLvYj7WwEADC6_OCMRE5muuWVoysKPok,17708
15
+ opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
16
+ opencode/_service.py,sha256=LtkGdZBbRH6I7F6PYnzMNNOqXudze_Uy3HO_ghnCPf4,36622
17
+ opencode/_streaming.py,sha256=TfnfZaNucjLrpfOB9m1i_sRtjYoACztTflavrhwvnDE,8861
18
+ opencode/_types.py,sha256=Z8bYxzpZhPRtisvPUv9R5g4oBlC23iXR70fFzReTOc0,2194
19
+ opencode/_unions.py,sha256=dW0dTmP2QK2lL6RhyK2YsjD2yNbO8Ah6pk-i5ySW3lo,1518
20
+ opencode/_version.py,sha256=zNErHu8z_g6ScOeYcO8QAiktg036FVPaUiIIZefeoLU,259
21
+ opencode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ opencode/service.py,sha256=kFjQ4mjV30-VXx5B22ZPUs0V0e__fyTZ2Yj2VAoUrbU,921
23
+ opencode/testing.py,sha256=_EGoh9SwC5WCwbXjm5AtbyAzB4zyO5xX2N-skMLHzR4,21069
24
+ opencode/lib/__init__.py,sha256=mWBVu6RUOqMNJHHZPLq2nkks7QdsWJgLPN44BmfDv9s,120
25
+ opencode/lib/_ready.py,sha256=dhX-n6DDjRWsYJ95paxt1i5WnlU_KsFsTqgkJEt01Q4,3286
26
+ opencode/lib/_session.py,sha256=SCmBOYWdDyoCGKJ5YF2eQPxu8R0TmVhcV4C_xl1jFKA,9212
27
+ opencode/resources/__init__.py,sha256=wNPQqEDgNYedtWhub2wVWL85jEoE2qCQQHks5WHyAoM,13239
28
+ opencode/resources/agent.py,sha256=6vQC__e3cZxkJG-Xeqj66wh4kH1zOpGS9662N2dSa3s,5624
29
+ opencode/resources/command.py,sha256=60itt-z1mhUlJEHY7aUy6kUerIdostbhZf7y0I6EK74,3073
30
+ opencode/resources/config.py,sha256=L6FbrXeegplUsTjHQZX1KsAaGA0Yy0_w8MP8V5dRo7Q,7699
31
+ opencode/resources/credential.py,sha256=Z464vfgOSXMB-pi4LCCnUR4mWRtmFpefqlZ9rWQP_Fk,12084
32
+ opencode/resources/debug.py,sha256=sZvMb7WsiB65O-B3-U4DablrUWctOOvd6BWY_t6SW3w,5537
33
+ opencode/resources/event.py,sha256=zsaY6bmBt5zYCxxghJzWCyG342_2uAAP1xsVgyTaYwc,2763
34
+ opencode/resources/experimental.py,sha256=zfIBB_4TtVdFqBgksk5jmQ-9a_5hpPhPRT1f0vlPQoY,28172
35
+ opencode/resources/file.py,sha256=3J06QXIQT5_WLdPse3OLQqMN8ypCR6VsadndAQnHQEg,13013
36
+ opencode/resources/form.py,sha256=Lc4Ko_01ABZZTIQi8UOfmSO_xfhWP8UoAr5wBUNdAqY,3021
37
+ opencode/resources/generate.py,sha256=RFjg4dBXDhzhxGz5bO50igZZ5-W7bT6PIEw_ED45Su0,3809
38
+ opencode/resources/integration.py,sha256=q59coZDkdVcRk53q3wwffrySYAsPT_GMIjbirT7aAXY,37919
39
+ opencode/resources/location.py,sha256=Idd0K174jkzJvpEqoucSII1sP8ibuHZIfsz6X5id8_U,5357
40
+ opencode/resources/mcp.py,sha256=aC6jLoS18Ez_4nMRsUmxbMGSR2jGFMAPurRuoPKFL10,16401
41
+ opencode/resources/message.py,sha256=D5IF_mAD2GnFH8_Ggy7tEXECsm6Jx6dt3CO0haGPfic,5971
42
+ opencode/resources/migration.py,sha256=aqwIG6vcFZ1zzKwRR731ZOpLJeUMG7E9f1dIc8YOR4o,4252
43
+ opencode/resources/model.py,sha256=1SXCA7r6rukxUC6iar9I33Zs01gNGl92-J3p1LfRo38,5875
44
+ opencode/resources/permission.py,sha256=KjcppKC5VwYFB8RvrOO8-tAcERyQtMd-IvNQn-lOigY,24528
45
+ opencode/resources/plugin.py,sha256=_wa0UfT-_TGgE1lo3nkODXjyb26EXXB_WHgX8DTcGVQ,8221
46
+ opencode/resources/project.py,sha256=HfMDFqKbpzPqmmKg4WAxkM7_J7RfWr1QiWBKuZg-CPU,6184
47
+ opencode/resources/provider.py,sha256=f0z-nLqtGzvHSGIHj7LR7eVzLTT60D6Jw2aR7IkMtWs,6200
48
+ opencode/resources/pty.py,sha256=dReNx2ETWMhJHmZ53x4JhkkjXRWxVP_kfa5eQYlo3EU,17631
49
+ opencode/resources/reference.py,sha256=ovKtRm4vCMD0ulWJncXZIkuCQacBDQYKwOk5QOOHjMg,3143
50
+ opencode/resources/rpc.py,sha256=aBTeAvDyBK5NQeO-Yt9mZrqiOB4u4_8dQD082t4pO9s,3684
51
+ opencode/resources/server.py,sha256=Vr-uTexG20gtt53c23ny-iGy_BeXYCKJWAnu0P6Uauc,6998
52
+ opencode/resources/session.py,sha256=xQXN9_Af3OIlqEDQZPSCiByZkyn3ZXut9GD041Yk0ig,126883
53
+ opencode/resources/shell.py,sha256=-VzSkaVOaVM1GZdjM2rz9mAWffg9fnxy9KCseYzti-I,13911
54
+ opencode/resources/skill.py,sha256=R44XZaWeSyVZNbHHVUnLsqL81Mlg1cvqMkTYqHSRv_c,3025
55
+ opencode/resources/vcs.py,sha256=vZzdowfs4CbCMDDQ_uF48d5P4ym8qw4DZ3Q-_sjbDw8,17811
56
+ opencode/resources/voice.py,sha256=cM3eEBopD-DSfwPdiuwSzapeGZyvhroCA97WRrLXXT0,3988
57
+ opencode/resources/websearch.py,sha256=hqXcTPq-lGnqB34VMcFL59KbS2vvfwd-JntgUozKAw4,6271
58
+ opencode/resources/worktree.py,sha256=rRDysFG4zncjlIgjkwxT2UKkUgLXMr2DxeEfg5j2YrE,11016
59
+ opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
60
+ opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
61
+ opencode_python_sdk-0.2.2.dist-info/METADATA,sha256=d8EuoMpm4YOLAqDLpupbnPDrHbOJEZXln6pLYfELqrs,19023
62
+ opencode_python_sdk-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
63
+ opencode_python_sdk-0.2.2.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
64
+ opencode_python_sdk-0.2.2.dist-info/RECORD,,