statewire 0.3.0__tar.gz → 0.3.1__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 (32) hide show
  1. {statewire-0.3.0 → statewire-0.3.1}/PKG-INFO +1 -1
  2. {statewire-0.3.0 → statewire-0.3.1}/pyproject.toml +1 -1
  3. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/api.py +8 -5
  4. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/assistant_transport.py +164 -14
  5. {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport.py +420 -0
  6. {statewire-0.3.0 → statewire-0.3.1}/tests/test_commands.py +19 -0
  7. {statewire-0.3.0 → statewire-0.3.1}/.gitignore +0 -0
  8. {statewire-0.3.0 → statewire-0.3.1}/README.md +0 -0
  9. {statewire-0.3.0 → statewire-0.3.1}/examples/__init__.py +0 -0
  10. {statewire-0.3.0 → statewire-0.3.1}/examples/demo_app.py +0 -0
  11. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/__init__.py +0 -0
  12. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/assistant_transport_client.py +0 -0
  13. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/client.py +0 -0
  14. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/langgraph.py +0 -0
  15. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/ops.py +0 -0
  16. {statewire-0.3.0 → statewire-0.3.1}/src/statewire/state.py +0 -0
  17. {statewire-0.3.0 → statewire-0.3.1}/tests/client_helpers.py +0 -0
  18. {statewire-0.3.0 → statewire-0.3.1}/tests/statewire_helpers.py +0 -0
  19. {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport_client.py +0 -0
  20. {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport_facade.py +0 -0
  21. {statewire-0.3.0 → statewire-0.3.1}/tests/test_authorize.py +0 -0
  22. {statewire-0.3.0 → statewire-0.3.1}/tests/test_client.py +0 -0
  23. {statewire-0.3.0 → statewire-0.3.1}/tests/test_client_ws.py +0 -0
  24. {statewire-0.3.0 → statewire-0.3.1}/tests/test_context.py +0 -0
  25. {statewire-0.3.0 → statewire-0.3.1}/tests/test_langgraph.py +0 -0
  26. {statewire-0.3.0 → statewire-0.3.1}/tests/test_lifespan.py +0 -0
  27. {statewire-0.3.0 → statewire-0.3.1}/tests/test_meta.py +0 -0
  28. {statewire-0.3.0 → statewire-0.3.1}/tests/test_state_proxy.py +0 -0
  29. {statewire-0.3.0 → statewire-0.3.1}/tests/test_statewire_hostable.py +0 -0
  30. {statewire-0.3.0 → statewire-0.3.1}/tests/test_stream.py +0 -0
  31. {statewire-0.3.0 → statewire-0.3.1}/tests/test_writer_lease.py +0 -0
  32. {statewire-0.3.0 → statewire-0.3.1}/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.1
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.1"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -391,11 +391,14 @@ class Statewire(PinnedAPI):
391
391
  f"{cls.__name__}.{fn.__name__} declares @route on reserved "
392
392
  f"path {spec.path!r}; it is a Statewire protocol endpoint"
393
393
  )
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
394
+ # Full-MRO scan so plain mixins (no Statewire base) contribute their
395
+ # @command handlers; the most derived registration of a method wins.
396
+ merged: dict[str, str] = {}
397
+ for base in reversed(cls.__mro__):
398
+ for name, fn in vars(base).items():
399
+ method = getattr(fn, "_statewire_command_method", None)
400
+ if method is not None:
401
+ merged[method] = name
399
402
  cls._statewire_commands = merged
400
403
  lifespan = cls.__dict__.get("lifespan")
401
404
  if lifespan is not None and not getattr(lifespan, "_statewire_checked", False):
@@ -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,6 +239,35 @@ 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
273
  await self.authorize(request)
@@ -228,10 +303,15 @@ class AssistantTransport(Statewire):
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),
@@ -277,6 +357,8 @@ class AssistantTransport(Statewire):
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")
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) -> None:
461
+ client_id = "at-" + secrets.token_urlsafe(9)
462
+ q, _ = self._register(client_id)
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:
@@ -595,3 +595,423 @@ async def test_status_reports_not_found_running_and_completed():
595
595
  body = (await client.post(status_url, json={"threadId": "t1"})).json()
