statewire 0.3.2__tar.gz → 0.3.3__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.3.2 → statewire-0.3.3}/PKG-INFO +1 -1
- {statewire-0.3.2 → statewire-0.3.3}/pyproject.toml +1 -1
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/assistant_transport.py +133 -60
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport.py +197 -2
- {statewire-0.3.2 → statewire-0.3.3}/.gitignore +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/README.md +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/examples/__init__.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/examples/demo_app.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/__init__.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/api.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/client.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/langgraph.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/ops.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/src/statewire/state.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/client_helpers.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/statewire_helpers.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_authorize.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_client.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_client_ws.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_commands.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_context.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_langgraph.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_lifespan.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_meta.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_state_proxy.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_stream.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_writer_lease.py +0 -0
- {statewire-0.3.2 → statewire-0.3.3}/tests/test_ws.py +0 -0
|
@@ -20,9 +20,11 @@ the command whole):
|
|
|
20
20
|
- ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>}``:
|
|
21
21
|
a fresh ``legacy_`` id is synthesized, image parts become
|
|
22
22
|
``{"type": "file", "mediaType": "image/*", "url"}`` parts, and command-level
|
|
23
|
-
anchors are dropped.
|
|
24
|
-
``
|
|
25
|
-
``
|
|
23
|
+
anchors are dropped. A command-level non-null ``sourceId`` makes it
|
|
24
|
+
``run/edit`` with that ``sourceId`` directly; otherwise, when the body
|
|
25
|
+
carries a top-level ``parentId`` and ``get_message_child_id`` resolves it to
|
|
26
|
+
a child, the command becomes ``run/edit`` with ``{"sourceId": <child>,
|
|
27
|
+
"message": <wire message>}``.
|
|
26
28
|
- ``add-tool-result`` becomes ``run/input`` with ``{"requestId": <resolved>,
|
|
27
29
|
"response": {"output": <result>, "isError"?, "artifact"?}}``; the requestId
|
|
28
30
|
comes from ``get_input_request_id(toolCallId)``, whose default peeks the
|
|
@@ -33,16 +35,19 @@ the command whole):
|
|
|
33
35
|
(None rejects).
|
|
34
36
|
|
|
35
37
|
A host without the target ``run/*`` handler rejects the batch via the
|
|
36
|
-
ordinary unknown-command path. The response streams
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
ordinary unknown-command path. The response streams state changes as legacy
|
|
39
|
+
frames until the instance is idle — every submitted command settled and no
|
|
40
|
+
live instance task (``create_task`` minus ``unref``) — then EOF. A run error
|
|
41
|
+
recorded into state (``get_run_error``; default ``state["error"]``) becomes
|
|
42
|
+
an error frame before EOF, its fields beyond ``message`` carried as the
|
|
43
|
+
frame's structured payload where the format allows; a rejection or crash
|
|
44
|
+
becomes an error frame followed by EOF. Client disconnect before EOF invokes
|
|
45
|
+
``on_assistant_transport_disconnect`` (a plain overridable hook, not a
|
|
46
|
+
command; default no-op); the run itself keeps executing server-side.
|
|
42
47
|
|
|
43
48
|
``POST /assistant-transport/api/resume`` (same body shape, ``commands`` must
|
|
44
49
|
be absent or empty) reattaches to the run: a full-state ``set`` at the root,
|
|
45
|
-
then the live tail until the
|
|
50
|
+
then the live tail until the instance is idle. If no run ever started, the
|
|
46
51
|
response is 200 with an empty body and ``X-Stream-Status: not_found``. If the
|
|
47
52
|
run already completed, the replay carries ``X-Stream-Status: completed``. A
|
|
48
53
|
resume disconnect never invokes the cancel hook.
|
|
@@ -56,7 +61,7 @@ existed at request time.
|
|
|
56
61
|
``POST /assistant-transport/api/status`` responds 200 always:
|
|
57
62
|
``{"isRunning": true, "status": "running"}`` while a run is active,
|
|
58
63
|
``{"isRunning": false, "status": "completed", "completedAt": <ms epoch>}``
|
|
59
|
-
|
|
64
|
+
once idle, and ``{"isRunning": false, "status": "not_found",
|
|
60
65
|
"message": ...}`` when no run ever started on this instance.
|
|
61
66
|
|
|
62
67
|
Two stream formats, selected by ``assistant_transport_protocol``:
|
|
@@ -74,7 +79,7 @@ import contextlib
|
|
|
74
79
|
import json
|
|
75
80
|
import secrets
|
|
76
81
|
import time
|
|
77
|
-
from typing import Any, AsyncIterator
|
|
82
|
+
from typing import Any, AsyncIterator, Coroutine
|
|
78
83
|
from uuid import uuid4
|
|
79
84
|
|
|
80
85
|
import httpx
|
|
@@ -102,7 +107,7 @@ class _DataStreamFormat:
|
|
|
102
107
|
return f"aui-state:[{','.join(op_frames)}]\n"
|
|
103
108
|
|
|
104
109
|
@staticmethod
|
|
105
|
-
def error(message: str) -> str:
|
|
110
|
+
def error(message: str, payload: Any = None) -> str:
|
|
106
111
|
return f"3:{json.dumps(message)}\n"
|
|
107
112
|
|
|
108
113
|
|
|
@@ -119,9 +124,11 @@ class _AssistantTransportFormat:
|
|
|
119
124
|
)
|
|
120
125
|
|
|
121
126
|
@staticmethod
|
|
122
|
-
def error(message: str) -> str:
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
def error(message: str, payload: Any = None) -> str:
|
|
128
|
+
chunk: dict[str, Any] = {"type": "error", "error": message}
|
|
129
|
+
if payload is not None:
|
|
130
|
+
chunk["payload"] = payload
|
|
131
|
+
return f"data: {json.dumps(chunk)}\n\n"
|
|
125
132
|
|
|
126
133
|
|
|
127
134
|
_FORMATS = {
|
|
@@ -183,7 +190,9 @@ _AT_CONSUMED_FIELDS = {"commands", "threadId", "state"}
|
|
|
183
190
|
|
|
184
191
|
|
|
185
192
|
class _RunError(Exception):
|
|
186
|
-
|
|
193
|
+
def __init__(self, message: str, *, payload: Any = None) -> None:
|
|
194
|
+
super().__init__(message)
|
|
195
|
+
self.payload = payload
|
|
187
196
|
|
|
188
197
|
|
|
189
198
|
def _legacy_wire_message(message: Any) -> dict[str, Any]:
|
|
@@ -215,6 +224,7 @@ class _Run:
|
|
|
215
224
|
def __init__(self, replica: Any) -> None:
|
|
216
225
|
self.replica = replica
|
|
217
226
|
self.error: str | None = None
|
|
227
|
+
self.error_payload: Any = None
|
|
218
228
|
self.done = False
|
|
219
229
|
self.completed_at: int | None = None
|
|
220
230
|
self.attachments: list["asyncio.Queue[_Item]"] = []
|
|
@@ -227,7 +237,7 @@ class _Run:
|
|
|
227
237
|
self.attachments.remove(aq)
|
|
228
238
|
while not aq.empty():
|
|
229
239
|
aq.get_nowait()
|
|
230
|
-
aq.put_nowait(("error", "state stream overflowed"))
|
|
240
|
+
aq.put_nowait(("error", ("state stream overflowed", None)))
|
|
231
241
|
aq.put_nowait(("end", None))
|
|
232
242
|
|
|
233
243
|
|
|
@@ -236,6 +246,34 @@ class AssistantTransport(Statewire):
|
|
|
236
246
|
|
|
237
247
|
_at_run: _Run | None = None
|
|
238
248
|
|
|
249
|
+
def __init__(self, ctx: Any) -> None:
|
|
250
|
+
super().__init__(ctx)
|
|
251
|
+
self._at_tasks: set["asyncio.Task[Any]"] = set()
|
|
252
|
+
|
|
253
|
+
def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
|
|
254
|
+
task = super().create_task(coro)
|
|
255
|
+
self._at_tasks.add(task)
|
|
256
|
+
task.add_done_callback(self._at_tasks.discard)
|
|
257
|
+
return task
|
|
258
|
+
|
|
259
|
+
def unref(self, task: "asyncio.Task[Any]") -> "asyncio.Task[Any]":
|
|
260
|
+
self._at_tasks.discard(task)
|
|
261
|
+
return super().unref(task)
|
|
262
|
+
|
|
263
|
+
async def get_run_error(self) -> dict[str, Any] | None:
|
|
264
|
+
"""The run error to surface as the end-of-stream error frame.
|
|
265
|
+
|
|
266
|
+
The default peeks the RunManager root-state mount: a non-null
|
|
267
|
+
``state["error"]`` object with a string ``message`` is the recorded
|
|
268
|
+
run error; its fields beyond ``message`` travel as the frame's
|
|
269
|
+
structured payload where the format carries one. None ends the
|
|
270
|
+
stream cleanly."""
|
|
271
|
+
state = plain(self.state)
|
|
272
|
+
error = state.get("error") if isinstance(state, dict) else None
|
|
273
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
274
|
+
return error
|
|
275
|
+
return None
|
|
276
|
+
|
|
239
277
|
async def on_assistant_transport_disconnect(self) -> None:
|
|
240
278
|
"""The legacy client went away or asked to stop; treat as a cancel signal."""
|
|
241
279
|
|
|
@@ -308,11 +346,12 @@ class AssistantTransport(Statewire):
|
|
|
308
346
|
parent_id = None
|
|
309
347
|
run = _Run(json.loads(snapshot)["ops"][0]["value"])
|
|
310
348
|
self._at_run = run
|
|
311
|
-
self.create_task(
|
|
349
|
+
watcher = self.create_task(
|
|
312
350
|
self._assistant_transport_run(
|
|
313
351
|
run, q, client_id, lease, commands, parent_id
|
|
314
352
|
)
|
|
315
353
|
)
|
|
354
|
+
self._at_tasks.discard(watcher)
|
|
316
355
|
return StreamingResponse(
|
|
317
356
|
self._assistant_transport_attach(fmt, run, initial=True),
|
|
318
357
|
media_type="text/event-stream",
|
|
@@ -415,11 +454,11 @@ class AssistantTransport(Statewire):
|
|
|
415
454
|
registered = type(self)._statewire_commands
|
|
416
455
|
if kind == "add-message" and "add-message" not in registered:
|
|
417
456
|
message = _legacy_wire_message(command.get("message"))
|
|
418
|
-
source_id = (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
457
|
+
source_id = command.get("sourceId")
|
|
458
|
+
if source_id is not None and not isinstance(source_id, str):
|
|
459
|
+
raise _RunError("add-message: sourceId must be a string or null")
|
|
460
|
+
if source_id is None and parent_id is not None:
|
|
461
|
+
source_id = await self.get_message_child_id(parent_id)
|
|
423
462
|
if source_id is not None:
|
|
424
463
|
return {
|
|
425
464
|
"method": "run/edit",
|
|
@@ -486,48 +525,82 @@ class AssistantTransport(Statewire):
|
|
|
486
525
|
total = len(commands) or (1 if parent_id is not None else 0)
|
|
487
526
|
acked = 0
|
|
488
527
|
open_pending: set[int] = set()
|
|
528
|
+
|
|
529
|
+
def idle() -> bool:
|
|
530
|
+
return (
|
|
531
|
+
submit.done()
|
|
532
|
+
and acked >= total
|
|
533
|
+
and not open_pending
|
|
534
|
+
and not self._at_tasks
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def consume(item: Any) -> bool:
|
|
538
|
+
nonlocal acked
|
|
539
|
+
if item is _LAGGARD:
|
|
540
|
+
raise _RunError("state stream overflowed")
|
|
541
|
+
data, finish = item
|
|
542
|
+
envelope = json.loads(data)
|
|
543
|
+
op_frames: list[str] = []
|
|
544
|
+
for op in envelope.get("ops", []):
|
|
545
|
+
run.replica, frame = _translate_op(run.replica, op)
|
|
546
|
+
if frame is not None:
|
|
547
|
+
op_frames.append(frame)
|
|
548
|
+
if op_frames:
|
|
549
|
+
run.push(("state", op_frames))
|
|
550
|
+
for res in envelope.get("res", []):
|
|
551
|
+
if res["type"] == "pending":
|
|
552
|
+
open_pending.add(res["seq"])
|
|
553
|
+
continue
|
|
554
|
+
open_pending.discard(res["seq"])
|
|
555
|
+
if res["type"] in ("rejected", "crashed"):
|
|
556
|
+
raise _RunError(
|
|
557
|
+
res.get("message") or res["type"],
|
|
558
|
+
payload=res.get("payload"),
|
|
559
|
+
)
|
|
560
|
+
if "ack" in envelope:
|
|
561
|
+
acked = envelope["ack"]
|
|
562
|
+
if finish and not idle():
|
|
563
|
+
fin = envelope.get("fin", {})
|
|
564
|
+
raise _RunError(
|
|
565
|
+
fin.get("message") or fin.get("reason", "stream ended")
|
|
566
|
+
)
|
|
567
|
+
return finish
|
|
568
|
+
|
|
489
569
|
try:
|
|
490
|
-
|
|
491
|
-
|
|
570
|
+
ended = False
|
|
571
|
+
while not ended:
|
|
572
|
+
if idle():
|
|
573
|
+
self.drain()
|
|
574
|
+
self.flush()
|
|
575
|
+
if q_get.done():
|
|
576
|
+
ended = consume(q_get.result()) or ended
|
|
577
|
+
q_get = asyncio.create_task(q.get())
|
|
578
|
+
continue
|
|
579
|
+
while not q.empty():
|
|
580
|
+
ended = consume(q.get_nowait()) or ended
|
|
581
|
+
if idle():
|
|
582
|
+
break
|
|
583
|
+
continue
|
|
584
|
+
waiters = {q_get, *self._at_tasks}
|
|
585
|
+
if not submit.done():
|
|
586
|
+
waiters.add(submit)
|
|
492
587
|
done, _ = await asyncio.wait(
|
|
493
588
|
waiters, return_when=asyncio.FIRST_COMPLETED
|
|
494
589
|
)
|
|
495
590
|
if submit in done and submit.exception() is not None:
|
|
496
591
|
raise _RunError(str(submit.exception()))
|
|
497
|
-
if q_get
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
for op in envelope.get("ops", []):
|
|
506
|
-
run.replica, frame = _translate_op(run.replica, op)
|
|
507
|
-
if frame is not None:
|
|
508
|
-
op_frames.append(frame)
|
|
509
|
-
if op_frames:
|
|
510
|
-
run.push(("state", op_frames))
|
|
511
|
-
for res in envelope.get("res", []):
|
|
512
|
-
if res["type"] == "pending":
|
|
513
|
-
open_pending.add(res["seq"])
|
|
514
|
-
continue
|
|
515
|
-
open_pending.discard(res["seq"])
|
|
516
|
-
if res["type"] in ("rejected", "crashed"):
|
|
517
|
-
raise _RunError(res.get("message") or res["type"])
|
|
518
|
-
if "ack" in envelope:
|
|
519
|
-
acked = envelope["ack"]
|
|
520
|
-
if finish:
|
|
521
|
-
if submit.done() and acked >= total and not open_pending:
|
|
522
|
-
break
|
|
523
|
-
fin = envelope.get("fin", {})
|
|
524
|
-
raise _RunError(
|
|
525
|
-
fin.get("message") or fin.get("reason", "stream ended")
|
|
526
|
-
)
|
|
527
|
-
q_get = asyncio.create_task(q.get())
|
|
592
|
+
if q_get in done:
|
|
593
|
+
ended = consume(q_get.result())
|
|
594
|
+
if not ended:
|
|
595
|
+
q_get = asyncio.create_task(q.get())
|
|
596
|
+
error = await self.get_run_error()
|
|
597
|
+
if error is not None:
|
|
598
|
+
payload = {k: v for k, v in error.items() if k != "message"}
|
|
599
|
+
raise _RunError(error["message"], payload=payload or None)
|
|
528
600
|
except _RunError as exc:
|
|
529
601
|
run.error = str(exc)
|
|
530
|
-
run.
|
|
602
|
+
run.error_payload = exc.payload
|
|
603
|
+
run.push(("error", (run.error, run.error_payload)))
|
|
531
604
|
finally:
|
|
532
605
|
q_get.cancel()
|
|
533
606
|
submit.cancel()
|
|
@@ -559,11 +632,11 @@ class AssistantTransport(Statewire):
|
|
|
559
632
|
if kind == "state":
|
|
560
633
|
yield fmt.state(value)
|
|
561
634
|
elif kind == "error":
|
|
562
|
-
yield fmt.error(value)
|
|
635
|
+
yield fmt.error(*value)
|
|
563
636
|
else:
|
|
564
637
|
break
|
|
565
638
|
elif initial and run.error is not None:
|
|
566
|
-
yield fmt.error(run.error)
|
|
639
|
+
yield fmt.error(run.error, run.error_payload)
|
|
567
640
|
if fmt.end:
|
|
568
641
|
yield fmt.end
|
|
569
642
|
completed = True
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
The legacy client POSTs ``{"commands", "state", "threadId", ...}`` and reads
|
|
4
4
|
back a data-stream (``<prefix>:<json>`` lines) or assistant-transport SSE
|
|
5
|
-
response carrying ``set``/``append-text`` state ops until the
|
|
5
|
+
response carrying ``set``/``append-text`` state ops until the instance is
|
|
6
|
+
idle.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import asyncio
|
|
9
10
|
import contextlib
|
|
10
11
|
import json
|
|
11
12
|
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
12
15
|
from statewire_helpers import statewire_client
|
|
13
16
|
|
|
14
17
|
from statewire import AssistantTransport, StatewireReject, command
|
|
@@ -110,7 +113,7 @@ async def post_run(client, body, thread="t1"):
|
|
|
110
113
|
return await client.post(f"/threads/{thread}/assistant-transport/api/chat", json=body)
|
|
111
114
|
|
|
112
115
|
|
|
113
|
-
async def
|
|
116
|
+
async def test_data_stream_response_streams_snapshot_then_ops_until_idle():
|
|
114
117
|
async with statewire_client(Harness) as (app, client):
|
|
115
118
|
response = await post_run(
|
|
116
119
|
client,
|
|
@@ -347,6 +350,14 @@ class _PostStream:
|
|
|
347
350
|
async def next_chunk(self, timeout: float = 5) -> bytes:
|
|
348
351
|
return await asyncio.wait_for(self._chunks.get(), timeout)
|
|
349
352
|
|
|
353
|
+
async def until_closed(self, timeout: float = 5) -> bytes:
|
|
354
|
+
assert self._task is not None
|
|
355
|
+
await asyncio.wait_for(asyncio.shield(self._task), timeout)
|
|
356
|
+
tail = b""
|
|
357
|
+
while not self._chunks.empty():
|
|
358
|
+
tail += self._chunks.get_nowait()
|
|
359
|
+
return tail
|
|
360
|
+
|
|
350
361
|
async def __aexit__(self, *exc):
|
|
351
362
|
self._disconnected.set()
|
|
352
363
|
assert self._task is not None
|
|
@@ -597,6 +608,138 @@ async def test_status_reports_not_found_running_and_completed():
|
|
|
597
608
|
assert isinstance(body["completedAt"], int)
|
|
598
609
|
|
|
599
610
|
|
|
611
|
+
class BackgroundHarness(AssistantTransport):
|
|
612
|
+
"""Host whose run/steer settles at enqueue; the run is an instance task."""
|
|
613
|
+
|
|
614
|
+
def __init__(self, ctx):
|
|
615
|
+
super().__init__(ctx)
|
|
616
|
+
self.release = asyncio.Event()
|
|
617
|
+
self.fail_with = None
|
|
618
|
+
self.cancelled = 0
|
|
619
|
+
|
|
620
|
+
async def lifespan(self):
|
|
621
|
+
self.state = {"text": "", "error": None}
|
|
622
|
+
yield
|
|
623
|
+
|
|
624
|
+
@command("run/steer")
|
|
625
|
+
async def run_steer(self, params):
|
|
626
|
+
self.create_task(self._run())
|
|
627
|
+
|
|
628
|
+
async def _run(self):
|
|
629
|
+
self.state["text"] += "a"
|
|
630
|
+
await self.release.wait()
|
|
631
|
+
if self.fail_with is not None:
|
|
632
|
+
self.state["error"] = self.fail_with
|
|
633
|
+
else:
|
|
634
|
+
self.state["text"] += "b"
|
|
635
|
+
|
|
636
|
+
async def on_assistant_transport_disconnect(self):
|
|
637
|
+
self.cancelled += 1
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def post_background_chat(app):
|
|
641
|
+
return _PostStream(
|
|
642
|
+
app,
|
|
643
|
+
"/threads/t1/assistant-transport/api/chat",
|
|
644
|
+
{"commands": [{"type": "run/steer"}], "state": {}, "threadId": "t1"},
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def test_chat_stays_open_past_command_settle_and_eofs_at_idle():
|
|
649
|
+
async with statewire_client(BackgroundHarness) as (app, client):
|
|
650
|
+
async with post_background_chat(app) as stream:
|
|
651
|
+
assert stream.status == 200
|
|
652
|
+
buffer = b""
|
|
653
|
+
while b'"a"' not in buffer:
|
|
654
|
+
buffer += await stream.next_chunk()
|
|
655
|
+
with pytest.raises(TimeoutError):
|
|
656
|
+
await stream.next_chunk(0.1)
|
|
657
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
658
|
+
instance.release.set()
|
|
659
|
+
buffer += await stream.until_closed()
|
|
660
|
+
frames = data_stream_frames(buffer.decode())
|
|
661
|
+
assert all(prefix == "aui-state" for prefix, _ in frames)
|
|
662
|
+
replica = apply_legacy_ops({}, state_ops(frames))
|
|
663
|
+
assert replica["text"] == "ab"
|
|
664
|
+
body = (
|
|
665
|
+
await client.post(
|
|
666
|
+
"/threads/t1/assistant-transport/api/status", json={"threadId": "t1"}
|
|
667
|
+
)
|
|
668
|
+
).json()
|
|
669
|
+
assert body["status"] == "completed"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
async def test_run_error_recorded_into_state_is_an_error_frame_before_eof():
|
|
673
|
+
async with statewire_client(BackgroundHarness) as (app, client):
|
|
674
|
+
async with post_background_chat(app) as stream:
|
|
675
|
+
buffer = b""
|
|
676
|
+
while b'"a"' not in buffer:
|
|
677
|
+
buffer += await stream.next_chunk()
|
|
678
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
679
|
+
instance.fail_with = {
|
|
680
|
+
"message": "payment required",
|
|
681
|
+
"reason": "payment-required",
|
|
682
|
+
}
|
|
683
|
+
instance.release.set()
|
|
684
|
+
buffer += await stream.until_closed()
|
|
685
|
+
frames = data_stream_frames(buffer.decode())
|
|
686
|
+
assert frames[-1] == ("3", "payment required")
|
|
687
|
+
replica = apply_legacy_ops({}, state_ops(frames))
|
|
688
|
+
assert replica["error"] == {
|
|
689
|
+
"message": "payment required",
|
|
690
|
+
"reason": "payment-required",
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
async def test_sse_mode_run_error_carries_the_structured_payload():
|
|
695
|
+
class SSEBackgroundHarness(BackgroundHarness):
|
|
696
|
+
assistant_transport_protocol = "assistant-transport"
|
|
697
|
+
|
|
698
|
+
async with statewire_client(SSEBackgroundHarness) as (app, client):
|
|
699
|
+
async with _PostStream(
|
|
700
|
+
app,
|
|
701
|
+
"/threads/t1/assistant-transport/api/chat",
|
|
702
|
+
{"commands": [{"type": "run/steer"}], "state": {}, "threadId": "t1"},
|
|
703
|
+
) as stream:
|
|
704
|
+
buffer = b""
|
|
705
|
+
while b'"a"' not in buffer:
|
|
706
|
+
buffer += await stream.next_chunk()
|
|
707
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
708
|
+
instance.fail_with = {
|
|
709
|
+
"message": "payment required",
|
|
710
|
+
"reason": "payment-required",
|
|
711
|
+
}
|
|
712
|
+
instance.release.set()
|
|
713
|
+
buffer += await stream.until_closed()
|
|
714
|
+
frames = sse_frames(buffer.decode())
|
|
715
|
+
assert frames[-1] == "[DONE]"
|
|
716
|
+
assert frames[-2] == {
|
|
717
|
+
"type": "error",
|
|
718
|
+
"error": "payment required",
|
|
719
|
+
"payload": {"reason": "payment-required"},
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def test_disconnect_before_idle_fires_the_hook_and_the_run_continues():
|
|
724
|
+
async with statewire_client(BackgroundHarness) as (app, client):
|
|
725
|
+
async with post_background_chat(app) as stream:
|
|
726
|
+
buffer = b""
|
|
727
|
+
while b'"a"' not in buffer:
|
|
728
|
+
buffer += await stream.next_chunk()
|
|
729
|
+
await asyncio.sleep(0.05)
|
|
730
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
731
|
+
assert instance.cancelled == 1
|
|
732
|
+
instance.release.set()
|
|
733
|
+
await asyncio.sleep(0.05)
|
|
734
|
+
response = await client.post(
|
|
735
|
+
"/threads/t1/assistant-transport/api/resume",
|
|
736
|
+
json={"commands": [], "state": {}, "threadId": "t1"},
|
|
737
|
+
)
|
|
738
|
+
assert response.headers["x-stream-status"] == "completed"
|
|
739
|
+
ops = state_ops(data_stream_frames(response.text))
|
|
740
|
+
assert ops[0]["value"]["text"] == "ab"
|
|
741
|
+
|
|
742
|
+
|
|
600
743
|
class RunHarness(AssistantTransport):
|
|
601
744
|
"""Host speaking the canonical run/* dialect; no legacy handlers."""
|
|
602
745
|
|
|
@@ -992,6 +1135,58 @@ async def test_add_message_with_a_resolvable_parent_is_normalized_to_run_edit():
|
|
|
992
1135
|
assert params["message"]["parts"] == [{"type": "text", "text": "again"}]
|
|
993
1136
|
|
|
994
1137
|
|
|
1138
|
+
async def test_add_message_with_a_source_id_is_normalized_to_run_edit_with_it():
|
|
1139
|
+
async with statewire_client(AnchoredHarness) as (app, client):
|
|
1140
|
+
response = await post_run(
|
|
1141
|
+
client,
|
|
1142
|
+
{
|
|
1143
|
+
"commands": [
|
|
1144
|
+
{
|
|
1145
|
+
"type": "add-message",
|
|
1146
|
+
"message": {
|
|
1147
|
+
"role": "user",
|
|
1148
|
+
"parts": [{"type": "text", "text": "again"}],
|
|
1149
|
+
},
|
|
1150
|
+
"sourceId": "m9",
|
|
1151
|
+
}
|
|
1152
|
+
],
|
|
1153
|
+
"state": {},
|
|
1154
|
+
"parentId": "m1",
|
|
1155
|
+
},
|
|
1156
|
+
)
|
|
1157
|
+
assert response.status_code == 200
|
|
1158
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
1159
|
+
assert instance.steered == []
|
|
1160
|
+
[params] = instance.edited
|
|
1161
|
+
assert params["sourceId"] == "m9"
|
|
1162
|
+
assert params["message"]["parts"] == [{"type": "text", "text": "again"}]
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
async def test_add_message_with_a_null_source_id_and_no_parent_is_run_steer():
|
|
1166
|
+
async with statewire_client(AnchoredHarness) as (app, client):
|
|
1167
|
+
response = await post_run(
|
|
1168
|
+
client,
|
|
1169
|
+
{
|
|
1170
|
+
"commands": [
|
|
1171
|
+
{
|
|
1172
|
+
"type": "add-message",
|
|
1173
|
+
"message": {
|
|
1174
|
+
"role": "user",
|
|
1175
|
+
"parts": [{"type": "text", "text": "hi"}],
|
|
1176
|
+
},
|
|
1177
|
+
"sourceId": None,
|
|
1178
|
+
}
|
|
1179
|
+
],
|
|
1180
|
+
"state": {},
|
|
1181
|
+
},
|
|
1182
|
+
)
|
|
1183
|
+
assert response.status_code == 200
|
|
1184
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
1185
|
+
assert instance.edited == []
|
|
1186
|
+
[(params, _)] = instance.steered
|
|
1187
|
+
assert set(params) == {"message"}
|
|
1188
|
+
|
|
1189
|
+
|
|
995
1190
|
async def test_add_message_with_an_unresolvable_parent_falls_back_to_run_steer():
|
|
996
1191
|
async with statewire_client(AnchoredHarness) as (app, client):
|
|
997
1192
|
response = await post_run(
|
|
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
|