statewire 0.4.3__tar.gz → 0.4.4__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.
- {statewire-0.4.3 → statewire-0.4.4}/PKG-INFO +1 -1
- {statewire-0.4.3 → statewire-0.4.4}/pyproject.toml +1 -1
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/assistant_transport.py +21 -13
- statewire-0.4.4/src/statewire/testing.py +61 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport.py +5 -5
- statewire-0.4.4/tests/test_testing.py +64 -0
- {statewire-0.4.3 → statewire-0.4.4}/.gitignore +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/README.md +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/examples/__init__.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/examples/demo_app.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/__init__.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/api.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/client.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/langgraph.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/legacy.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/ops.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/src/statewire/state.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/client_helpers.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/statewire_helpers.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_authorize.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_client.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_client_ws.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_commands.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_context.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_idle.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_langgraph.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_legacy.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_lifespan.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_meta.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_state_proxy.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_stream.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_writer_lease.py +0 -0
- {statewire-0.4.3 → statewire-0.4.4}/tests/test_ws.py +0 -0
|
@@ -29,7 +29,8 @@ the command whole):
|
|
|
29
29
|
- ``add-tool-result`` becomes ``run/input`` with ``{"requestId": <resolved>,
|
|
30
30
|
"response": {"output": <result>, "isError"?, "artifact"?}}``; the requestId
|
|
31
31
|
comes from ``get_input_request_id(toolCallId)``, whose default peeks the
|
|
32
|
-
unanswered tool-call request in ``state["inputRequests"]``
|
|
32
|
+
unanswered tool-call request in ``state["runs"][0]["inputRequests"]``
|
|
33
|
+
(None rejects),
|
|
33
34
|
and a ``modelContent`` field rejects (unsupported in ``run/input``).
|
|
34
35
|
- A body with a ``parentId`` and no commands is the legacy reload: it becomes
|
|
35
36
|
``run/reload`` with ``{"sourceId": <child>}`` via ``get_message_child_id``
|
|
@@ -39,7 +40,8 @@ A host without the target ``run/*`` handler rejects the batch via the
|
|
|
39
40
|
ordinary unknown-command path. The response streams state changes as legacy
|
|
40
41
|
frames until the instance is idle — every submitted command settled and no
|
|
41
42
|
live instance task (``create_task`` minus ``unref``) — then EOF. A run error
|
|
42
|
-
recorded into state (``get_run_error``; default
|
|
43
|
+
recorded into state (``get_run_error``; default
|
|
44
|
+
``state["runs"][0]["error"]``) becomes
|
|
43
45
|
an error frame before EOF, its fields beyond ``message`` carried as the
|
|
44
46
|
frame's structured payload where the format allows; a rejection or crash
|
|
45
47
|
becomes an error frame followed by EOF. Client disconnect before EOF
|
|
@@ -189,6 +191,11 @@ def _translate_op(replica: Any, op: dict[str, Any]) -> tuple[Any, str | None]:
|
|
|
189
191
|
_AT_CONSUMED_FIELDS = {"commands", "threadId", "state"}
|
|
190
192
|
|
|
191
193
|
|
|
194
|
+
def _run_entry(state: Any) -> Any:
|
|
195
|
+
runs = state.get("runs") if isinstance(state, dict) else None
|
|
196
|
+
return runs[0] if isinstance(runs, list) and runs else None
|
|
197
|
+
|
|
198
|
+
|
|
192
199
|
class _RunError(Exception):
|
|
193
200
|
def __init__(self, message: str, *, payload: Any = None) -> None:
|
|
194
201
|
super().__init__(message)
|
|
@@ -228,12 +235,12 @@ class AssistantTransport(Statewire):
|
|
|
228
235
|
"""The run error to surface as the end-of-stream error frame.
|
|
229
236
|
|
|
230
237
|
The default peeks the RunManager root-state mount: a non-null
|
|
231
|
-
``state["error"]`` object with a string ``message`` is the
|
|
232
|
-
run error; its fields beyond ``message`` travel as the
|
|
233
|
-
structured payload where the format carries one. None ends
|
|
234
|
-
stream cleanly."""
|
|
235
|
-
|
|
236
|
-
error =
|
|
238
|
+
``state["runs"][0]["error"]`` object with a string ``message`` is the
|
|
239
|
+
recorded run error; its fields beyond ``message`` travel as the
|
|
240
|
+
frame's structured payload where the format carries one. None ends
|
|
241
|
+
the stream cleanly."""
|
|
242
|
+
entry = _run_entry(plain(self.state))
|
|
243
|
+
error = entry.get("error") if isinstance(entry, dict) else None
|
|
237
244
|
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
238
245
|
return error
|
|
239
246
|
return None
|
|
@@ -242,11 +249,12 @@ class AssistantTransport(Statewire):
|
|
|
242
249
|
"""Resolve a legacy toolCallId to the pending run/input requestId.
|
|
243
250
|
|
|
244
251
|
The default peeks the RunManager root-state mount:
|
|
245
|
-
``state["inputRequests"]`` holds ``{..., "response":
|
|
246
|
-
the unanswered tool-call request matching
|
|
247
|
-
rejects the legacy ``add-tool-result``
|
|
248
|
-
|
|
249
|
-
|
|
252
|
+
``state["runs"][0]["inputRequests"]`` holds ``{..., "response":
|
|
253
|
+
null}`` entries; the unanswered tool-call request matching
|
|
254
|
+
``tool_call_id`` wins. None rejects the legacy ``add-tool-result``
|
|
255
|
+
command."""
|
|
256
|
+
entry = _run_entry(plain(self.state))
|
|
257
|
+
requests = entry.get("inputRequests") if isinstance(entry, dict) else None
|
|
250
258
|
if not isinstance(requests, list):
|
|
251
259
|
return None
|
|
252
260
|
for request in requests:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Test helpers: real command contexts for driving @command handlers directly."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from pinned.host import invalid_id_reason
|
|
7
|
+
|
|
8
|
+
from .api import (
|
|
9
|
+
_LAGGARD,
|
|
10
|
+
_UNSET,
|
|
11
|
+
Statewire,
|
|
12
|
+
StatewireClientHandle,
|
|
13
|
+
StatewireCommandContext,
|
|
14
|
+
_Invocation,
|
|
15
|
+
)
|
|
16
|
+
from .state import _frozen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def command_context(
|
|
20
|
+
sw: Statewire,
|
|
21
|
+
*,
|
|
22
|
+
client_id: str = "test",
|
|
23
|
+
identity: Any = None,
|
|
24
|
+
context: dict[str, Any] | None = None,
|
|
25
|
+
seq: int = 1,
|
|
26
|
+
) -> tuple[StatewireCommandContext, Callable[[], list[dict[str, Any]]]]:
|
|
27
|
+
"""A real ``StatewireCommandContext`` for calling a ``@command`` handler
|
|
28
|
+
directly, plus ``effects()``: each call flushes the host and returns every
|
|
29
|
+
envelope delivered to the client so far, decoded (attach snapshot excluded)."""
|
|
30
|
+
if not isinstance(sw, Statewire):
|
|
31
|
+
raise TypeError("command_context() requires a Statewire instance")
|
|
32
|
+
if sw._state is _UNSET:
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
"state is not set; assign sw.state (or enter lifespan) first"
|
|
35
|
+
)
|
|
36
|
+
reason = invalid_id_reason(client_id)
|
|
37
|
+
if reason is not None:
|
|
38
|
+
raise ValueError(f"invalid client_id: {reason}")
|
|
39
|
+
if seq < 1:
|
|
40
|
+
raise ValueError("seq must be >= 1")
|
|
41
|
+
if context is not None and not isinstance(context, dict):
|
|
42
|
+
raise TypeError("context must be a dict")
|
|
43
|
+
q, _snapshot = sw._register(client_id, identity)
|
|
44
|
+
client = sw._clients[client_id]
|
|
45
|
+
client.context = _frozen(context) if context is not None else None
|
|
46
|
+
inv = _Invocation(client, seq)
|
|
47
|
+
client.inflight[seq] = inv
|
|
48
|
+
ctx = StatewireCommandContext(StatewireClientHandle(sw, client), inv)
|
|
49
|
+
captured: list[dict[str, Any]] = []
|
|
50
|
+
|
|
51
|
+
def effects() -> list[dict[str, Any]]:
|
|
52
|
+
sw.flush()
|
|
53
|
+
while not q.empty():
|
|
54
|
+
item = q.get_nowait()
|
|
55
|
+
if item is _LAGGARD:
|
|
56
|
+
raise RuntimeError("test client fell behind its stream queue")
|
|
57
|
+
data, _finish = item
|
|
58
|
+
captured.append(json.loads(data))
|
|
59
|
+
return captured
|
|
60
|
+
|
|
61
|
+
return ctx, effects
|
|
@@ -588,7 +588,7 @@ class BackgroundHarness(AssistantTransport):
|
|
|
588
588
|
self.fail_with = None
|
|
589
589
|
|
|
590
590
|
async def lifespan(self):
|
|
591
|
-
self.state = {"text": "", "
|
|
591
|
+
self.state = {"text": "", "runs": []}
|
|
592
592
|
yield
|
|
593
593
|
|
|
594
594
|
@command("run/steer")
|
|
@@ -599,7 +599,7 @@ class BackgroundHarness(AssistantTransport):
|
|
|
599
599
|
self.state["text"] += "a"
|
|
600
600
|
await self.release.wait()
|
|
601
601
|
if self.fail_with is not None:
|
|
602
|
-
self.state["
|
|
602
|
+
self.state["runs"] = [{"error": self.fail_with}]
|
|
603
603
|
else:
|
|
604
604
|
self.state["text"] += "b"
|
|
605
605
|
|
|
@@ -652,7 +652,7 @@ async def test_run_error_recorded_into_state_is_an_error_frame_before_eof():
|
|
|
652
652
|
frames = data_stream_frames(buffer.decode())
|
|
653
653
|
assert frames[-1] == ("3", "payment required")
|
|
654
654
|
replica = apply_legacy_ops({}, state_ops(frames))
|
|
655
|
-
assert replica["error"] == {
|
|
655
|
+
assert replica["runs"][0]["error"] == {
|
|
656
656
|
"message": "payment required",
|
|
657
657
|
"reason": "payment-required",
|
|
658
658
|
}
|
|
@@ -752,7 +752,7 @@ class InputHarness(RunHarness):
|
|
|
752
752
|
async def lifespan(self):
|
|
753
753
|
self.state = {
|
|
754
754
|
"messages": [],
|
|
755
|
-
"inputRequests": [
|
|
755
|
+
"runs": [{"inputRequests": [
|
|
756
756
|
{
|
|
757
757
|
"type": "tool-call",
|
|
758
758
|
"id": "req-old",
|
|
@@ -771,7 +771,7 @@ class InputHarness(RunHarness):
|
|
|
771
771
|
"toolCallId": "call-2",
|
|
772
772
|
"response": None,
|
|
773
773
|
},
|
|
774
|
-
],
|
|
774
|
+
]}],
|
|
775
775
|
}
|
|
776
776
|
yield
|
|
777
777
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from statewire import Statewire, StatewireCommandContext, command, plain
|
|
3
|
+
from statewire.testing import command_context
|
|
4
|
+
from statewire_helpers import make_instance
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Board(Statewire):
|
|
8
|
+
async def lifespan(self):
|
|
9
|
+
self.state = {"posts": []}
|
|
10
|
+
yield
|
|
11
|
+
|
|
12
|
+
@command
|
|
13
|
+
async def post(self, text: str, *, ctx):
|
|
14
|
+
self.state["posts"].append({"text": text, "by": ctx.caller.identity})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_board():
|
|
18
|
+
sw = make_instance(Board)
|
|
19
|
+
sw.state = {"posts": []}
|
|
20
|
+
return sw
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_returns_real_context_backed_by_a_real_client():
|
|
24
|
+
sw = make_board()
|
|
25
|
+
ctx, _effects = command_context(
|
|
26
|
+
sw, client_id="c9", identity="alice", context={"tz": "utc"}
|
|
27
|
+
)
|
|
28
|
+
assert type(ctx) is StatewireCommandContext
|
|
29
|
+
assert ctx.caller.client_id == "c9"
|
|
30
|
+
assert ctx.caller.identity == "alice"
|
|
31
|
+
assert ctx.caller.context == {"tz": "utc"}
|
|
32
|
+
assert ctx.caller.is_connected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_handler_sees_state_and_effects_capture_its_ops():
|
|
36
|
+
sw = make_board()
|
|
37
|
+
ctx, effects = command_context(sw, identity="alice")
|
|
38
|
+
await sw.post("hi", ctx=ctx)
|
|
39
|
+
assert plain(sw.state) == {"posts": [{"text": "hi", "by": "alice"}]}
|
|
40
|
+
(envelope,) = effects()
|
|
41
|
+
assert envelope["ops"] == [
|
|
42
|
+
{"op": "add", "path": ["posts", 0], "value": {"text": "hi", "by": "alice"}}
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_ack_is_captured_as_pending_res_and_ack_watermark():
|
|
47
|
+
sw = make_board()
|
|
48
|
+
ctx, effects = command_context(sw, seq=3)
|
|
49
|
+
ctx.ack()
|
|
50
|
+
assert effects() == [{"res": [{"seq": 3, "type": "pending"}], "ack": 3}]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_rejects_invalid_input():
|
|
54
|
+
with pytest.raises(TypeError, match="Statewire instance"):
|
|
55
|
+
command_context(object()) # type: ignore[arg-type]
|
|
56
|
+
with pytest.raises(RuntimeError, match="state is not set"):
|
|
57
|
+
command_context(make_instance(Board))
|
|
58
|
+
sw = make_board()
|
|
59
|
+
with pytest.raises(ValueError, match="invalid client_id"):
|
|
60
|
+
command_context(sw, client_id="")
|
|
61
|
+
with pytest.raises(ValueError, match="seq must be >= 1"):
|
|
62
|
+
command_context(sw, seq=0)
|
|
63
|
+
with pytest.raises(TypeError, match="context must be a dict"):
|
|
64
|
+
command_context(sw, context="nope") # type: ignore[arg-type]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|