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.
- {statewire-0.4.0 → statewire-0.4.2}/PKG-INFO +1 -1
- {statewire-0.4.0 → statewire-0.4.2}/pyproject.toml +1 -1
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/__init__.py +2 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/assistant_transport.py +16 -76
- statewire-0.4.2/src/statewire/legacy.py +107 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport.py +1 -2
- statewire-0.4.2/tests/test_legacy.py +108 -0
- {statewire-0.4.0 → statewire-0.4.2}/.gitignore +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/README.md +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/examples/__init__.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/examples/demo_app.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/api.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/client.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/langgraph.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/ops.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/src/statewire/state.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/client_helpers.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/statewire_helpers.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_authorize.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_client.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_client_ws.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_commands.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_context.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_langgraph.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_lifespan.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_meta.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_state_proxy.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_stream.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_writer_lease.py +0 -0
- {statewire-0.4.0 → statewire-0.4.2}/tests/test_ws.py +0 -0
|
@@ -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
|
|
36
|
-
|
|
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 =
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
|
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
|
|
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
|