596
596
  assert body["isRunning"] is False and body["status"] == "completed"
597
597
  assert isinstance(body["completedAt"], int)
598
+
599
+
600
+ class RunHarness(AssistantTransport):
601
+ """Host speaking the canonical run/* dialect; no legacy handlers."""
602
+
603
+ def __init__(self, ctx):
604
+ super().__init__(ctx)
605
+ self.steered = []
606
+ self.edited = []
607
+ self.inputs = []
608
+ self.reloaded = []
609
+ self.stopped = 0
610
+ self.cancelled = 0
611
+
612
+ async def lifespan(self):
613
+ self.state = {"messages": []}
614
+ yield
615
+
616
+ @command("run/steer")
617
+ async def run_steer(self, params, *, ctx):
618
+ self.steered.append((params, ctx.caller.client_id))
619
+ self.state["messages"].append(params["message"])
620
+
621
+ @command("run/edit")
622
+ async def run_edit(self, params):
623
+ self.edited.append(params)
624
+
625
+ @command("run/input")
626
+ async def run_input(self, params):
627
+ self.inputs.append(params)
628
+
629
+ @command("run/reload")
630
+ async def run_reload(self, params):
631
+ self.reloaded.append(params)
632
+
633
+ @command("run/stop")
634
+ async def run_stop(self, params=None, *, ctx):
635
+ self.stopped += 1
636
+
637
+ async def on_assistant_transport_disconnect(self):
638
+ self.cancelled += 1
639
+
640
+
641
+ class AnchoredHarness(RunHarness):
642
+ async def get_message_child_id(self, parent_id):
643
+ return {"m1": "m2"}.get(parent_id)
644
+
645
+
646
+ class InputHarness(RunHarness):
647
+ async def lifespan(self):
648
+ self.state = {
649
+ "messages": [],
650
+ "inputRequests": [
651
+ {
652
+ "type": "tool-call",
653
+ "id": "req-old",
654
+ "toolCallId": "call-1",
655
+ "response": {"output": "done"},
656
+ },
657
+ {
658
+ "type": "tool-call",
659
+ "id": "req-1",
660
+ "toolCallId": "call-1",
661
+ "response": None,
662
+ },
663
+ {
664
+ "type": "tool-approval",
665
+ "id": "req-2",
666
+ "toolCallId": "call-2",
667
+ "response": None,
668
+ },
669
+ ],
670
+ }
671
+ yield
672
+
673
+
674
+ async def test_add_message_is_normalized_to_run_steer():
675
+ async with statewire_client(RunHarness) as (app, client):
676
+ response = await post_run(
677
+ client,
678
+ {
679
+ "commands": [
680
+ {
681
+ "type": "add-message",
682
+ "message": {
683
+ "role": "user",
684
+ "parts": [
685
+ {"type": "text", "text": "hi"},
686
+ {"type": "image", "image": "data:image/png;base64,xx"},
687
+ ],
688
+ },
689
+ "parentId": "m0",
690
+ "sourceId": None,
691
+ }
692
+ ],
693
+ "state": {},
694
+ "threadId": "t1",
695
+ },
696
+ )
697
+ assert response.status_code == 200
698
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
699
+ [(params, caller_id)] = instance.steered
700
+ assert set(params) == {"message"}
701
+ message = params["message"]
702
+ assert message["id"].startswith("legacy_")
703
+ assert message["role"] == "user"
704
+ assert message["parts"] == [
705
+ {"type": "text", "text": "hi"},
706
+ {
707
+ "type": "file",
708
+ "mediaType": "image/*",
709
+ "url": "data:image/png;base64,xx",
710
+ },
711
+ ]
712
+ assert caller_id.startswith("at-")
713
+ frames = data_stream_frames(response.text)
714
+ assert all(prefix == "aui-state" for prefix, _ in frames)
715
+
716
+
717
+ async def test_add_message_prefers_a_host_registered_handler():
718
+ class LegacyHarness(Harness):
719
+ @command("add-message")
720
+ async def add_message_raw(self, cmd):
721
+ self.seen.append(cmd)
722
+
723
+ async with statewire_client(LegacyHarness) as (app, client):
724
+ command_body = {
725
+ "type": "add-message",
726
+ "message": {"role": "user", "parts": [{"type": "text", "text": "hi"}]},
727
+ }
728
+ response = await post_run(client, {"commands": [command_body], "state": {}})
729
+ assert response.status_code == 200
730
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
731
+ assert instance.seen == [command_body]
732
+
733
+
734
+ async def test_add_message_without_run_steer_is_an_error_frame():
735
+ async with statewire_client(Harness) as (app, client):
736
+ response = await post_run(
737
+ client,
738
+ {
739
+ "commands": [
740
+ {
741
+ "type": "add-message",
742
+ "message": {"role": "user", "parts": []},
743
+ }
744
+ ],
745
+ "state": {},
746
+ },
747
+ )
748
+ assert response.status_code == 200
749
+ frames = data_stream_frames(response.text)
750
+ assert frames[-1][0] == "3"
751
+ assert "unknown method: 'run/steer'" in frames[-1][1]
752
+
753
+
754
+ async def test_malformed_add_message_is_an_error_frame():
755
+ async with statewire_client(RunHarness) as (app, client):
756
+ response = await post_run(
757
+ client,
758
+ {"commands": [{"type": "add-message", "message": "hi"}], "state": {}},
759
+ )
760
+ assert response.status_code == 200
761
+ frames = data_stream_frames(response.text)
762
+ assert frames[-1] == ("3", "add-message: message must be an object")
763
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
764
+ assert instance.steered == []
765
+
766
+
767
+ async def test_image_part_without_image_field_is_an_error_frame():
768
+ async with statewire_client(RunHarness) as (app, client):
769
+ response = await post_run(
770
+ client,
771
+ {
772
+ "commands": [
773
+ {
774
+ "type": "add-message",
775
+ "message": {"role": "user", "parts": [{"type": "image"}]},
776
+ }
777
+ ],
778
+ "state": {},
779
+ },
780
+ )
781
+ assert response.status_code == 200
782
+ frames = data_stream_frames(response.text)
783
+ assert frames[-1] == (
784
+ "3",
785
+ "add-message: image part requires a string image field",
786
+ )
787
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
788
+ assert instance.steered == []
789
+
790
+
791
+ async def test_cancel_dispatches_run_stop_when_registered():
792
+ async with statewire_client(RunHarness) as (app, client):
793
+ response = await client.post(
794
+ "/threads/t1/assistant-transport/api/cancel", json={"threadId": "t1"}
795
+ )
796
+ assert response.status_code == 200
797
+ assert response.json() == {"success": True, "found": False}
798
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
799
+ assert instance.stopped == 1
800
+ assert instance.cancelled == 0
801
+
802
+
803
+ async def test_cancel_swallows_a_run_stop_rejection():
804
+ class RejectingStop(RunHarness):
805
+ @command("run/stop")
806
+ async def run_stop(self, params=None, *, ctx):
807
+ raise StatewireReject("wrong-state")
808
+
809
+ async with statewire_client(RejectingStop) as (app, client):
810
+ response = await client.post(
811
+ "/threads/t1/assistant-transport/api/cancel", json={"threadId": "t1"}
812
+ )
813
+ assert response.status_code == 200
814
+ assert response.json() == {"success": True, "found": False}
815
+
816
+
817
+ async def test_add_tool_result_is_normalized_to_run_input():
818
+ async with statewire_client(InputHarness) as (app, client):
819
+ response = await post_run(
820
+ client,
821
+ {
822
+ "commands": [
823
+ {
824
+ "type": "add-tool-result",
825
+ "toolCallId": "call-1",
826
+ "toolName": "search",
827
+ "result": {"ok": True},
828
+ "isError": True,
829
+ "artifact": {"trace": [1, 2]},
830
+ }
831
+ ],
832
+ "state": {},
833
+ },
834
+ )
835
+ assert response.status_code == 200
836
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
837
+ assert instance.inputs == [
838
+ {
839
+ "requestId": "req-1",
840
+ "response": {
841
+ "output": {"ok": True},
842
+ "isError": True,
843
+ "artifact": {"trace": [1, 2]},
844
+ },
845
+ }
846
+ ]
847
+
848
+ response = await post_run(
849
+ client,
850
+ {
851
+ "commands": [
852
+ {
853
+ "type": "add-tool-result",
854
+ "toolCallId": "call-1",
855
+ "result": "plain",
856
+ "isError": False,
857
+ }
858
+ ],
859
+ "state": {},
860
+ },
861
+ )
862
+ assert response.status_code == 200
863
+ assert instance.inputs[1] == {
864
+ "requestId": "req-1",
865
+ "response": {"output": "plain"},
866
+ }
867
+
868
+
869
+ async def test_default_request_id_resolution_skips_answered_and_non_tool_requests():
870
+ async with statewire_client(InputHarness) as (app, client):
871
+ response = await post_run(
872
+ client,
873
+ {
874
+ "commands": [
875
+ {"type": "add-tool-result", "toolCallId": "call-2", "result": 1}
876
+ ],
877
+ "state": {},
878
+ },
879
+ )
880
+ frames = data_stream_frames(response.text)
881
+ assert frames[-1] == (
882
+ "3",
883
+ "add-tool-result: no input request for toolCallId 'call-2'",
884
+ )
885
+
886
+
887
+ async def test_add_tool_result_without_input_requests_state_is_an_error_frame():
888
+ async with statewire_client(RunHarness) as (app, client):
889
+ response = await post_run(
890
+ client,
891
+ {
892
+ "commands": [
893
+ {"type": "add-tool-result", "toolCallId": "call-1", "result": 1}
894
+ ],
895
+ "state": {},
896
+ },
897
+ )
898
+ frames = data_stream_frames(response.text)
899
+ assert frames[-1] == (
900
+ "3",
901
+ "add-tool-result: no input request for toolCallId 'call-1'",
902
+ )
903
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
904
+ assert instance.inputs == []
905
+
906
+
907
+ async def test_add_tool_result_with_model_content_is_rejected():
908
+ async with statewire_client(InputHarness) as (app, client):
909
+ response = await post_run(
910
+ client,
911
+ {
912
+ "commands": [
913
+ {
914
+ "type": "add-tool-result",
915
+ "toolCallId": "call-1",
916
+ "result": 1,
917
+ "modelContent": [{"type": "text", "text": "x"}],
918
+ }
919
+ ],
920
+ "state": {},
921
+ },
922
+ )
923
+ frames = data_stream_frames(response.text)
924
+ assert frames[-1] == ("3", "add-tool-result: modelContent is not supported")
925
+
926
+
927
+ async def test_add_tool_result_prefers_a_host_registered_handler():
928
+ class LegacyToolHarness(Harness):
929
+ @command("add-tool-result")
930
+ async def add_tool_result_raw(self, cmd):
931
+ self.seen.append(cmd)
932
+
933
+ async with statewire_client(LegacyToolHarness) as (app, client):
934
+ command_body = {
935
+ "type": "add-tool-result",
936
+ "toolCallId": "call-1",
937
+ "result": {"ok": True},
938
+ }
939
+ response = await post_run(client, {"commands": [command_body], "state": {}})
940
+ assert response.status_code == 200
941
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
942
+ assert instance.seen == [command_body]
943
+
944
+
945
+ async def test_parent_id_without_commands_is_normalized_to_run_reload():
946
+ async with statewire_client(AnchoredHarness) as (app, client):
947
+ response = await post_run(
948
+ client, {"commands": [], "state": {}, "parentId": "m1"}
949
+ )
950
+ assert response.status_code == 200
951
+ frames = data_stream_frames(response.text)
952
+ assert all(prefix == "aui-state" for prefix, _ in frames)
953
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
954
+ assert instance.reloaded == [{"sourceId": "m2"}]
955
+
956
+
957
+ async def test_reload_without_a_resolvable_child_is_an_error_frame():
958
+ async with statewire_client(RunHarness) as (app, client):
959
+ response = await post_run(
960
+ client, {"commands": [], "state": {}, "parentId": "m1"}
961
+ )
962
+ frames = data_stream_frames(response.text)
963
+ assert frames[-1] == ("3", "reload: no child message for parentId 'm1'")
964
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
965
+ assert instance.reloaded == []
966
+
967
+
968
+ async def test_add_message_with_a_resolvable_parent_is_normalized_to_run_edit():
969
+ async with statewire_client(AnchoredHarness) as (app, client):
970
+ response = await post_run(
971
+ client,
972
+ {
973
+ "commands": [
974
+ {
975
+ "type": "add-message",
976
+ "message": {
977
+ "role": "user",
978
+ "parts": [{"type": "text", "text": "again"}],
979
+ },
980
+ }
981
+ ],
982
+ "state": {},
983
+ "parentId": "m1",
984
+ },
985
+ )
986
+ assert response.status_code == 200
987
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
988
+ assert instance.steered == []
989
+ [params] = instance.edited
990
+ assert params["sourceId"] == "m2"
991
+ assert params["message"]["id"].startswith("legacy_")
992
+ assert params["message"]["parts"] == [{"type": "text", "text": "again"}]
993
+
994
+
995
+ async def test_add_message_with_an_unresolvable_parent_falls_back_to_run_steer():
996
+ async with statewire_client(AnchoredHarness) as (app, client):
997
+ response = await post_run(
998
+ client,
999
+ {
1000
+ "commands": [
1001
+ {
1002
+ "type": "add-message",
1003
+ "message": {
1004
+ "role": "user",
1005
+ "parts": [{"type": "text", "text": "hi"}],
1006
+ },
1007
+ }
1008
+ ],
1009
+ "state": {},
1010
+ "parentId": "unknown",
1011
+ },
1012
+ )
1013
+ assert response.status_code == 200
1014
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
1015
+ assert instance.edited == []
1016
+ [(params, _)] = instance.steered
1017
+ assert set(params) == {"message"}
@@ -551,6 +551,25 @@ async def test_duplicate_while_original_executing_does_not_rerun():
551
551
  assert runs == ["go"]
552
552
 
553
553
 
554
+ async def test_plain_mixin_commands_register_on_the_subclass():
555
+ class Mixin:
556
+ @command("mixin/inc")
557
+ async def mixin_inc(self, by: int = 1):
558
+ self.state["count"] += by
559
+
560
+ class Host(Mixin, make_counter()):
561
+ pass
562
+
563
+ assert Host._statewire_commands["mixin/inc"] == "mixin_inc"
564
+ assert Host._statewire_commands["inc"] == "inc"
565
+ async with statewire_client(Host) as (app, client):
566
+ await attach(app)
567
+ await post_command(client, {"method": "mixin/inc", "params": [4]}, seq=1)
568
+ async with stream_of(app) as stream:
569
+ snapshot = await stream.next_event()
570
+ assert snapshot["ops"][0]["value"]["count"] == 4
571
+
572
+
554
573
  async def test_subclass_override_without_redecorating_dispatches_the_override():
555
574
  base = make_counter()
556
575
 
File without changes
File without changes
File without changes
File without changes