statewire 0.4.0__tar.gz → 0.4.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 (34) hide show
  1. {statewire-0.4.0 → statewire-0.4.2}/PKG-INFO +1 -1
  2. {statewire-0.4.0 → statewire-0.4.2}/pyproject.toml +1 -1
  3. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/__init__.py +2 -0
  4. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/assistant_transport.py +16 -76
  5. statewire-0.4.2/src/statewire/legacy.py +107 -0
  6. {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport.py +1 -2
  7. statewire-0.4.2/tests/test_legacy.py +108 -0
  8. {statewire-0.4.0 → statewire-0.4.2}/.gitignore +0 -0
  9. {statewire-0.4.0 → statewire-0.4.2}/README.md +0 -0
  10. {statewire-0.4.0 → statewire-0.4.2}/examples/__init__.py +0 -0
  11. {statewire-0.4.0 → statewire-0.4.2}/examples/demo_app.py +0 -0
  12. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/api.py +0 -0
  13. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/assistant_transport_client.py +0 -0
  14. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/client.py +0 -0
  15. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/langgraph.py +0 -0
  16. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/ops.py +0 -0
  17. {statewire-0.4.0 → statewire-0.4.2}/src/statewire/state.py +0 -0
  18. {statewire-0.4.0 → statewire-0.4.2}/tests/client_helpers.py +0 -0
  19. {statewire-0.4.0 → statewire-0.4.2}/tests/statewire_helpers.py +0 -0
  20. {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport_client.py +0 -0
  21. {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport_facade.py +0 -0
  22. {statewire-0.4.0 → statewire-0.4.2}/tests/test_authorize.py +0 -0
  23. {statewire-0.4.0 → statewire-0.4.2}/tests/test_client.py +0 -0
  24. {statewire-0.4.0 → statewire-0.4.2}/tests/test_client_ws.py +0 -0
  25. {statewire-0.4.0 → statewire-0.4.2}/tests/test_commands.py +0 -0
  26. {statewire-0.4.0 → statewire-0.4.2}/tests/test_context.py +0 -0
  27. {statewire-0.4.0 → statewire-0.4.2}/tests/test_langgraph.py +0 -0
  28. {statewire-0.4.0 → statewire-0.4.2}/tests/test_lifespan.py +0 -0
  29. {statewire-0.4.0 → statewire-0.4.2}/tests/test_meta.py +0 -0
  30. {statewire-0.4.0 → statewire-0.4.2}/tests/test_state_proxy.py +0 -0
  31. {statewire-0.4.0 → statewire-0.4.2}/tests/test_statewire_hostable.py +0 -0
  32. {statewire-0.4.0 → statewire-0.4.2}/tests/test_stream.py +0 -0
  33. {statewire-0.4.0 → statewire-0.4.2}/tests/test_writer_lease.py +0 -0
  34. {statewire-0.4.0 → statewire-0.4.2}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.4.0
3
+ Version: 0.4.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.4.0"
3
+ version = "0.4.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"
@@ -6,12 +6,14 @@ from .api import (
6
6
  command,
7
7
  )
8
8
  from .assistant_transport import AssistantTransport, add_assistant_transport_facade
9
+ from .legacy import upgrade_legacy_commands
9
10
  from .state import StateProxy, plain
10
11
 
11
12
  __all__ = [
12
13
  "Statewire",
13
14
  "AssistantTransport",
14
15
  "add_assistant_transport_facade",
16
+ "upgrade_legacy_commands",
15
17
  "command",
16
18
  "StatewireClientHandle",
17
19
  "StatewireCommandContext",
@@ -32,8 +32,8 @@ the command whole):
32
32
  unanswered tool-call request in ``state["inputRequests"]`` (None rejects),
33
33
  and a ``modelContent`` field rejects (unsupported in ``run/input``).
34
34
  - A body with a ``parentId`` and no commands is the legacy reload: it becomes
35
- ``run/reload`` with ``{"sourceId": <child>, "messageId": <fresh legacy_
36
- id>}`` via ``get_message_child_id`` (None rejects).
35
+ ``run/reload`` with ``{"sourceId": <child>}`` via ``get_message_child_id``
36
+ (None rejects).
37
37
 
38
38
  A host without the target ``run/*`` handler rejects the batch via the
39
39
  ordinary unknown-command path. The response streams state changes as legacy
@@ -81,7 +81,6 @@ import json
81
81
  import secrets
82
82
  import time
83
83
  from typing import Any, AsyncIterator, Coroutine
84
- from uuid import uuid4
85
84
 
86
85
  import httpx
87
86
  from fastapi import FastAPI, HTTPException, Request
@@ -90,6 +89,7 @@ from starlette.background import BackgroundTask
90
89
  from starlette.responses import Response, StreamingResponse
91
90
 
92
91
  from .api import _LAGGARD, Statewire
92
+ from .legacy import upgrade_legacy_commands
93
93
  from .state import _frozen, plain
94
94
 
95
95
  _STREAM_HEADERS = {
@@ -196,28 +196,6 @@ class _RunError(Exception):
196
196
  self.payload = payload
197
197
 
198
198
 
199
- def _legacy_wire_message(message: Any) -> dict[str, Any]:
200
- if not isinstance(message, dict):
201
- raise _RunError("add-message: message must be an object")
202
- parts = message.get("parts")
203
- if not isinstance(parts, list):
204
- raise _RunError("add-message: message.parts must be an array")
205
- converted = []
206
- for part in parts:
207
- if isinstance(part, dict) and part.get("type") == "image":
208
- image = part.get("image")
209
- if not isinstance(image, str):
210
- raise _RunError("add-message: image part requires a string image field")
211
- converted.append({"type": "file", "mediaType": "image/*", "url": image})
212
- else:
213
- converted.append(part)
214
- return {
215
- "id": f"legacy_{uuid4().hex}",
216
- "role": message.get("role"),
217
- "parts": converted,
218
- }
219
-
220
-
221
199
  _Item = tuple[str, Any]
222
200
 
223
201
 
@@ -430,7 +408,11 @@ class AssistantTransport(Statewire):
430
408
  if not commands:
431
409
  if parent_id is None:
432
410
  return
433
- members = [await self._legacy_reload(parent_id)]
411
+ members = await upgrade_legacy_commands(
412
+ [],
413
+ parent_id=parent_id,
414
+ get_message_child_id=self.get_message_child_id,
415
+ )
434
416
  else:
435
417
  members = [
436
418
  await self._assistant_transport_member(command, parent_id)
@@ -453,57 +435,15 @@ class AssistantTransport(Statewire):
453
435
  ) -> dict[str, Any]:
454
436
  kind = command["type"]
455
437
  registered = type(self)._statewire_commands
456
- if kind == "add-message" and "add-message" not in registered:
457
- message = _legacy_wire_message(command.get("message"))
458
- source_id = command.get("sourceId")
459
- if source_id is not None and not isinstance(source_id, str):
460
- raise _RunError("add-message: sourceId must be a string or null")
461
- if source_id is None and parent_id is not None:
462
- source_id = await self.get_message_child_id(parent_id)
463
- if source_id is not None:
464
- return {
465
- "method": "run/edit",
466
- "params": [{"sourceId": source_id, "message": message}],
467
- }
468
- return {
469
- "method": "run/steer",
470
- "params": [{"message": message, "anchorMessageId": parent_id}],
471
- }
472
- if kind == "add-tool-result" and "add-tool-result" not in registered:
473
- return {
474
- "method": "run/input",
475
- "params": [await self._legacy_input(command)],
476
- }
477
- return {"method": kind, "params": [command]}
478
-
479
- async def _legacy_input(self, command: dict[str, Any]) -> dict[str, Any]:
480
- if command.get("modelContent") is not None:
481
- raise _RunError("add-tool-result: modelContent is not supported")
482
- tool_call_id = command.get("toolCallId")
483
- if not isinstance(tool_call_id, str):
484
- raise _RunError("add-tool-result: toolCallId must be a string")
485
- request_id = await self.get_input_request_id(tool_call_id)
486
- if request_id is None:
487
- raise _RunError(
488
- f"add-tool-result: no input request for toolCallId {tool_call_id!r}"
438
+ if kind in ("add-message", "add-tool-result") and kind not in registered:
439
+ [member] = await upgrade_legacy_commands(
440
+ [command],
441
+ parent_id=parent_id,
442
+ get_message_child_id=self.get_message_child_id,
443
+ get_input_request_id=self.get_input_request_id,
489
444
  )
490
- response: dict[str, Any] = {"output": command.get("result")}
491
- if command.get("isError"):
492
- response["isError"] = True
493
- if "artifact" in command:
494
- response["artifact"] = command["artifact"]
495
- return {"requestId": request_id, "response": response}
496
-
497
- async def _legacy_reload(self, parent_id: str) -> dict[str, Any]:
498
- source_id = await self.get_message_child_id(parent_id)
499
- if source_id is None:
500
- raise _RunError(f"reload: no child message for parentId {parent_id!r}")
501
- return {
502
- "method": "run/reload",
503
- "params": [
504
- {"sourceId": source_id, "messageId": f"legacy_{uuid4().hex}"}
505
- ],
506
- }
445
+ return member
446
+ return {"method": kind, "params": [command]}
507
447
 
508
448
  async def _assistant_transport_dispatch(self, method: str, identity: Any) -> None:
509
449
  client_id = "at-" + secrets.token_urlsafe(9)
@@ -0,0 +1,107 @@
1
+ """Upgrade legacy assistant-transport commands to the canonical ``run/*`` dialect."""
2
+
3
+ from typing import Any, Awaitable, Callable
4
+ from uuid import uuid4
5
+
6
+ _Resolver = Callable[[str], Awaitable[str | None]]
7
+
8
+
9
+ async def _unresolved(_: str) -> None:
10
+ return None
11
+
12
+
13
+ def _wire_message(message: Any) -> dict[str, Any]:
14
+ if not isinstance(message, dict):
15
+ raise ValueError("add-message: message must be an object")
16
+ parts = message.get("parts")
17
+ if not isinstance(parts, list):
18
+ raise ValueError("add-message: message.parts must be an array")
19
+ converted = []
20
+ for part in parts:
21
+ if isinstance(part, dict) and part.get("type") == "image":
22
+ image = part.get("image")
23
+ if not isinstance(image, str):
24
+ raise ValueError("add-message: image part requires a string image field")
25
+ converted.append({"type": "file", "mediaType": "image/*", "url": image})
26
+ else:
27
+ converted.append(part)
28
+ return {
29
+ "id": f"legacy_{uuid4().hex}",
30
+ "role": message.get("role"),
31
+ "parts": converted,
32
+ }
33
+
34
+
35
+ async def _add_message(
36
+ command: dict[str, Any], parent_id: str | None, resolve_child: _Resolver
37
+ ) -> dict[str, Any]:
38
+ message = _wire_message(command.get("message"))
39
+ source_id = command.get("sourceId")
40
+ if source_id is not None and not isinstance(source_id, str):
41
+ raise ValueError("add-message: sourceId must be a string or null")
42
+ if source_id is None and parent_id is not None:
43
+ source_id = await resolve_child(parent_id)
44
+ if source_id is not None:
45
+ return {
46
+ "method": "run/edit",
47
+ "params": [{"sourceId": source_id, "message": message}],
48
+ }
49
+ return {
50
+ "method": "run/steer",
51
+ "params": [{"message": message, "anchorMessageId": parent_id}],
52
+ }
53
+
54
+
55
+ async def _add_tool_result(
56
+ command: dict[str, Any], resolve_request: _Resolver
57
+ ) -> dict[str, Any]:
58
+ if command.get("modelContent") is not None:
59
+ raise ValueError("add-tool-result: modelContent is not supported")
60
+ tool_call_id = command.get("toolCallId")
61
+ if not isinstance(tool_call_id, str):
62
+ raise ValueError("add-tool-result: toolCallId must be a string")
63
+ request_id = await resolve_request(tool_call_id)
64
+ if request_id is None:
65
+ raise ValueError(
66
+ f"add-tool-result: no input request for toolCallId {tool_call_id!r}"
67
+ )
68
+ response: dict[str, Any] = {"output": command.get("result")}
69
+ if command.get("isError"):
70
+ response["isError"] = True
71
+ if "artifact" in command:
72
+ response["artifact"] = command["artifact"]
73
+ return {"requestId": request_id, "response": response}
74
+
75
+
76
+ async def upgrade_legacy_commands(
77
+ commands: list[dict[str, Any]],
78
+ *,
79
+ parent_id: str | None = None,
80
+ get_message_child_id: _Resolver | None = None,
81
+ get_input_request_id: _Resolver | None = None,
82
+ ) -> list[dict[str, Any]]:
83
+ """Upgrade a legacy assistant-transport body (``commands`` + optional ``parentId``) into ``run/*`` command members."""
84
+ resolve_child = get_message_child_id or _unresolved
85
+ resolve_request = get_input_request_id or _unresolved
86
+ if not commands:
87
+ if parent_id is None:
88
+ return []
89
+ source_id = await resolve_child(parent_id)
90
+ if source_id is None:
91
+ raise ValueError(f"reload: no child message for parentId {parent_id!r}")
92
+ return [{"method": "run/reload", "params": [{"sourceId": source_id}]}]
93
+ members = []
94
+ for command in commands:
95
+ kind = command.get("type")
96
+ if kind == "add-message":
97
+ members.append(await _add_message(command, parent_id, resolve_child))
98
+ elif kind == "add-tool-result":
99
+ members.append(
100
+ {
101
+ "method": "run/input",
102
+ "params": [await _add_tool_result(command, resolve_request)],
103
+ }
104
+ )
105
+ else:
106
+ raise ValueError(f"unknown legacy command type: {kind!r}")
107
+ return members
@@ -1096,8 +1096,7 @@ async def test_parent_id_without_commands_is_normalized_to_run_reload():
1096
1096
  assert all(prefix == "aui-state" for prefix, _ in frames)
1097
1097
  instance = (await app.state.pinned_host.directory.get("t1")).instance
1098
1098
  [params] = instance.reloaded
1099
- assert params["sourceId"] == "m2"
1100
- assert params["messageId"].startswith("legacy_")
1099
+ assert params == {"sourceId": "m2"}
1101
1100
 
1102
1101
 
1103
1102
  async def test_reload_without_a_resolvable_child_is_an_error_frame():
@@ -0,0 +1,108 @@
1
+ import pytest
2
+
3
+ from statewire import upgrade_legacy_commands
4
+
5
+
6
+ async def child_of(parent_id: str) -> str | None:
7
+ return {"m1": "m2"}.get(parent_id)
8
+
9
+
10
+ async def request_of(tool_call_id: str) -> str | None:
11
+ return {"call_1": "req_1"}.get(tool_call_id)
12
+
13
+
14
+ async def test_add_message_without_anchor_is_steer():
15
+ [member] = await upgrade_legacy_commands(
16
+ [{"type": "add-message", "message": {"role": "user", "parts": [{"type": "text", "text": "hi"}]}}]
17
+ )
18
+ assert member["method"] == "run/steer"
19
+ params = member["params"][0]
20
+ assert params["anchorMessageId"] is None
21
+ assert params["message"]["role"] == "user"
22
+ assert params["message"]["parts"] == [{"type": "text", "text": "hi"}]
23
+ assert params["message"]["id"].startswith("legacy_")
24
+
25
+
26
+ async def test_add_message_with_resolved_child_is_edit():
27
+ [member] = await upgrade_legacy_commands(
28
+ [{"type": "add-message", "message": {"role": "user", "parts": []}}],
29
+ parent_id="m1",
30
+ get_message_child_id=child_of,
31
+ )
32
+ assert member["method"] == "run/edit"
33
+ assert member["params"][0]["sourceId"] == "m2"
34
+
35
+
36
+ async def test_add_message_with_unresolved_child_is_steer_with_anchor():
37
+ [member] = await upgrade_legacy_commands(
38
+ [{"type": "add-message", "message": {"role": "user", "parts": []}}],
39
+ parent_id="unknown",
40
+ get_message_child_id=child_of,
41
+ )
42
+ assert member["method"] == "run/steer"
43
+ assert member["params"][0]["anchorMessageId"] == "unknown"
44
+
45
+
46
+ async def test_add_message_explicit_source_id_wins():
47
+ [member] = await upgrade_legacy_commands(
48
+ [{"type": "add-message", "sourceId": "m9", "message": {"role": "user", "parts": []}}],
49
+ parent_id="m1",
50
+ get_message_child_id=child_of,
51
+ )
52
+ assert member["method"] == "run/edit"
53
+ assert member["params"][0]["sourceId"] == "m9"
54
+
55
+
56
+ async def test_add_message_converts_image_parts():
57
+ [member] = await upgrade_legacy_commands(
58
+ [{"type": "add-message", "message": {"role": "user", "parts": [{"type": "image", "image": "https://x/i.png"}]}}]
59
+ )
60
+ assert member["params"][0]["message"]["parts"] == [
61
+ {"type": "file", "mediaType": "image/*", "url": "https://x/i.png"}
62
+ ]
63
+
64
+
65
+ async def test_add_tool_result_is_input():
66
+ [member] = await upgrade_legacy_commands(
67
+ [{"type": "add-tool-result", "toolCallId": "call_1", "result": {"ok": True}, "isError": True, "artifact": "a"}],
68
+ get_input_request_id=request_of,
69
+ )
70
+ assert member == {
71
+ "method": "run/input",
72
+ "params": [
73
+ {
74
+ "requestId": "req_1",
75
+ "response": {"output": {"ok": True}, "isError": True, "artifact": "a"},
76
+ }
77
+ ],
78
+ }
79
+
80
+
81
+ async def test_add_tool_result_without_request_raises():
82
+ with pytest.raises(ValueError, match="no input request"):
83
+ await upgrade_legacy_commands(
84
+ [{"type": "add-tool-result", "toolCallId": "nope", "result": 1}],
85
+ get_input_request_id=request_of,
86
+ )
87
+
88
+
89
+ async def test_parent_id_without_commands_is_reload():
90
+ [member] = await upgrade_legacy_commands(
91
+ [], parent_id="m1", get_message_child_id=child_of
92
+ )
93
+ assert member["method"] == "run/reload"
94
+ assert member["params"] == [{"sourceId": "m2"}]
95
+
96
+
97
+ async def test_reload_without_child_raises():
98
+ with pytest.raises(ValueError, match="no child message"):
99
+ await upgrade_legacy_commands([], parent_id="unknown", get_message_child_id=child_of)
100
+
101
+
102
+ async def test_empty_body_is_empty():
103
+ assert await upgrade_legacy_commands([]) == []
104
+
105
+
106
+ async def test_unknown_type_raises():
107
+ with pytest.raises(ValueError, match="unknown legacy command type"):
108
+ await upgrade_legacy_commands([{"type": "set-title", "title": "x"}])
File without changes
File without changes
File without changes
File without changes