statewire 0.4.4__tar.gz → 0.4.6__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.4 → statewire-0.4.6}/PKG-INFO +1 -1
  2. {statewire-0.4.4 → statewire-0.4.6}/pyproject.toml +1 -1
  3. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/assistant_transport.py +21 -5
  4. {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport.py +108 -13
  5. {statewire-0.4.4 → statewire-0.4.6}/.gitignore +0 -0
  6. {statewire-0.4.4 → statewire-0.4.6}/README.md +0 -0
  7. {statewire-0.4.4 → statewire-0.4.6}/examples/__init__.py +0 -0
  8. {statewire-0.4.4 → statewire-0.4.6}/examples/demo_app.py +0 -0
  9. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/__init__.py +0 -0
  10. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/api.py +0 -0
  11. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/assistant_transport_client.py +0 -0
  12. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/client.py +0 -0
  13. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/langgraph.py +0 -0
  14. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/legacy.py +0 -0
  15. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/ops.py +0 -0
  16. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/state.py +0 -0
  17. {statewire-0.4.4 → statewire-0.4.6}/src/statewire/testing.py +0 -0
  18. {statewire-0.4.4 → statewire-0.4.6}/tests/client_helpers.py +0 -0
  19. {statewire-0.4.4 → statewire-0.4.6}/tests/statewire_helpers.py +0 -0
  20. {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport_client.py +0 -0
  21. {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport_facade.py +0 -0
  22. {statewire-0.4.4 → statewire-0.4.6}/tests/test_authorize.py +0 -0
  23. {statewire-0.4.4 → statewire-0.4.6}/tests/test_client.py +0 -0
  24. {statewire-0.4.4 → statewire-0.4.6}/tests/test_client_ws.py +0 -0
  25. {statewire-0.4.4 → statewire-0.4.6}/tests/test_commands.py +0 -0
  26. {statewire-0.4.4 → statewire-0.4.6}/tests/test_context.py +0 -0
  27. {statewire-0.4.4 → statewire-0.4.6}/tests/test_idle.py +0 -0
  28. {statewire-0.4.4 → statewire-0.4.6}/tests/test_langgraph.py +0 -0
  29. {statewire-0.4.4 → statewire-0.4.6}/tests/test_legacy.py +0 -0
  30. {statewire-0.4.4 → statewire-0.4.6}/tests/test_lifespan.py +0 -0
  31. {statewire-0.4.4 → statewire-0.4.6}/tests/test_meta.py +0 -0
  32. {statewire-0.4.4 → statewire-0.4.6}/tests/test_state_proxy.py +0 -0
  33. {statewire-0.4.4 → statewire-0.4.6}/tests/test_statewire_hostable.py +0 -0
  34. {statewire-0.4.4 → statewire-0.4.6}/tests/test_stream.py +0 -0
  35. {statewire-0.4.4 → statewire-0.4.6}/tests/test_testing.py +0 -0
  36. {statewire-0.4.4 → statewire-0.4.6}/tests/test_writer_lease.py +0 -0
  37. {statewire-0.4.4 → statewire-0.4.6}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.4.4
3
+ Version: 0.4.6
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.4"
3
+ version = "0.4.6"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -38,7 +38,12 @@ the command whole):
38
38
 
39
39
  A host without the target ``run/*`` handler rejects the batch via the
40
40
  ordinary unknown-command path. The response streams state changes as legacy
41
- frames until the instance is idle — every submitted command settled and no
41
+ frames. A command-carrying request opens with a single full-state ``set``
42
+ emitted once every submitted command is acked, so the first state frame
43
+ carries all of the request's commands' effects; a rejection or run error
44
+ before that point sends the error frame with no state frame ahead of it. A
45
+ command-free request opens with the snapshot immediately. The stream runs
46
+ until the instance is idle — every submitted command settled and no
42
47
  live instance task (``create_task`` minus ``unref``) — then EOF. A run error
43
48
  recorded into state (``get_run_error``; default
44
49
  ``state["runs"][0]["error"]``) becomes
@@ -206,8 +211,9 @@ _Item = tuple[str, Any]
206
211
 
207
212
 
208
213
  class _Run:
209
- def __init__(self, replica: Any) -> None:
214
+ def __init__(self, replica: Any, *, holding: bool = False) -> None:
210
215
  self.replica = replica
216
+ self.holding = holding
211
217
  self.error: str | None = None
212
218
  self.error_payload: Any = None
213
219
  self.done = False
@@ -313,7 +319,10 @@ class AssistantTransport(Statewire):
313
319
  parent_id = body.get("parentId")
314
320
  if not isinstance(parent_id, str):
315
321
  parent_id = None
316
- run = _Run(json.loads(snapshot)["ops"][0]["value"])
322
+ run = _Run(
323
+ json.loads(snapshot)["ops"][0]["value"],
324
+ holding=bool(commands) or parent_id is not None,
325
+ )
317
326
  self._at_run = run
318
327
  watcher = self.create_task(
319
328
  self._assistant_transport_run(
@@ -474,6 +483,10 @@ class AssistantTransport(Statewire):
474
483
  acked = 0
475
484
  open_pending: set[int] = set()
476
485
 
486
+ def release_hold() -> None:
487
+ run.holding = False
488
+ run.push(("state", [_legacy_op("set", [], run.replica)]))
489
+
477
490
  def idle() -> bool:
478
491
  return (
479
492
  submit.done()
@@ -493,7 +506,7 @@ class AssistantTransport(Statewire):
493
506
  run.replica, frame = _translate_op(run.replica, op)
494
507
  if frame is not None:
495
508
  op_frames.append(frame)
496
- if op_frames:
509
+ if op_frames and not run.holding:
497
510
  run.push(("state", op_frames))
498
511
  for res in envelope.get("res", []):
499
512
  if res["type"] == "pending":
@@ -507,6 +520,8 @@ class AssistantTransport(Statewire):
507
520
  )
508
521
  if "ack" in envelope:
509
522
  acked = envelope["ack"]
523
+ if acked >= total and run.holding:
524
+ release_hold()
510
525
  if finish and not idle():
511
526
  fin = envelope.get("fin", {})
512
527
  raise _RunError(
@@ -566,7 +581,8 @@ class AssistantTransport(Statewire):
566
581
  if live is not None:
567
582
  live.attachments.append(aq)
568
583
  try:
569
- yield fmt.state([_legacy_op("set", [], run.replica)])
584
+ if not (initial and run.holding):
585
+ yield fmt.state([_legacy_op("set", [], run.replica)])
570
586
  if live is not None:
571
587
  while True:
572
588
  try:
@@ -43,7 +43,9 @@ class Harness(AssistantTransport):
43
43
  raise StatewireReject("not allowed")
44
44
 
45
45
  @command("splice")
46
- async def splice(self, cmd):
46
+ async def splice(self, cmd, *, ctx):
47
+ ctx.ack()
48
+ await asyncio.sleep(0)
47
49
  self.state["messages"].insert(0, {"first": True})
48
50
  del self.state["meta"]["title"]
49
51
 
@@ -109,7 +111,7 @@ async def post_run(client, body, thread="t1"):
109
111
  return await client.post(f"/threads/{thread}/assistant-transport/api/chat", json=body)
110
112
 
111
113
 
112
- async def test_data_stream_response_streams_snapshot_then_ops_until_idle():
114
+ async def test_data_stream_first_frame_is_a_full_set_carrying_command_effects():
113
115
  async with statewire_client(Harness) as (app, client):
114
116
  response = await post_run(
115
117
  client,
@@ -137,18 +139,16 @@ async def test_data_stream_response_streams_snapshot_then_ops_until_idle():
137
139
  frames = data_stream_frames(response.text)
138
140
  assert all(prefix == "aui-state" for prefix, _ in frames)
139
141
  ops = state_ops(frames)
140
- assert ops[0] == {
141
- "type": "set",
142
- "path": [],
143
- "value": {"messages": [], "text": "", "meta": {"title": None}},
144
- }
142
+ assert ops[0]["type"] == "set" and ops[0]["path"] == []
143
+ assert ops[0]["value"]["messages"] == [
144
+ {"role": "user", "parts": [{"type": "text", "text": "hi"}]}
145
+ ]
145
146
  replica = apply_legacy_ops({}, ops)
146
147
  assert replica == {
147
148
  "messages": [{"role": "user", "parts": [{"type": "text", "text": "hi"}]}],
148
149
  "text": "echo: hi",
149
150
  "meta": {"title": None},
150
151
  }
151
- assert any(op["type"] == "append-text" and op["path"] == ["text"] for op in ops)
152
152
 
153
153
 
154
154
  async def test_unknown_fields_are_tolerated_and_commands_pass_through_whole():
@@ -189,13 +189,13 @@ async def test_unknown_command_type_is_an_error_frame_then_eof():
189
189
  assert "unknown method" in frames[-1][1]
190
190
 
191
191
 
192
- async def test_rejected_command_is_an_error_frame_then_eof():
192
+ async def test_rejected_command_is_an_error_frame_with_no_state_frame():
193
193
  async with statewire_client(Harness) as (app, client):
194
194
  response = await post_run(
195
195
  client, {"commands": [{"type": "reject-me"}], "state": {}}
196
196
  )
197
197
  frames = data_stream_frames(response.text)
198
- assert frames[-1] == ("3", "not allowed")
198
+ assert frames == [("3", "not allowed")]
199
199
 
200
200
 
201
201
  async def test_malformed_body_is_400():
@@ -287,8 +287,7 @@ async def test_sse_mode_rejections_ride_an_error_chunk():
287
287
  async with statewire_client(SSEHarness) as (app, client):
288
288
  response = await post_run(client, {"commands": [{"type": "reject-me"}]})
289
289
  frames = sse_frames(response.text)
290
- assert frames[-1] == "[DONE]"
291
- assert frames[-2] == {"type": "error", "error": "not allowed"}
290
+ assert frames == [{"type": "error", "error": "not allowed"}, "[DONE]"]
292
291
 
293
292
 
294
293
  class _PostStream:
@@ -451,7 +450,7 @@ async def test_mid_run_disconnect_then_resume_streams_snapshot_and_live_tail():
451
450
  ) as stream:
452
451
  assert stream.status == 200
453
452
  buffer = b""
454
- while b'"append-text"' not in buffer:
453
+ while b'"a"' not in buffer:
455
454
  buffer += await stream.next_chunk()
456
455
  instance = (await host.directory.get("t1")).instance
457
456
 
@@ -706,6 +705,102 @@ async def test_disconnect_before_idle_leaves_the_run_executing():
706
705
  assert ops[0]["value"]["text"] == "ab"
707
706
 
708
707
 
708
+ class HeldHarness(Harness):
709
+ """Each command writes state eagerly but acks only when its gate opens."""
710
+
711
+ def __init__(self, ctx):
712
+ super().__init__(ctx)
713
+ self.releases = {"a": asyncio.Event(), "b": asyncio.Event()}
714
+
715
+ @command("held")
716
+ async def held(self, cmd, *, ctx):
717
+ self.state["text"] += cmd["value"]
718
+ await self.releases[cmd["value"]].wait()
719
+ ctx.ack()
720
+ self.state["text"] += cmd["value"].upper()
721
+
722
+ @command("held-reject")
723
+ async def held_reject(self, cmd, *, ctx):
724
+ self.state["text"] += cmd["value"]
725
+ await self.releases[cmd["value"]].wait()
726
+ raise StatewireReject("late reject")
727
+
728
+
729
+ def post_held_chat(app, commands):
730
+ return _PostStream(
731
+ app,
732
+ "/threads/t1/assistant-transport/api/chat",
733
+ {"commands": commands, "state": {}, "threadId": "t1"},
734
+ )
735
+
736
+
737
+ async def test_no_state_frame_until_every_command_is_acked():
738
+ async with statewire_client(HeldHarness) as (app, client):
739
+ commands = [
740
+ {"type": "held", "value": "a"},
741
+ {"type": "held", "value": "b"},
742
+ ]
743
+ async with post_held_chat(app, commands) as stream:
744
+ with pytest.raises(TimeoutError):
745
+ await stream.next_chunk(0.1)
746
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
747
+ instance.releases["a"].set()
748
+ with pytest.raises(TimeoutError):
749
+ await stream.next_chunk(0.1)
750
+ instance.releases["b"].set()
751
+ buffer = await stream.until_closed()
752
+ ops = state_ops(data_stream_frames(buffer.decode()))
753
+ assert ops[0]["type"] == "set" and ops[0]["path"] == []
754
+ assert apply_legacy_ops({}, ops)["text"] == "abAB"
755
+
756
+
757
+ async def test_held_deltas_fold_into_the_release_snapshot():
758
+ async with statewire_client(HeldHarness) as (app, client):
759
+ commands = [
760
+ {"type": "held", "value": "a"},
761
+ {"type": "held", "value": "b"},
762
+ ]
763
+ async with post_held_chat(app, commands) as stream:
764
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
765
+ instance.releases["a"].set()
766
+ instance.releases["b"].set()
767
+ buffer = await stream.until_closed()
768
+ ops = state_ops(data_stream_frames(buffer.decode()))
769
+ assert ops[0]["type"] == "set" and ops[0]["path"] == []
770
+ appended = [op["value"] for op in ops if op["type"] == "append-text"]
771
+ assert "a" not in appended and "b" not in appended
772
+ assert apply_legacy_ops({}, ops)["text"] == "abAB"
773
+
774
+
775
+ async def test_rejection_while_holding_is_an_error_frame_with_no_state_frame():
776
+ async with statewire_client(HeldHarness) as (app, client):
777
+ commands = [
778
+ {"type": "held", "value": "a"},
779
+ {"type": "reject-me"},
780
+ ]
781
+ async with post_held_chat(app, commands) as stream:
782
+ buffer = await stream.until_closed()
783
+ frames = data_stream_frames(buffer.decode())
784
+ assert frames == [("3", "not allowed")]
785
+
786
+
787
+ async def test_partially_acked_commands_then_rejection_send_no_state_frame():
788
+ async with statewire_client(HeldHarness) as (app, client):
789
+ commands = [
790
+ {"type": "held", "value": "a"},
791
+ {"type": "held-reject", "value": "b"},
792
+ ]
793
+ async with post_held_chat(app, commands) as stream:
794
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
795
+ instance.releases["a"].set()
796
+ with pytest.raises(TimeoutError):
797
+ await stream.next_chunk(0.1)
798
+ instance.releases["b"].set()
799
+ buffer = await stream.until_closed()
800
+ frames = data_stream_frames(buffer.decode())
801
+ assert frames == [("3", "late reject")]
802
+
803
+
709
804
  class RunHarness(AssistantTransport):
710
805
  """Host speaking the canonical run/* dialect; no legacy handlers."""
711
806
 
File without changes
File without changes
File without changes
File without changes
File without changes