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.
Files changed (37) hide show
  1. {statewire-0.4.3 → statewire-0.4.4}/PKG-INFO +1 -1
  2. {statewire-0.4.3 → statewire-0.4.4}/pyproject.toml +1 -1
  3. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/assistant_transport.py +21 -13
  4. statewire-0.4.4/src/statewire/testing.py +61 -0
  5. {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport.py +5 -5
  6. statewire-0.4.4/tests/test_testing.py +64 -0
  7. {statewire-0.4.3 → statewire-0.4.4}/.gitignore +0 -0
  8. {statewire-0.4.3 → statewire-0.4.4}/README.md +0 -0
  9. {statewire-0.4.3 → statewire-0.4.4}/examples/__init__.py +0 -0
  10. {statewire-0.4.3 → statewire-0.4.4}/examples/demo_app.py +0 -0
  11. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/__init__.py +0 -0
  12. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/api.py +0 -0
  13. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/assistant_transport_client.py +0 -0
  14. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/client.py +0 -0
  15. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/langgraph.py +0 -0
  16. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/legacy.py +0 -0
  17. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/ops.py +0 -0
  18. {statewire-0.4.3 → statewire-0.4.4}/src/statewire/state.py +0 -0
  19. {statewire-0.4.3 → statewire-0.4.4}/tests/client_helpers.py +0 -0
  20. {statewire-0.4.3 → statewire-0.4.4}/tests/statewire_helpers.py +0 -0
  21. {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport_client.py +0 -0
  22. {statewire-0.4.3 → statewire-0.4.4}/tests/test_assistant_transport_facade.py +0 -0
  23. {statewire-0.4.3 → statewire-0.4.4}/tests/test_authorize.py +0 -0
  24. {statewire-0.4.3 → statewire-0.4.4}/tests/test_client.py +0 -0
  25. {statewire-0.4.3 → statewire-0.4.4}/tests/test_client_ws.py +0 -0
  26. {statewire-0.4.3 → statewire-0.4.4}/tests/test_commands.py +0 -0
  27. {statewire-0.4.3 → statewire-0.4.4}/tests/test_context.py +0 -0
  28. {statewire-0.4.3 → statewire-0.4.4}/tests/test_idle.py +0 -0
  29. {statewire-0.4.3 → statewire-0.4.4}/tests/test_langgraph.py +0 -0
  30. {statewire-0.4.3 → statewire-0.4.4}/tests/test_legacy.py +0 -0
  31. {statewire-0.4.3 → statewire-0.4.4}/tests/test_lifespan.py +0 -0
  32. {statewire-0.4.3 → statewire-0.4.4}/tests/test_meta.py +0 -0
  33. {statewire-0.4.3 → statewire-0.4.4}/tests/test_state_proxy.py +0 -0
  34. {statewire-0.4.3 → statewire-0.4.4}/tests/test_statewire_hostable.py +0 -0
  35. {statewire-0.4.3 → statewire-0.4.4}/tests/test_stream.py +0 -0
  36. {statewire-0.4.3 → statewire-0.4.4}/tests/test_writer_lease.py +0 -0
  37. {statewire-0.4.3 → statewire-0.4.4}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Replicate one JSON object over an SSE op stream + a command endpoint
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statewire"
3
- version = "0.4.3"
3
+ version = "0.4.4"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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"]`` (None rejects),
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 ``state["error"]``) becomes
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 recorded
232
- run error; its fields beyond ``message`` travel as the frame's
233
- structured payload where the format carries one. None ends the
234
- stream cleanly."""
235
- state = plain(self.state)
236
- error = state.get("error") if isinstance(state, dict) else None
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": null}`` entries;
246
- the unanswered tool-call request matching ``tool_call_id`` wins. None
247
- rejects the legacy ``add-tool-result`` command."""
248
- state = plain(self.state)
249
- requests = state.get("inputRequests") if isinstance(state, dict) else None
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": "", "error": None}
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["error"] = self.fail_with
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