statewire 0.3.0__tar.gz → 0.3.2__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 (33) hide show
  1. {statewire-0.3.0 → statewire-0.3.2}/PKG-INFO +1 -1
  2. {statewire-0.3.0 → statewire-0.3.2}/pyproject.toml +1 -1
  3. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/api.py +58 -19
  4. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/assistant_transport.py +167 -17
  5. {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport.py +420 -0
  6. statewire-0.3.2/tests/test_authorize.py +256 -0
  7. {statewire-0.3.0 → statewire-0.3.2}/tests/test_commands.py +19 -0
  8. {statewire-0.3.0 → statewire-0.3.2}/tests/test_state_proxy.py +1 -1
  9. {statewire-0.3.0 → statewire-0.3.2}/tests/test_writer_lease.py +1 -1
  10. statewire-0.3.0/tests/test_authorize.py +0 -56
  11. {statewire-0.3.0 → statewire-0.3.2}/.gitignore +0 -0
  12. {statewire-0.3.0 → statewire-0.3.2}/README.md +0 -0
  13. {statewire-0.3.0 → statewire-0.3.2}/examples/__init__.py +0 -0
  14. {statewire-0.3.0 → statewire-0.3.2}/examples/demo_app.py +0 -0
  15. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/__init__.py +0 -0
  16. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/assistant_transport_client.py +0 -0
  17. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/client.py +0 -0
  18. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/langgraph.py +0 -0
  19. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/ops.py +0 -0
  20. {statewire-0.3.0 → statewire-0.3.2}/src/statewire/state.py +0 -0
  21. {statewire-0.3.0 → statewire-0.3.2}/tests/client_helpers.py +0 -0
  22. {statewire-0.3.0 → statewire-0.3.2}/tests/statewire_helpers.py +0 -0
  23. {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport_client.py +0 -0
  24. {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport_facade.py +0 -0
  25. {statewire-0.3.0 → statewire-0.3.2}/tests/test_client.py +0 -0
  26. {statewire-0.3.0 → statewire-0.3.2}/tests/test_client_ws.py +0 -0
  27. {statewire-0.3.0 → statewire-0.3.2}/tests/test_context.py +0 -0
  28. {statewire-0.3.0 → statewire-0.3.2}/tests/test_langgraph.py +0 -0
  29. {statewire-0.3.0 → statewire-0.3.2}/tests/test_lifespan.py +0 -0
  30. {statewire-0.3.0 → statewire-0.3.2}/tests/test_meta.py +0 -0
  31. {statewire-0.3.0 → statewire-0.3.2}/tests/test_statewire_hostable.py +0 -0
  32. {statewire-0.3.0 → statewire-0.3.2}/tests/test_stream.py +0 -0
  33. {statewire-0.3.0 → statewire-0.3.2}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.3.0
3
+ Version: 0.3.2
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.3.0"
3
+ version = "0.3.2"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -36,6 +36,16 @@ body over HTTP, as a ``res`` on the stream over WS.
36
36
  Handlers that declare a keyword-only ``ctx`` parameter receive a
37
37
  ``StatewireCommandContext``: ``ctx.caller`` is the submitting client's
38
38
  handle (holdable across a long run), ``ctx.ack()`` the invocation ack.
39
+ ``authorize`` gates every ingress and returns the caller's identity
40
+ (``None`` = anonymous); the server stamps it on the client record per
41
+ attach and per admitted command submission — never client-supplied,
42
+ never on a rejected ingress — and drops it with the record. Handlers
43
+ read it as ``ctx.caller.identity``: the current session owner, live —
44
+ a newer authorized attach re-stamps it, so per-command trust decisions
45
+ read it before the first await. A client id is a within-thread session
46
+ id, not an authorization boundary: ``authorize`` must gate the thread
47
+ itself, since any authorized principal knowing a client id can attach,
48
+ receive the snapshot, and supersede that session.
39
49
 
40
50
  The host also owns a scheduler: ``self.schedule(fn)`` runs ``fn`` at the
41
51
  next drain (duplicate schedules coalesce), ``self.drain()`` drains now.
@@ -161,6 +171,7 @@ class _Client:
161
171
  self.results: dict[int, tuple[float, dict[str, Any]]] = {}
162
172
  self.inflight: dict[int, _Invocation] = {}
163
173
  self.context: dict[str, Any] | None = None
174
+ self.identity: Any = None
164
175
 
165
176
  def settle(self, seq: int, response: dict[str, Any] | None = None) -> None:
166
177
  full = {"seq": seq, **(response if response is not None else {"type": "accepted"})}
@@ -207,8 +218,9 @@ class _Client:
207
218
  class StatewireClientHandle:
208
219
  """Read-only view of the submitting client, reachable as ``ctx.caller``
209
220
  in a ``@command`` handler. Holdable across a long run: ``context`` reads
210
- the client's current stored context live, ``is_connected`` reports
211
- whether the client has an active stream attachment."""
221
+ the client's current stored context live, ``identity`` the current
222
+ session owner's identity, and ``is_connected`` reports whether the
223
+ client has an active stream attachment."""
212
224
 
213
225
  def __init__(self, host: "Statewire", client: _Client) -> None:
214
226
  self._host = host
@@ -222,6 +234,13 @@ class StatewireClientHandle:
222
234
  def context(self) -> dict[str, Any] | None:
223
235
  return self._client.context
224
236
 
237
+ @property
238
+ def identity(self) -> Any:
239
+ """Current session owner's identity — live, re-stamped by a newer
240
+ authorized attach; per-command trust decisions read it before the
241
+ handler's first await."""
242
+ return self._client.identity
243
+
225
244
  @property
226
245
  def is_connected(self) -> bool:
227
246
  client = self._client
@@ -391,11 +410,14 @@ class Statewire(PinnedAPI):
391
410
  f"{cls.__name__}.{fn.__name__} declares @route on reserved "
392
411
  f"path {spec.path!r}; it is a Statewire protocol endpoint"
393
412
  )
394
- merged = dict(cls._statewire_commands)
395
- for name, fn in vars(cls).items():
396
- method = getattr(fn, "_statewire_command_method", None)
397
- if method is not None:
398
- merged[method] = name
413
+ # Full-MRO scan so plain mixins (no Statewire base) contribute their
414
+ # @command handlers; the most derived registration of a method wins.
415
+ merged: dict[str, str] = {}
416
+ for base in reversed(cls.__mro__):
417
+ for name, fn in vars(base).items():
418
+ method = getattr(fn, "_statewire_command_method", None)
419
+ if method is not None:
420
+ merged[method] = name
399
421
  cls._statewire_commands = merged
400
422
  lifespan = cls.__dict__.get("lifespan")
401
423
  if lifespan is not None and not getattr(lifespan, "_statewire_checked", False):
@@ -589,14 +611,22 @@ class Statewire(PinnedAPI):
589
611
 
590
612
  # ─── GET /stream ────────────────────────────────────────
591
613
 
592
- async def authorize(self, request: HTTPConnection) -> None:
593
- """Auth seam: override to gate /stream, /commands and /ws; raise HTTPException to reject."""
614
+ async def authorize(self, request: HTTPConnection) -> Any:
615
+ """Auth seam: override to gate /stream, /commands and /ws; raise
616
+ HTTPException to reject. The return value is the caller's identity —
617
+ any host-chosen object, ``None`` for anonymous — stamped on the client
618
+ record at each attach and each admitted command submission (fresh
619
+ identity wins) and read by handlers via ``ctx.caller.identity``."""
620
+ return None
594
621
 
595
- def _register(self, client_id: str) -> tuple[asyncio.Queue[Any], bytes]:
622
+ def _register(
623
+ self, client_id: str, identity: Any
624
+ ) -> tuple[asyncio.Queue[Any], bytes]:
596
625
  client = self._clients.get(client_id)
597
626
  last = client.last_seq if client is not None else -1
598
627
  if client is None:
599
628
  client = self._clients[client_id] = _Client(self, client_id)
629
+ client.identity = identity
600
630
  client.touched = time.monotonic()
601
631
  self._flush()
602
632
  self._evict_streams(
@@ -650,9 +680,9 @@ class Statewire(PinnedAPI):
650
680
 
651
681
  @route.get("/stream")
652
682
  async def stream(self, request: Request) -> Response:
653
- await self.authorize(request)
683
+ identity = await self.authorize(request)
654
684
  client_id = _client_id_of(request)
655
- q, snapshot = self._register(client_id)
685
+ q, snapshot = self._register(client_id, identity)
656
686
  return StreamingResponse(
657
687
  self._stream_events(q, snapshot),
658
688
  media_type="text/event-stream",
@@ -707,7 +737,7 @@ class Statewire(PinnedAPI):
707
737
 
708
738
  @route.post("/commands")
709
739
  async def commands(self, request: Request) -> Any:
710
- await self.authorize(request)
740
+ identity = await self.authorize(request)
711
741
  client_id = _client_id_of(request)
712
742
  seq = _command_seq_of(request)
713
743
  lease = request.headers.get(LEASE_HEADER)
@@ -716,7 +746,7 @@ class Statewire(PinnedAPI):
716
746
  body = json.loads(raw)
717
747
  except (json.JSONDecodeError, UnicodeDecodeError):
718
748
  raise HTTPException(status_code=400, detail="body must be JSON") from None
719
- return await self._submit(client_id, seq, body, lease)
749
+ return await self._submit(client_id, seq, body, lease, identity)
720
750
 
721
751
  def _validate_submission(
722
752
  self, body: Any
@@ -785,7 +815,12 @@ class Statewire(PinnedAPI):
785
815
  return _Prepared(method, handler, params, wants_ctx), None
786
816
 
787
817
  async def _admit(
788
- self, client_id: str, seq: int, body: Any, lease: str | None
818
+ self,
819
+ client_id: str,
820
+ seq: int,
821
+ body: Any,
822
+ lease: str | None,
823
+ identity: Any = _UNSET,
789
824
  ) -> tuple[str, Any]:
790
825
  client = self._clients.get(client_id)
791
826
  if client is None:
@@ -800,6 +835,10 @@ class Statewire(PinnedAPI):
800
835
  context, members, rejections = self._validate_submission(body)
801
836
  if rejections:
802
837
  return "rejected", rejections
838
+ # Only an admitted submission may re-stamp; _UNSET keeps the
839
+ # attach-time identity (WS frames, facade dispatch).
840
+ if identity is not _UNSET:
841
+ client.identity = identity
803
842
  if context is not _UNSET:
804
843
  client.context = context
805
844
  invocations = self._start_batch(client, seq, members)
@@ -811,9 +850,9 @@ class Statewire(PinnedAPI):
811
850
  return "executed", None
812
851
 
813
852
  async def _submit(
814
- self, client_id: str, seq: int, body: Any, lease: str | None
853
+ self, client_id: str, seq: int, body: Any, lease: str | None, identity: Any
815
854
  ) -> Any:
816
- outcome, detail = await self._admit(client_id, seq, body, lease)
855
+ outcome, detail = await self._admit(client_id, seq, body, lease, identity)
817
856
  match outcome:
818
857
  case "unknown-client":
819
858
  return JSONResponse({"error": "unknown-client"}, status_code=412)
@@ -967,7 +1006,7 @@ class Statewire(PinnedAPI):
967
1006
  @route.websocket("/ws")
968
1007
  async def ws(self, websocket: WebSocket) -> None:
969
1008
  try:
970
- await self.authorize(websocket)
1009
+ identity = await self.authorize(websocket)
971
1010
  except HTTPException as exc:
972
1011
  reason = exc.detail if isinstance(exc.detail, str) else "unauthorized"
973
1012
  raise WebSocketException(code=1008, reason=reason) from None
@@ -979,7 +1018,7 @@ class Statewire(PinnedAPI):
979
1018
  await websocket.accept(
980
1019
  subprotocol=ops.WS_SUBPROTOCOL if ops.WS_SUBPROTOCOL in offered else None
981
1020
  )
982
- q, snapshot = self._register(client_id)
1021
+ q, snapshot = self._register(client_id, identity)
983
1022
  lease = self._clients[client_id].lease
984
1023
  try:
985
1024
  await self._ws_pump(websocket, q, client_id, snapshot, lease)
@@ -12,7 +12,28 @@ context, readable via ``ctx.caller`` in handlers that declare a keyword-only
12
12
  ``ctx`` parameter. Unknown fields below the top level are ignored. All
13
13
  commands ``{"type": <name>, ...}`` are submitted as one batch through the
14
14
  normal Statewire admission path to the ``@command`` handlers registered under
15
- those names, each with the whole command object as the single param. The response streams
15
+ those names, each with the whole command object as the single param — except
16
+ the legacy commands, which are normalized to the canonical ``run/*`` dialect
17
+ (a host-registered handler of the same legacy name always wins and receives
18
+ the command whole):
19
+
20
+ - ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>}``:
21
+ a fresh ``legacy_`` id is synthesized, image parts become
22
+ ``{"type": "file", "mediaType": "image/*", "url"}`` parts, and command-level
23
+ anchors are dropped. When the body carries a top-level ``parentId`` and
24
+ ``get_message_child_id`` resolves it to a child, the command becomes
25
+ ``run/edit`` with ``{"sourceId": <child>, "message": <wire message>}``.
26
+ - ``add-tool-result`` becomes ``run/input`` with ``{"requestId": <resolved>,
27
+ "response": {"output": <result>, "isError"?, "artifact"?}}``; the requestId
28
+ comes from ``get_input_request_id(toolCallId)``, whose default peeks the
29
+ unanswered tool-call request in ``state["inputRequests"]`` (None rejects),
30
+ and a ``modelContent`` field rejects (unsupported in ``run/input``).
31
+ - A body with a ``parentId`` and no commands is the legacy reload: it becomes
32
+ ``run/reload`` with ``{"sourceId": <child>}`` via ``get_message_child_id``
33
+ (None rejects).
34
+
35
+ A host without the target ``run/*`` handler rejects the batch via the
36
+ ordinary unknown-command path. The response streams
16
37
  state changes as legacy frames until every submitted command settles, then
17
38
  EOF; a rejection or crash becomes an error frame followed by EOF. Client
18
39
  disconnect before EOF invokes ``on_assistant_transport_disconnect`` (a plain
@@ -26,9 +47,11 @@ response is 200 with an empty body and ``X-Stream-Status: not_found``. If the
26
47
  run already completed, the replay carries ``X-Stream-Status: completed``. A
27
48
  resume disconnect never invokes the cancel hook.
28
49
 
29
- ``POST /assistant-transport/api/cancel`` invokes the same disconnect hook and
30
- responds ``{"success": true, "found": <bool>}``; ``found`` is true when a live
31
- run existed at request time.
50
+ ``POST /assistant-transport/api/cancel`` invokes the same disconnect hook,
51
+ dispatches the host's registered ``run/stop`` handler (if any; a rejection —
52
+ e.g. wrong-state while idle — is swallowed), and responds
53
+ ``{"success": true, "found": <bool>}``; ``found`` is true when a live run
54
+ existed at request time.
32
55
 
33
56
  ``POST /assistant-transport/api/status`` responds 200 always:
34
57
  ``{"isRunning": true, "status": "running"}`` while a run is active,
@@ -52,6 +75,7 @@ import json
52
75
  import secrets
53
76
  import time
54
77
  from typing import Any, AsyncIterator
78
+ from uuid import uuid4
55
79
 
56
80
  import httpx
57
81
  from fastapi import FastAPI, HTTPException, Request
@@ -60,7 +84,7 @@ from starlette.background import BackgroundTask
60
84
  from starlette.responses import Response, StreamingResponse
61
85
 
62
86
  from .api import _LAGGARD, Statewire
63
- from .state import _frozen
87
+ from .state import _frozen, plain
64
88
 
65
89
  _STREAM_HEADERS = {
66
90
  "Cache-Control": "no-cache, no-store, no-transform",
@@ -162,6 +186,28 @@ class _RunError(Exception):
162
186
  pass
163
187
 
164
188
 
189
+ def _legacy_wire_message(message: Any) -> dict[str, Any]:
190
+ if not isinstance(message, dict):
191
+ raise _RunError("add-message: message must be an object")
192
+ parts = message.get("parts")
193
+ if not isinstance(parts, list):
194
+ raise _RunError("add-message: message.parts must be an array")
195
+ converted = []
196
+ for part in parts:
197
+ if isinstance(part, dict) and part.get("type") == "image":
198
+ image = part.get("image")
199
+ if not isinstance(image, str):
200
+ raise _RunError("add-message: image part requires a string image field")
201
+ converted.append({"type": "file", "mediaType": "image/*", "url": image})
202
+ else:
203
+ converted.append(part)
204
+ return {
205
+ "id": f"legacy_{uuid4().hex}",
206
+ "role": message.get("role"),
207
+ "parts": converted,
208
+ }
209
+
210
+
165
211
  _Item = tuple[str, Any]
166
212
 
167
213
 
@@ -193,9 +239,38 @@ class AssistantTransport(Statewire):
193
239
  async def on_assistant_transport_disconnect(self) -> None:
194
240
  """The legacy client went away or asked to stop; treat as a cancel signal."""
195
241
 
242
+ async def get_input_request_id(self, tool_call_id: str) -> str | None:
243
+ """Resolve a legacy toolCallId to the pending run/input requestId.
244
+
245
+ The default peeks the RunManager root-state mount:
246
+ ``state["inputRequests"]`` holds ``{..., "response": null}`` entries;
247
+ the unanswered tool-call request matching ``tool_call_id`` wins. None
248
+ rejects the legacy ``add-tool-result`` command."""
249
+ state = plain(self.state)
250
+ requests = state.get("inputRequests") if isinstance(state, dict) else None
251
+ if not isinstance(requests, list):
252
+ return None
253
+ for request in requests:
254
+ if (
255
+ isinstance(request, dict)
256
+ and request.get("type") == "tool-call"
257
+ and request.get("toolCallId") == tool_call_id
258
+ and request.get("response") is None
259
+ ):
260
+ return request.get("id")
261
+ return None
262
+
263
+ async def get_message_child_id(self, parent_id: str) -> str | None:
264
+ """Resolve a message id to its child message id.
265
+
266
+ Anchors the legacy reload (``run/reload``) and edit (``run/edit``)
267
+ translations. None (the default) rejects a legacy reload and sends
268
+ ``add-message`` as ``run/steer``."""
269
+ return None
270
+
196
271
  @route.post("/assistant-transport/api/chat")
197
272
  async def assistant_transport_chat(self, request: Request) -> Response:
198
- await self.authorize(request)
273
+ identity = await self.authorize(request)
199
274
  fmt = _FORMATS[self.assistant_transport_protocol]
200
275
  raw = await request.body()
201
276
  try:
@@ -224,14 +299,19 @@ class AssistantTransport(Statewire):
224
299
  status_code=400, detail=f"invalid context: {exc}"
225
300
  ) from None
226
301
  client_id = "at-" + secrets.token_urlsafe(9)
227
- q, snapshot = self._register(client_id)
302
+ q, snapshot = self._register(client_id, identity)
228
303
  self._clients[client_id].context = context
229
304
  lease = self._clients[client_id].lease
230
305
  assert lease is not None
306
+ parent_id = body.get("parentId")
307
+ if not isinstance(parent_id, str):
308
+ parent_id = None
231
309
  run = _Run(json.loads(snapshot)["ops"][0]["value"])
232
310
  self._at_run = run
233
311
  self.create_task(
234
- self._assistant_transport_run(run, q, client_id, lease, commands)
312
+ self._assistant_transport_run(
313
+ run, q, client_id, lease, commands, parent_id
314
+ )
235
315
  )
236
316
  return StreamingResponse(
237
317
  self._assistant_transport_attach(fmt, run, initial=True),
@@ -272,11 +352,13 @@ class AssistantTransport(Statewire):
272
352
 
273
353
  @route.post("/assistant-transport/api/cancel")
274
354
  async def assistant_transport_cancel(self, request: Request) -> dict[str, Any]:
275
- await self.authorize(request)
355
+ identity = await self.authorize(request)
276
356
  run = self._at_run
277
357
  found = run is not None and not run.done
278
358
  if found:
279
359
  await self.on_assistant_transport_disconnect()
360
+ if "run/stop" in type(self)._statewire_commands:
361
+ await self._assistant_transport_dispatch("run/stop", identity)
280
362
  return {"success": True, "found": found}
281
363
 
282
364
  @route.post("/assistant-transport/api/status")
@@ -299,16 +381,22 @@ class AssistantTransport(Statewire):
299
381
  }
300
382
 
301
383
  async def _assistant_transport_submit(
302
- self, client_id: str, lease: str, commands: list[dict[str, Any]]
384
+ self,
385
+ client_id: str,
386
+ lease: str,
387
+ commands: list[dict[str, Any]],
388
+ parent_id: str | None,
303
389
  ) -> None:
304
390
  if not commands:
305
- return
306
- body = {
307
- "commands": [
308
- {"method": command["type"], "params": [command]}
391
+ if parent_id is None:
392
+ return
393
+ members = [await self._legacy_reload(parent_id)]
394
+ else:
395
+ members = [
396
+ await self._assistant_transport_member(command, parent_id)
309
397
  for command in commands
310
398
  ]
311
- }
399
+ body = {"commands": members}
312
400
  outcome, detail = await self._admit(client_id, 1, body, lease)
313
401
  if outcome == "rejected":
314
402
  raise _RunError(
@@ -320,6 +408,67 @@ class AssistantTransport(Statewire):
320
408
  if outcome != "executed":
321
409
  raise _RunError(f"command submission failed: {outcome}")
322
410
 
411
+ async def _assistant_transport_member(
412
+ self, command: dict[str, Any], parent_id: str | None
413
+ ) -> dict[str, Any]:
414
+ kind = command["type"]
415
+ registered = type(self)._statewire_commands
416
+ if kind == "add-message" and "add-message" not in registered:
417
+ message = _legacy_wire_message(command.get("message"))
418
+ source_id = (
419
+ await self.get_message_child_id(parent_id)
420
+ if parent_id is not None
421
+ else None
422
+ )
423
+ if source_id is not None:
424
+ return {
425
+ "method": "run/edit",
426
+ "params": [{"sourceId": source_id, "message": message}],
427
+ }
428
+ return {"method": "run/steer", "params": [{"message": message}]}
429
+ if kind == "add-tool-result" and "add-tool-result" not in registered:
430
+ return {
431
+ "method": "run/input",
432
+ "params": [await self._legacy_input(command)],
433
+ }
434
+ return {"method": kind, "params": [command]}
435
+
436
+ async def _legacy_input(self, command: dict[str, Any]) -> dict[str, Any]:
437
+ if command.get("modelContent") is not None:
438
+ raise _RunError("add-tool-result: modelContent is not supported")
439
+ tool_call_id = command.get("toolCallId")
440
+ if not isinstance(tool_call_id, str):
441
+ raise _RunError("add-tool-result: toolCallId must be a string")
442
+ request_id = await self.get_input_request_id(tool_call_id)
443
+ if request_id is None:
444
+ raise _RunError(
445
+ f"add-tool-result: no input request for toolCallId {tool_call_id!r}"
446
+ )
447
+ response: dict[str, Any] = {"output": command.get("result")}
448
+ if command.get("isError"):
449
+ response["isError"] = True
450
+ if "artifact" in command:
451
+ response["artifact"] = command["artifact"]
452
+ return {"requestId": request_id, "response": response}
453
+
454
+ async def _legacy_reload(self, parent_id: str) -> dict[str, Any]:
455
+ source_id = await self.get_message_child_id(parent_id)
456
+ if source_id is None:
457
+ raise _RunError(f"reload: no child message for parentId {parent_id!r}")
458
+ return {"method": "run/reload", "params": [{"sourceId": source_id}]}
459
+
460
+ async def _assistant_transport_dispatch(self, method: str, identity: Any) -> None:
461
+ client_id = "at-" + secrets.token_urlsafe(9)
462
+ q, _ = self._register(client_id, identity)
463
+ try:
464
+ lease = self._clients[client_id].lease
465
+ assert lease is not None
466
+ await self._admit(
467
+ client_id, 1, {"commands": [{"method": method, "params": []}]}, lease
468
+ )
469
+ finally:
470
+ self._subscribers.pop(q, None)
471
+
323
472
  async def _assistant_transport_run(
324
473
  self,
325
474
  run: _Run,
@@ -327,13 +476,14 @@ class AssistantTransport(Statewire):
327
476
  client_id: str,
328
477
  lease: str,
329
478
  commands: list[dict[str, Any]],
479
+ parent_id: str | None,
330
480
  ) -> None:
331
481
  submit = asyncio.create_task(
332
- self._assistant_transport_submit(client_id, lease, commands)
482
+ self._assistant_transport_submit(client_id, lease, commands, parent_id)
333
483
  )
334
484
  q_get: asyncio.Task[Any] = asyncio.create_task(q.get())
335
485
  try:
336
- total = len(commands)
486
+ total = len(commands) or (1 if parent_id is not None else 0)
337
487
  acked = 0
338
488
  open_pending: set[int] = set()
339
489
  try: