statewire 0.3.3__tar.gz → 0.4.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 (34) hide show
  1. {statewire-0.3.3 → statewire-0.4.1}/PKG-INFO +1 -1
  2. {statewire-0.3.3 → statewire-0.4.1}/pyproject.toml +1 -1
  3. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/__init__.py +2 -0
  4. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/assistant_transport.py +19 -70
  5. statewire-0.4.1/src/statewire/legacy.py +114 -0
  6. {statewire-0.3.3 → statewire-0.4.1}/tests/test_assistant_transport.py +15 -10
  7. {statewire-0.3.3 → statewire-0.4.1}/tests/test_assistant_transport_client.py +2 -2
  8. statewire-0.4.1/tests/test_legacy.py +110 -0
  9. {statewire-0.3.3 → statewire-0.4.1}/.gitignore +0 -0
  10. {statewire-0.3.3 → statewire-0.4.1}/README.md +0 -0
  11. {statewire-0.3.3 → statewire-0.4.1}/examples/__init__.py +0 -0
  12. {statewire-0.3.3 → statewire-0.4.1}/examples/demo_app.py +0 -0
  13. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/api.py +0 -0
  14. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/assistant_transport_client.py +0 -0
  15. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/client.py +0 -0
  16. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/langgraph.py +0 -0
  17. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/ops.py +0 -0
  18. {statewire-0.3.3 → statewire-0.4.1}/src/statewire/state.py +0 -0
  19. {statewire-0.3.3 → statewire-0.4.1}/tests/client_helpers.py +0 -0
  20. {statewire-0.3.3 → statewire-0.4.1}/tests/statewire_helpers.py +0 -0
  21. {statewire-0.3.3 → statewire-0.4.1}/tests/test_assistant_transport_facade.py +0 -0
  22. {statewire-0.3.3 → statewire-0.4.1}/tests/test_authorize.py +0 -0
  23. {statewire-0.3.3 → statewire-0.4.1}/tests/test_client.py +0 -0
  24. {statewire-0.3.3 → statewire-0.4.1}/tests/test_client_ws.py +0 -0
  25. {statewire-0.3.3 → statewire-0.4.1}/tests/test_commands.py +0 -0
  26. {statewire-0.3.3 → statewire-0.4.1}/tests/test_context.py +0 -0
  27. {statewire-0.3.3 → statewire-0.4.1}/tests/test_langgraph.py +0 -0
  28. {statewire-0.3.3 → statewire-0.4.1}/tests/test_lifespan.py +0 -0
  29. {statewire-0.3.3 → statewire-0.4.1}/tests/test_meta.py +0 -0
  30. {statewire-0.3.3 → statewire-0.4.1}/tests/test_state_proxy.py +0 -0
  31. {statewire-0.3.3 → statewire-0.4.1}/tests/test_statewire_hostable.py +0 -0
  32. {statewire-0.3.3 → statewire-0.4.1}/tests/test_stream.py +0 -0
  33. {statewire-0.3.3 → statewire-0.4.1}/tests/test_writer_lease.py +0 -0
  34. {statewire-0.3.3 → statewire-0.4.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.3
3
+ Version: 0.4.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.3"
3
+ version = "0.4.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"
@@ -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",
@@ -17,8 +17,9 @@ the legacy commands, which are normalized to the canonical ``run/*`` dialect
17
17
  (a host-registered handler of the same legacy name always wins and receives
18
18
  the command whole):
19
19
 
20
- - ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>}``:
21
- a fresh ``legacy_`` id is synthesized, image parts become
20
+ - ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>,
21
+ "anchorMessageId": <body parentId>}`` (body ``parentId`` null or absent →
22
+ ``null``): a fresh ``legacy_`` id is synthesized, image parts become
22
23
  ``{"type": "file", "mediaType": "image/*", "url"}`` parts, and command-level
23
24
  anchors are dropped. A command-level non-null ``sourceId`` makes it
24
25
  ``run/edit`` with that ``sourceId`` directly; otherwise, when the body
@@ -31,8 +32,8 @@ the command whole):
31
32
  unanswered tool-call request in ``state["inputRequests"]`` (None rejects),
32
33
  and a ``modelContent`` field rejects (unsupported in ``run/input``).
33
34
  - A body with a ``parentId`` and no commands is the legacy reload: it becomes
34
- ``run/reload`` with ``{"sourceId": <child>}`` via ``get_message_child_id``
35
- (None rejects).
35
+ ``run/reload`` with ``{"sourceId": <child>, "messageId": <fresh legacy_
36
+ id>}`` via ``get_message_child_id`` (None rejects).
36
37
 
37
38
  A host without the target ``run/*`` handler rejects the batch via the
38
39
  ordinary unknown-command path. The response streams state changes as legacy
@@ -80,7 +81,6 @@ import json
80
81
  import secrets
81
82
  import time
82
83
  from typing import Any, AsyncIterator, Coroutine
83
- from uuid import uuid4
84
84
 
85
85
  import httpx
86
86
  from fastapi import FastAPI, HTTPException, Request
@@ -89,6 +89,7 @@ from starlette.background import BackgroundTask
89
89
  from starlette.responses import Response, StreamingResponse
90
90
 
91
91
  from .api import _LAGGARD, Statewire
92
+ from .legacy import upgrade_legacy_commands
92
93
  from .state import _frozen, plain
93
94
 
94
95
  _STREAM_HEADERS = {
@@ -195,28 +196,6 @@ class _RunError(Exception):
195
196
  self.payload = payload
196
197
 
197
198
 
198
- def _legacy_wire_message(message: Any) -> dict[str, Any]:
199
- if not isinstance(message, dict):
200
- raise _RunError("add-message: message must be an object")
201
- parts = message.get("parts")
202
- if not isinstance(parts, list):
203
- raise _RunError("add-message: message.parts must be an array")
204
- converted = []
205
- for part in parts:
206
- if isinstance(part, dict) and part.get("type") == "image":
207
- image = part.get("image")
208
- if not isinstance(image, str):
209
- raise _RunError("add-message: image part requires a string image field")
210
- converted.append({"type": "file", "mediaType": "image/*", "url": image})
211
- else:
212
- converted.append(part)
213
- return {
214
- "id": f"legacy_{uuid4().hex}",
215
- "role": message.get("role"),
216
- "parts": converted,
217
- }
218
-
219
-
220
199
  _Item = tuple[str, Any]
221
200
 
222
201
 
@@ -429,7 +408,11 @@ class AssistantTransport(Statewire):
429
408
  if not commands:
430
409
  if parent_id is None:
431
410
  return
432
- 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
+ )
433
416
  else:
434
417
  members = [
435
418
  await self._assistant_transport_member(command, parent_id)
@@ -452,49 +435,15 @@ class AssistantTransport(Statewire):
452
435
  ) -> dict[str, Any]:
453
436
  kind = command["type"]
454
437
  registered = type(self)._statewire_commands
455
- if kind == "add-message" and "add-message" not in registered:
456
- message = _legacy_wire_message(command.get("message"))
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)
462
- if source_id is not None:
463
- return {
464
- "method": "run/edit",
465
- "params": [{"sourceId": source_id, "message": message}],
466
- }
467
- return {"method": "run/steer", "params": [{"message": message}]}
468
- if kind == "add-tool-result" and "add-tool-result" not in registered:
469
- return {
470
- "method": "run/input",
471
- "params": [await self._legacy_input(command)],
472
- }
473
- return {"method": kind, "params": [command]}
474
-
475
- async def _legacy_input(self, command: dict[str, Any]) -> dict[str, Any]:
476
- if command.get("modelContent") is not None:
477
- raise _RunError("add-tool-result: modelContent is not supported")
478
- tool_call_id = command.get("toolCallId")
479
- if not isinstance(tool_call_id, str):
480
- raise _RunError("add-tool-result: toolCallId must be a string")
481
- request_id = await self.get_input_request_id(tool_call_id)
482
- if request_id is None:
483
- raise _RunError(
484
- 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,
485
444
  )
486
- response: dict[str, Any] = {"output": command.get("result")}
487
- if command.get("isError"):
488
- response["isError"] = True
489
- if "artifact" in command:
490
- response["artifact"] = command["artifact"]
491
- return {"requestId": request_id, "response": response}
492
-
493
- async def _legacy_reload(self, parent_id: str) -> dict[str, Any]:
494
- source_id = await self.get_message_child_id(parent_id)
495
- if source_id is None:
496
- raise _RunError(f"reload: no child message for parentId {parent_id!r}")
497
- return {"method": "run/reload", "params": [{"sourceId": source_id}]}
445
+ return member
446
+ return {"method": kind, "params": [command]}
498
447
 
499
448
  async def _assistant_transport_dispatch(self, method: str, identity: Any) -> None:
500
449
  client_id = "at-" + secrets.token_urlsafe(9)
@@ -0,0 +1,114 @@
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 [
93
+ {
94
+ "method": "run/reload",
95
+ "params": [
96
+ {"sourceId": source_id, "messageId": f"legacy_{uuid4().hex}"}
97
+ ],
98
+ }
99
+ ]
100
+ members = []
101
+ for command in commands:
102
+ kind = command.get("type")
103
+ if kind == "add-message":
104
+ members.append(await _add_message(command, parent_id, resolve_child))
105
+ elif kind == "add-tool-result":
106
+ members.append(
107
+ {
108
+ "method": "run/input",
109
+ "params": [await _add_tool_result(command, resolve_request)],
110
+ }
111
+ )
112
+ else:
113
+ raise ValueError(f"unknown legacy command type: {kind!r}")
114
+ return members
@@ -27,7 +27,7 @@ class Harness(AssistantTransport):
27
27
  self.state = {"messages": [], "text": "", "meta": {"title": None}}
28
28
  yield
29
29
 
30
- @command("message/add")
30
+ @command("demo/echo")
31
31
  async def add_message(self, cmd):
32
32
  self.seen.append(cmd)
33
33
  self.state["messages"].append(cmd["message"])
@@ -120,7 +120,7 @@ async def test_data_stream_response_streams_snapshot_then_ops_until_idle():
120
120
  {
121
121
  "commands": [
122
122
  {
123
- "type": "message/add",
123
+ "type": "demo/echo",
124
124
  "message": {
125
125
  "role": "user",
126
126
  "parts": [{"type": "text", "text": "hi"}],
@@ -229,7 +229,7 @@ async def test_splice_and_remove_fall_back_to_parent_set():
229
229
  {
230
230
  "commands": [
231
231
  {
232
- "type": "message/add",
232
+ "type": "demo/echo",
233
233
  "message": {
234
234
  "role": "user",
235
235
  "parts": [{"type": "text", "text": "hi"}],
@@ -266,7 +266,7 @@ async def test_assistant_transport_sse_mode():
266
266
  {
267
267
  "commands": [
268
268
  {
269
- "type": "message/add",
269
+ "type": "demo/echo",
270
270
  "message": {
271
271
  "role": "user",
272
272
  "parts": [{"type": "text", "text": "hi"}],
@@ -428,7 +428,7 @@ async def test_resume_after_completed_run_replays_with_completed_header():
428
428
  {
429
429
  "commands": [
430
430
  {
431
- "type": "message/add",
431
+ "type": "demo/echo",
432
432
  "message": {
433
433
  "role": "user",
434
434
  "parts": [{"type": "text", "text": "hi"}],
@@ -453,7 +453,7 @@ async def test_resume_rejects_commands_and_malformed_bodies():
453
453
  async with statewire_client(Harness) as (app, client):
454
454
  response = await client.post(
455
455
  "/threads/t1/assistant-transport/api/resume",
456
- json={"commands": [{"type": "message/add"}]},
456
+ json={"commands": [{"type": "demo/echo"}]},
457
457
  )
458
458
  assert response.status_code == 400
459
459
  assert (
@@ -840,7 +840,8 @@ async def test_add_message_is_normalized_to_run_steer():
840
840
  assert response.status_code == 200
841
841
  instance = (await app.state.pinned_host.directory.get("t1")).instance
842
842
  [(params, caller_id)] = instance.steered
843
- assert set(params) == {"message"}
843
+ assert set(params) == {"message", "anchorMessageId"}
844
+ assert params["anchorMessageId"] is None
844
845
  message = params["message"]
845
846
  assert message["id"].startswith("legacy_")
846
847
  assert message["role"] == "user"
@@ -1094,7 +1095,9 @@ async def test_parent_id_without_commands_is_normalized_to_run_reload():
1094
1095
  frames = data_stream_frames(response.text)
1095
1096
  assert all(prefix == "aui-state" for prefix, _ in frames)
1096
1097
  instance = (await app.state.pinned_host.directory.get("t1")).instance
1097
- assert instance.reloaded == [{"sourceId": "m2"}]
1098
+ [params] = instance.reloaded
1099
+ assert params["sourceId"] == "m2"
1100
+ assert params["messageId"].startswith("legacy_")
1098
1101
 
1099
1102
 
1100
1103
  async def test_reload_without_a_resolvable_child_is_an_error_frame():
@@ -1184,7 +1187,8 @@ async def test_add_message_with_a_null_source_id_and_no_parent_is_run_steer():
1184
1187
  instance = (await app.state.pinned_host.directory.get("t1")).instance
1185
1188
  assert instance.edited == []
1186
1189
  [(params, _)] = instance.steered
1187
- assert set(params) == {"message"}
1190
+ assert set(params) == {"message", "anchorMessageId"}
1191
+ assert params["anchorMessageId"] is None
1188
1192
 
1189
1193
 
1190
1194
  async def test_add_message_with_an_unresolvable_parent_falls_back_to_run_steer():
@@ -1209,4 +1213,5 @@ async def test_add_message_with_an_unresolvable_parent_falls_back_to_run_steer()
1209
1213
  instance = (await app.state.pinned_host.directory.get("t1")).instance
1210
1214
  assert instance.edited == []
1211
1215
  [(params, _)] = instance.steered
1212
- assert set(params) == {"message"}
1216
+ assert set(params) == {"message", "anchorMessageId"}
1217
+ assert params["anchorMessageId"] == "unknown"
@@ -124,11 +124,11 @@ async def test_client_posts_the_legacy_body_and_applies_state_ops():
124
124
  body={"system": "be nice"},
125
125
  on_chunk=seen.append,
126
126
  )
127
- assert await client.send([{"type": "message/add", "message": "hi"}]) is True
127
+ assert await client.send([{"type": "demo/echo", "message": "hi"}]) is True
128
128
  assert client.state == {"text": "hello"}
129
129
  assert received == [
130
130
  {
131
- "commands": [{"type": "message/add", "message": "hi"}],
131
+ "commands": [{"type": "demo/echo", "message": "hi"}],
132
132
  "state": {"text": ""},
133
133
  "threadId": "t1",
134
134
  "system": "be nice",
@@ -0,0 +1,110 @@
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
+ params = member["params"][0]
95
+ assert params["sourceId"] == "m2"
96
+ assert params["messageId"].startswith("legacy_")
97
+
98
+
99
+ async def test_reload_without_child_raises():
100
+ with pytest.raises(ValueError, match="no child message"):
101
+ await upgrade_legacy_commands([], parent_id="unknown", get_message_child_id=child_of)
102
+
103
+
104
+ async def test_empty_body_is_empty():
105
+ assert await upgrade_legacy_commands([]) == []
106
+
107
+
108
+ async def test_unknown_type_raises():
109
+ with pytest.raises(ValueError, match="unknown legacy command type"):
110
+ await upgrade_legacy_commands([{"type": "set-title", "title": "x"}])
File without changes
File without changes
File without changes
File without changes