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.
- {statewire-0.3.0 → statewire-0.3.1}/PKG-INFO +1 -1
- {statewire-0.3.0 → statewire-0.3.1}/pyproject.toml +1 -1
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/api.py +8 -5
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/assistant_transport.py +164 -14
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport.py +420 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_commands.py +19 -0
- {statewire-0.3.0 → statewire-0.3.1}/.gitignore +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/README.md +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/examples/__init__.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/examples/demo_app.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/__init__.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/langgraph.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/ops.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/src/statewire/state.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/client_helpers.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/statewire_helpers.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_authorize.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_client_ws.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_context.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_langgraph.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_lifespan.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_meta.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_state_proxy.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_stream.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_writer_lease.py +0 -0
- {statewire-0.3.0 → statewire-0.3.1}/tests/test_ws.py +0 -0
|
@@ -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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|