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.
- {statewire-0.4.4 → statewire-0.4.6}/PKG-INFO +1 -1
- {statewire-0.4.4 → statewire-0.4.6}/pyproject.toml +1 -1
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/assistant_transport.py +21 -5
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport.py +108 -13
- {statewire-0.4.4 → statewire-0.4.6}/.gitignore +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/README.md +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/examples/__init__.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/examples/demo_app.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/__init__.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/api.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/client.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/langgraph.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/legacy.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/ops.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/state.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/src/statewire/testing.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/client_helpers.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/statewire_helpers.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_authorize.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_client.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_client_ws.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_commands.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_context.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_idle.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_langgraph.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_legacy.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_lifespan.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_meta.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_state_proxy.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_stream.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_testing.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_writer_lease.py +0 -0
- {statewire-0.4.4 → statewire-0.4.6}/tests/test_ws.py +0 -0
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
"
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
|
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'"
|
|
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
|
|
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
|