zonrad-sdk 0.1.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.
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: zonrad-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Zonrad platform (/platform/v1/) — agents, sessions, runs, tools, and connections.
5
+ Author: Zonrad
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://zonrad.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.28
14
+
15
+ # Zonrad Python SDK — Quickstart
16
+
17
+ The Zonrad SDK talks to the `/platform/v1/` boundary of your Zonrad workspace:
18
+ create agents, run them, stream their output, and inspect what they did —
19
+ all from a Python script, with no Django code in sight.
20
+
21
+ This guide is copy-paste runnable end to end.
22
+
23
+ ## 1. Install
24
+
25
+ ```bash
26
+ pip install zonrad-sdk
27
+ ```
28
+
29
+ The package on PyPI is named `zonrad-sdk` (`zonrad` was already taken), but
30
+ the import is unaffected: `from zonrad import Zonrad`, exactly as below.
31
+
32
+ Working against an unreleased local checkout of this repo instead? Use
33
+ `pip install -e sdk/python` — same import, no PyPI round trip.
34
+
35
+ ## 2. Get an API key
36
+
37
+ Every request needs a `PlatformAPIKey`, scoped to exactly one workspace.
38
+ Mint one with an authenticated session (e.g. your zonrad-ui login) against
39
+ the key-management endpoint:
40
+
41
+ ```bash
42
+ curl -X POST https://<your-zonrad-host>/platform/v1/workspaces/<workspace_id>/api-keys \
43
+ -H "Authorization: Bearer <your JWT access token>" \
44
+ -H "Content-Type: application/json" \
45
+ -d '{"name": "my laptop"}'
46
+ ```
47
+
48
+ The response looks like:
49
+
50
+ ```json
51
+ {
52
+ "id": 1,
53
+ "name": "my laptop",
54
+ "prefix": "zk_live_7792c540",
55
+ "key": "zk_live_7792c540_9dQwhgufdWlqEkq5hZy-fD5IaZffHAHy",
56
+ "workspace_id": 38,
57
+ "last_used_at": null,
58
+ "revoked_at": null,
59
+ "created_at": "2026-09-18T11:29:08.140046Z"
60
+ }
61
+ ```
62
+
63
+ **Save the `key` and `workspace_id` now** — the raw key is shown exactly
64
+ once and can never be retrieved again (only its prefix, for identifying it
65
+ later). If you lose it, mint a new one and revoke the old.
66
+
67
+ ## 3. Your first agent, session, and run
68
+
69
+ ```python
70
+ from zonrad import Zonrad
71
+
72
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
73
+
74
+ agent = z.agents.create(
75
+ name="Release Notes Bot",
76
+ instructions="Summarize engineering updates concisely for a non-technical audience.",
77
+ )
78
+
79
+ session = z.sessions.new(agent_id=agent["id"]) # lazy — no request sent yet
80
+ run = session.run("Say hello in exactly three words.")
81
+
82
+ for event in run.stream():
83
+ if event["event"] == "response.chunk":
84
+ print(event["data"]["content"], end="", flush=True)
85
+ elif event["event"] == "response.completed":
86
+ print() # final newline
87
+
88
+ print("session id (assigned by the server):", session.id)
89
+ print("run id:", run.id)
90
+ ```
91
+
92
+ `workspace_id` is set once, at client construction — every resource call
93
+ uses it automatically, since your API key is already scoped to that one
94
+ workspace.
95
+
96
+ `z.sessions.new()` is lazy: no HTTP request happens until the first
97
+ `.run()`. Use `z.sessions.create(agent_id=...)` instead if you want the
98
+ conversation to exist before you send the first message (e.g. to save its
99
+ `id` before running anything).
100
+
101
+ ## 4. Inspecting a trace
102
+
103
+ Every run is recorded. If something looks wrong, pull the full trace —
104
+ every model call and tool call, in order, with token counts:
105
+
106
+ ```python
107
+ trace = z.runs.get_trace(run.id)
108
+ for span in trace["spans"]:
109
+ print(span["type"], span["name"], span.get("total_tokens"))
110
+ ```
111
+
112
+ ## 5. Running your own Python function as a tool
113
+
114
+ `@z.tools.local(...)` registers a plain Python function that runs **on
115
+ this machine** — the agent calls it mid-run, the SDK executes it locally,
116
+ and the conversation continues automatically. No backend deploy, no new
117
+ tool registration anywhere except this script:
118
+
119
+ ```python
120
+ from zonrad import Zonrad
121
+
122
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
123
+
124
+ @z.tools.local()
125
+ def get_local_file(path: str) -> dict:
126
+ """Reads a file from this machine's filesystem."""
127
+ with open(path) as f:
128
+ return {"content": f.read()}
129
+
130
+ agent = z.agents.create(
131
+ name="File Assistant",
132
+ instructions="Use get_local_file to answer questions about files on the user's machine.",
133
+ )
134
+ session = z.sessions.new(agent_id=agent["id"])
135
+ run = session.run("What's in ~/notes.txt?")
136
+
137
+ for event in run.stream():
138
+ if event["event"] == "response.chunk":
139
+ print(event["data"]["content"], end="", flush=True)
140
+ ```
141
+
142
+ That's the whole thing — `run.stream()` sees the model call
143
+ `get_local_file`, runs your function, sends the result back, and keeps
144
+ streaming. You never handle the pause yourself.
145
+
146
+ The function's parameter types (`str`, `int`, `float`, `bool`, `list`,
147
+ `dict`, `Optional[...]`) are turned into the schema the model sees — same
148
+ rule as the backend's own `register_tool()` below, so a function looks the
149
+ same either way. Every parameter needs a type hint. Give the tool an
150
+ explicit name/description if the function's own name/docstring aren't
151
+ what you want the model to see: `@z.tools.local(name="read_file",
152
+ description="...")`.
153
+
154
+ **What this can't do:** a name that collides with an existing server-side
155
+ tool, or one your workspace has denied via policy, is rejected with a
156
+ clear error the moment you call `.run()` — not silently ignored. And a
157
+ local tool only exists for runs started by *this* process; it's not
158
+ visible to other users or other scripts, and `z.tools.list()` still only
159
+ shows the server-side registry (see below).
160
+
161
+ ## 6. Adding a tool for everyone (backend registration)
162
+
163
+ If a tool should be available to any agent in the workspace, not just from
164
+ your own script, register it in the backend repo instead:
165
+
166
+ ```python
167
+ # backend_django/apps/custom_agents/tools/my_tool.py
168
+ from apps.custom_agents.tool_registry import register_tool
169
+
170
+ @register_tool(
171
+ id="fetch_release_notes",
172
+ name="Fetch Release Notes",
173
+ description="Fetches the latest release notes for a given repo.",
174
+ risk_level="low",
175
+ )
176
+ def fetch_release_notes(repo: str) -> dict:
177
+ ...
178
+ return {"notes": "..."}
179
+ ```
180
+
181
+ This requires a backend deploy and then shows up in `z.tools.list()` for
182
+ everyone — unlike `@z.tools.local(...)`, which is private to the process
183
+ that registered it and needs no deploy at all.
184
+
185
+ ## What's not supported yet
186
+
187
+ - **Non-Python SDKs.** Python only, for now.
188
+ - **Streaming partial output *from* a local tool.** Your function's return
189
+ value is submitted as one complete result — there's no equivalent of
190
+ `response.chunk` for a tool's own execution.
191
+
192
+ ## Reference
193
+
194
+ ```python
195
+ z.agents.list() / .create(**fields) / .get(id) / .update(id, **fields) / .delete(id)
196
+ z.sessions.create(agent_id) / .new(agent_id) / .get(session_id)
197
+ session.run(prompt) -> Run
198
+ run.stream() # generator of {"event": ..., "data": {...}} dicts — auto-handles local tool calls
199
+ run.get() / .get_trace() / .resume(decision, reason="")
200
+ z.runs.get(run_id) / .get_trace(run_id) / .resume(run_id, decision, reason="")
201
+ z.tools.list() / z.toolkits.list() # server-side registry (read-only)
202
+ z.tools.local(name=None, description=None, parameters=None) # decorator — registers a local function
203
+ z.connections.list()
204
+ z.models.list()
205
+ z.policies.get() / .update(**fields)
206
+ z.api_keys.list() / .create(name="") / .revoke(key_id)
207
+ ```
208
+
209
+ A `tool.execution_requested` event your own code never has to handle
210
+ raises `zonrad.ZonradToolNotRegisteredError` internally if no matching
211
+ `@z.tools.local(...)` handler exists — the SDK catches this itself and
212
+ submits it as that call's error, so the run continues rather than your
213
+ script crashing.
214
+
215
+ Errors raise `zonrad.ZonradAPIError` with `.status_code`, `.error_code`
216
+ (when the server sent one, e.g. `"insufficient_credits"`), and `.body`
217
+ (the full parsed response).
@@ -0,0 +1,203 @@
1
+ # Zonrad Python SDK — Quickstart
2
+
3
+ The Zonrad SDK talks to the `/platform/v1/` boundary of your Zonrad workspace:
4
+ create agents, run them, stream their output, and inspect what they did —
5
+ all from a Python script, with no Django code in sight.
6
+
7
+ This guide is copy-paste runnable end to end.
8
+
9
+ ## 1. Install
10
+
11
+ ```bash
12
+ pip install zonrad-sdk
13
+ ```
14
+
15
+ The package on PyPI is named `zonrad-sdk` (`zonrad` was already taken), but
16
+ the import is unaffected: `from zonrad import Zonrad`, exactly as below.
17
+
18
+ Working against an unreleased local checkout of this repo instead? Use
19
+ `pip install -e sdk/python` — same import, no PyPI round trip.
20
+
21
+ ## 2. Get an API key
22
+
23
+ Every request needs a `PlatformAPIKey`, scoped to exactly one workspace.
24
+ Mint one with an authenticated session (e.g. your zonrad-ui login) against
25
+ the key-management endpoint:
26
+
27
+ ```bash
28
+ curl -X POST https://<your-zonrad-host>/platform/v1/workspaces/<workspace_id>/api-keys \
29
+ -H "Authorization: Bearer <your JWT access token>" \
30
+ -H "Content-Type: application/json" \
31
+ -d '{"name": "my laptop"}'
32
+ ```
33
+
34
+ The response looks like:
35
+
36
+ ```json
37
+ {
38
+ "id": 1,
39
+ "name": "my laptop",
40
+ "prefix": "zk_live_7792c540",
41
+ "key": "zk_live_7792c540_9dQwhgufdWlqEkq5hZy-fD5IaZffHAHy",
42
+ "workspace_id": 38,
43
+ "last_used_at": null,
44
+ "revoked_at": null,
45
+ "created_at": "2026-09-18T11:29:08.140046Z"
46
+ }
47
+ ```
48
+
49
+ **Save the `key` and `workspace_id` now** — the raw key is shown exactly
50
+ once and can never be retrieved again (only its prefix, for identifying it
51
+ later). If you lose it, mint a new one and revoke the old.
52
+
53
+ ## 3. Your first agent, session, and run
54
+
55
+ ```python
56
+ from zonrad import Zonrad
57
+
58
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
59
+
60
+ agent = z.agents.create(
61
+ name="Release Notes Bot",
62
+ instructions="Summarize engineering updates concisely for a non-technical audience.",
63
+ )
64
+
65
+ session = z.sessions.new(agent_id=agent["id"]) # lazy — no request sent yet
66
+ run = session.run("Say hello in exactly three words.")
67
+
68
+ for event in run.stream():
69
+ if event["event"] == "response.chunk":
70
+ print(event["data"]["content"], end="", flush=True)
71
+ elif event["event"] == "response.completed":
72
+ print() # final newline
73
+
74
+ print("session id (assigned by the server):", session.id)
75
+ print("run id:", run.id)
76
+ ```
77
+
78
+ `workspace_id` is set once, at client construction — every resource call
79
+ uses it automatically, since your API key is already scoped to that one
80
+ workspace.
81
+
82
+ `z.sessions.new()` is lazy: no HTTP request happens until the first
83
+ `.run()`. Use `z.sessions.create(agent_id=...)` instead if you want the
84
+ conversation to exist before you send the first message (e.g. to save its
85
+ `id` before running anything).
86
+
87
+ ## 4. Inspecting a trace
88
+
89
+ Every run is recorded. If something looks wrong, pull the full trace —
90
+ every model call and tool call, in order, with token counts:
91
+
92
+ ```python
93
+ trace = z.runs.get_trace(run.id)
94
+ for span in trace["spans"]:
95
+ print(span["type"], span["name"], span.get("total_tokens"))
96
+ ```
97
+
98
+ ## 5. Running your own Python function as a tool
99
+
100
+ `@z.tools.local(...)` registers a plain Python function that runs **on
101
+ this machine** — the agent calls it mid-run, the SDK executes it locally,
102
+ and the conversation continues automatically. No backend deploy, no new
103
+ tool registration anywhere except this script:
104
+
105
+ ```python
106
+ from zonrad import Zonrad
107
+
108
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
109
+
110
+ @z.tools.local()
111
+ def get_local_file(path: str) -> dict:
112
+ """Reads a file from this machine's filesystem."""
113
+ with open(path) as f:
114
+ return {"content": f.read()}
115
+
116
+ agent = z.agents.create(
117
+ name="File Assistant",
118
+ instructions="Use get_local_file to answer questions about files on the user's machine.",
119
+ )
120
+ session = z.sessions.new(agent_id=agent["id"])
121
+ run = session.run("What's in ~/notes.txt?")
122
+
123
+ for event in run.stream():
124
+ if event["event"] == "response.chunk":
125
+ print(event["data"]["content"], end="", flush=True)
126
+ ```
127
+
128
+ That's the whole thing — `run.stream()` sees the model call
129
+ `get_local_file`, runs your function, sends the result back, and keeps
130
+ streaming. You never handle the pause yourself.
131
+
132
+ The function's parameter types (`str`, `int`, `float`, `bool`, `list`,
133
+ `dict`, `Optional[...]`) are turned into the schema the model sees — same
134
+ rule as the backend's own `register_tool()` below, so a function looks the
135
+ same either way. Every parameter needs a type hint. Give the tool an
136
+ explicit name/description if the function's own name/docstring aren't
137
+ what you want the model to see: `@z.tools.local(name="read_file",
138
+ description="...")`.
139
+
140
+ **What this can't do:** a name that collides with an existing server-side
141
+ tool, or one your workspace has denied via policy, is rejected with a
142
+ clear error the moment you call `.run()` — not silently ignored. And a
143
+ local tool only exists for runs started by *this* process; it's not
144
+ visible to other users or other scripts, and `z.tools.list()` still only
145
+ shows the server-side registry (see below).
146
+
147
+ ## 6. Adding a tool for everyone (backend registration)
148
+
149
+ If a tool should be available to any agent in the workspace, not just from
150
+ your own script, register it in the backend repo instead:
151
+
152
+ ```python
153
+ # backend_django/apps/custom_agents/tools/my_tool.py
154
+ from apps.custom_agents.tool_registry import register_tool
155
+
156
+ @register_tool(
157
+ id="fetch_release_notes",
158
+ name="Fetch Release Notes",
159
+ description="Fetches the latest release notes for a given repo.",
160
+ risk_level="low",
161
+ )
162
+ def fetch_release_notes(repo: str) -> dict:
163
+ ...
164
+ return {"notes": "..."}
165
+ ```
166
+
167
+ This requires a backend deploy and then shows up in `z.tools.list()` for
168
+ everyone — unlike `@z.tools.local(...)`, which is private to the process
169
+ that registered it and needs no deploy at all.
170
+
171
+ ## What's not supported yet
172
+
173
+ - **Non-Python SDKs.** Python only, for now.
174
+ - **Streaming partial output *from* a local tool.** Your function's return
175
+ value is submitted as one complete result — there's no equivalent of
176
+ `response.chunk` for a tool's own execution.
177
+
178
+ ## Reference
179
+
180
+ ```python
181
+ z.agents.list() / .create(**fields) / .get(id) / .update(id, **fields) / .delete(id)
182
+ z.sessions.create(agent_id) / .new(agent_id) / .get(session_id)
183
+ session.run(prompt) -> Run
184
+ run.stream() # generator of {"event": ..., "data": {...}} dicts — auto-handles local tool calls
185
+ run.get() / .get_trace() / .resume(decision, reason="")
186
+ z.runs.get(run_id) / .get_trace(run_id) / .resume(run_id, decision, reason="")
187
+ z.tools.list() / z.toolkits.list() # server-side registry (read-only)
188
+ z.tools.local(name=None, description=None, parameters=None) # decorator — registers a local function
189
+ z.connections.list()
190
+ z.models.list()
191
+ z.policies.get() / .update(**fields)
192
+ z.api_keys.list() / .create(name="") / .revoke(key_id)
193
+ ```
194
+
195
+ A `tool.execution_requested` event your own code never has to handle
196
+ raises `zonrad.ZonradToolNotRegisteredError` internally if no matching
197
+ `@z.tools.local(...)` handler exists — the SDK catches this itself and
198
+ submits it as that call's error, so the run continues rather than your
199
+ script crashing.
200
+
201
+ Errors raise `zonrad.ZonradAPIError` with `.status_code`, `.error_code`
202
+ (when the server sent one, e.g. `"insufficient_credits"`), and `.body`
203
+ (the full parsed response).
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zonrad-sdk"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Zonrad platform (/platform/v1/) — agents, sessions, runs, tools, and connections."
9
+ readme = "QUICKSTART.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Zonrad" }]
13
+ dependencies = [
14
+ "requests>=2.28",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://zonrad.ai"
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["zonrad*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,239 @@
1
+ import json
2
+ from typing import Optional
3
+
4
+ import pytest
5
+
6
+ from zonrad._schema import ToolSchemaError, build_schema_from_function
7
+ from zonrad.exceptions import ZonradToolNotRegisteredError
8
+ from zonrad.resources.runs import Run
9
+ from zonrad.resources.tools import ToolsResource
10
+
11
+
12
+ class _FakeClient:
13
+ workspace_id = 1
14
+
15
+ def __init__(self):
16
+ self.tools = ToolsResource(self)
17
+ self.submitted = None
18
+ self._next_continuation_lines = None
19
+
20
+ class _HTTP:
21
+ def __init__(self, outer):
22
+ self.outer = outer
23
+
24
+ def stream(self, method, path, json):
25
+ self.outer.submitted = {"method": method, "path": path, "json": json}
26
+ return _FakeStreamResponse(self.outer._next_continuation_lines or [])
27
+
28
+ def __getattr__(self, item):
29
+ if item == "_http":
30
+ return self._HTTP(self)
31
+ raise AttributeError(item)
32
+
33
+
34
+ class _FakeStreamResponse:
35
+ def __init__(self, lines):
36
+ self._lines = lines
37
+
38
+ def iter_lines(self, decode_unicode=True):
39
+ yield from self._lines
40
+
41
+
42
+ def _sse_line(event, data):
43
+ return f"data: {json.dumps({'event': event, 'data': data})}"
44
+
45
+
46
+ class TestBuildSchemaFromFunction:
47
+ def test_primitive_types(self):
48
+ def fn(a: str, b: int, c: float, d: bool):
49
+ ...
50
+ schema = build_schema_from_function(fn)
51
+ assert schema["properties"]["a"]["type"] == "string"
52
+ assert schema["properties"]["b"]["type"] == "integer"
53
+ assert schema["properties"]["c"]["type"] == "number"
54
+ assert schema["properties"]["d"]["type"] == "boolean"
55
+ assert set(schema["required"]) == {"a", "b", "c", "d"}
56
+
57
+ def test_list_and_dict(self):
58
+ def fn(items: list, meta: dict):
59
+ ...
60
+ schema = build_schema_from_function(fn)
61
+ assert schema["properties"]["items"]["type"] == "array"
62
+ assert schema["properties"]["meta"]["type"] == "object"
63
+
64
+ def test_optional_excludes_from_required(self):
65
+ def fn(name: str, nickname: Optional[str] = None):
66
+ ...
67
+ schema = build_schema_from_function(fn)
68
+ assert "nickname" not in schema["required"]
69
+ assert "name" in schema["required"]
70
+
71
+ def test_missing_type_hint_raises(self):
72
+ def fn(a):
73
+ ...
74
+ with pytest.raises(ToolSchemaError):
75
+ build_schema_from_function(fn)
76
+
77
+
78
+ class TestToolsResourceLocal:
79
+ def test_local_registers_and_lists_specs(self):
80
+ client = _FakeClient()
81
+
82
+ @client.tools.local()
83
+ def get_weather(city: str) -> dict:
84
+ """Gets the weather."""
85
+ return {"city": city, "temp": 72}
86
+
87
+ specs = client.tools._local_specs()
88
+ assert len(specs) == 1
89
+ assert specs[0]["name"] == "get_weather"
90
+ assert specs[0]["description"] == "Gets the weather."
91
+ assert specs[0]["parameters"]["properties"]["city"]["type"] == "string"
92
+
93
+ def test_execute_local_happy_path_dict_arguments(self):
94
+ client = _FakeClient()
95
+
96
+ @client.tools.local(name="add")
97
+ def add(a: int, b: int) -> int:
98
+ return a + b
99
+
100
+ assert client.tools._execute_local("add", {"a": 2, "b": 3}) == 5
101
+
102
+ def test_execute_local_defensively_parses_json_string_arguments(self):
103
+ client = _FakeClient()
104
+
105
+ @client.tools.local(name="add")
106
+ def add(a: int, b: int) -> int:
107
+ return a + b
108
+
109
+ assert client.tools._execute_local("add", json.dumps({"a": 2, "b": 3})) == 5
110
+
111
+ def test_execute_local_falls_back_to_empty_dict_for_garbage_input(self):
112
+ client = _FakeClient()
113
+
114
+ @client.tools.local(name="noop")
115
+ def noop() -> str:
116
+ return "ok"
117
+
118
+ assert client.tools._execute_local("noop", "not json") == "ok"
119
+ assert client.tools._execute_local("noop", None) == "ok"
120
+
121
+ def test_execute_local_raises_for_unregistered_tool(self):
122
+ client = _FakeClient()
123
+ with pytest.raises(ZonradToolNotRegisteredError):
124
+ client.tools._execute_local("nonexistent", {})
125
+
126
+
127
+ class TestRunStreamAutoLoop:
128
+ def _run_with_events(self, lines, client=None):
129
+ client = client or _FakeClient()
130
+ resp = _FakeStreamResponse(lines)
131
+ return Run._from_stream(client, resp), client
132
+
133
+ def test_tool_execution_requested_is_handled_transparently(self):
134
+ client = _FakeClient()
135
+
136
+ @client.tools.local(name="get_local_file")
137
+ def get_local_file(path: str) -> dict:
138
+ return {"content": f"contents of {path}"}
139
+
140
+ first_stream_lines = [
141
+ _sse_line("agent.status", {"agent_run_id": "run_1", "status": "thinking"}),
142
+ "",
143
+ _sse_line("tool.execution_requested", {
144
+ "agent_run_id": "run_1", "tool": "get_local_file",
145
+ "tool_call_id": "call_1", "input": {"path": "/tmp/x"}, "batch_size": 1,
146
+ }),
147
+ "",
148
+ ]
149
+ continuation_lines = [
150
+ _sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}),
151
+ "",
152
+ ]
153
+ client._next_continuation_lines = continuation_lines
154
+
155
+ run, _ = self._run_with_events(first_stream_lines, client=client)
156
+ events = list(run.stream())
157
+
158
+ assert [e["event"] for e in events] == ["agent.status", "response.completed"]
159
+ # Never surfaced tool.execution_requested to the caller.
160
+ assert not any(e["event"] == "tool.execution_requested" for e in events)
161
+ # Submitted exactly the local function's real return value.
162
+ assert client.submitted["json"]["results"] == [
163
+ {"tool_call_id": "call_1", "output": {"content": "contents of /tmp/x"}, "error": None},
164
+ ]
165
+ assert client.submitted["path"].endswith("/runs/run_1/submit-tool-results")
166
+
167
+ def test_batch_split_across_buffered_and_live_events_is_unified(self):
168
+ """Regression: the first draft ran _drain(buffered) then separately
169
+ _drain(events_iter), which would try to read the dead stream a
170
+ second time once a batch executed mid-buffer. itertools.chain fixes
171
+ this — this test exercises a batch_size=2 split across both sources."""
172
+ client = _FakeClient()
173
+
174
+ @client.tools.local(name="tool_a")
175
+ def tool_a() -> str:
176
+ return "a"
177
+
178
+ @client.tools.local(name="tool_b")
179
+ def tool_b() -> str:
180
+ return "b"
181
+
182
+ # Only ONE of the two batch events is on the initial (buffered) stream;
183
+ # Run._from_stream's own buffering (Phase 5) stops once agent_run_id
184
+ # resolves, so the second call ends up in _events_iter, not buffered.
185
+ lines = [
186
+ _sse_line("tool.execution_requested", {
187
+ "agent_run_id": "run_1", "tool": "tool_a", "tool_call_id": "call_a", "input": {}, "batch_size": 2,
188
+ }),
189
+ "",
190
+ _sse_line("tool.execution_requested", {
191
+ "agent_run_id": "run_1", "tool": "tool_b", "tool_call_id": "call_b", "input": {}, "batch_size": 2,
192
+ }),
193
+ "",
194
+ ]
195
+ client._next_continuation_lines = [
196
+ _sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}), "",
197
+ ]
198
+
199
+ run, _ = self._run_with_events(lines, client=client)
200
+ # Simulate the split: force exactly one event into "buffered" and
201
+ # leave the rest for the live iterator, matching what _from_stream's
202
+ # early-break-on-agent_run_id actually produces in practice.
203
+ assert len(run._buffered_events) == 1
204
+ events = list(run.stream())
205
+
206
+ assert [e["event"] for e in events] == ["response.completed"]
207
+ submitted_ids = {r["tool_call_id"] for r in client.submitted["json"]["results"]}
208
+ assert submitted_ids == {"call_a", "call_b"}
209
+
210
+ def test_unregistered_client_tool_submits_a_clear_error_instead_of_raising(self):
211
+ client = _FakeClient() # no local tools registered at all
212
+ lines = [
213
+ _sse_line("tool.execution_requested", {
214
+ "agent_run_id": "run_1", "tool": "mystery_tool", "tool_call_id": "call_1", "input": {}, "batch_size": 1,
215
+ }),
216
+ "",
217
+ ]
218
+ client._next_continuation_lines = [
219
+ _sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}), "",
220
+ ]
221
+
222
+ run, _ = self._run_with_events(lines, client=client)
223
+ events = list(run.stream()) # must not raise
224
+
225
+ assert [e["event"] for e in events] == ["response.completed"]
226
+ result = client.submitted["json"]["results"][0]
227
+ assert result["output"] is None
228
+ assert "mystery_tool" in result["error"]
229
+
230
+ def test_approval_required_is_not_auto_handled(self):
231
+ client = _FakeClient()
232
+ lines = [
233
+ _sse_line("approval.required", {"agent_run_id": "run_1", "tool": "send_slack", "tool_call_id": "call_1"}),
234
+ "",
235
+ ]
236
+ run, _ = self._run_with_events(lines, client=client)
237
+ events = list(run.stream())
238
+ assert [e["event"] for e in events] == ["approval.required"]
239
+ assert client.submitted is None # nothing auto-submitted