statewire 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {statewire-0.3.2 → statewire-0.3.3}/PKG-INFO +1 -1
  2. {statewire-0.3.2 → statewire-0.3.3}/pyproject.toml +1 -1
  3. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/assistant_transport.py +133 -60
  4. {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport.py +197 -2
  5. {statewire-0.3.2 → statewire-0.3.3}/.gitignore +0 -0
  6. {statewire-0.3.2 → statewire-0.3.3}/README.md +0 -0
  7. {statewire-0.3.2 → statewire-0.3.3}/examples/__init__.py +0 -0
  8. {statewire-0.3.2 → statewire-0.3.3}/examples/demo_app.py +0 -0
  9. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/__init__.py +0 -0
  10. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/api.py +0 -0
  11. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/assistant_transport_client.py +0 -0
  12. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/client.py +0 -0
  13. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/langgraph.py +0 -0
  14. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/ops.py +0 -0
  15. {statewire-0.3.2 → statewire-0.3.3}/src/statewire/state.py +0 -0
  16. {statewire-0.3.2 → statewire-0.3.3}/tests/client_helpers.py +0 -0
  17. {statewire-0.3.2 → statewire-0.3.3}/tests/statewire_helpers.py +0 -0
  18. {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport_client.py +0 -0
  19. {statewire-0.3.2 → statewire-0.3.3}/tests/test_assistant_transport_facade.py +0 -0
  20. {statewire-0.3.2 → statewire-0.3.3}/tests/test_authorize.py +0 -0
  21. {statewire-0.3.2 → statewire-0.3.3}/tests/test_client.py +0 -0
  22. {statewire-0.3.2 → statewire-0.3.3}/tests/test_client_ws.py +0 -0
  23. {statewire-0.3.2 → statewire-0.3.3}/tests/test_commands.py +0 -0
  24. {statewire-0.3.2 → statewire-0.3.3}/tests/test_context.py +0 -0
  25. {statewire-0.3.2 → statewire-0.3.3}/tests/test_langgraph.py +0 -0
  26. {statewire-0.3.2 → statewire-0.3.3}/tests/test_lifespan.py +0 -0
  27. {statewire-0.3.2 → statewire-0.3.3}/tests/test_meta.py +0 -0
  28. {statewire-0.3.2 → statewire-0.3.3}/tests/test_state_proxy.py +0 -0
  29. {statewire-0.3.2 → statewire-0.3.3}/tests/test_statewire_hostable.py +0 -0
  30. {statewire-0.3.2 → statewire-0.3.3}/tests/test_stream.py +0 -0
  31. {statewire-0.3.2 → statewire-0.3.3}/tests/test_writer_lease.py +0 -0
  32. {statewire-0.3.2 → statewire-0.3.3}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.3.2
3
+ Version: 0.3.3
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.2"
3
+ version = "0.3.3"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,9 +20,11 @@ the command whole):
20
20
  - ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>}``:
21
21
  a fresh ``legacy_`` id is synthesized, image parts become
22
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>}``.
23
+ anchors are dropped. A command-level non-null ``sourceId`` makes it
24
+ ``run/edit`` with that ``sourceId`` directly; otherwise, when the body
25
+ carries a top-level ``parentId`` and ``get_message_child_id`` resolves it to
26
+ a child, the command becomes ``run/edit`` with ``{"sourceId": <child>,
27
+ "message": <wire message>}``.
26
28
  - ``add-tool-result`` becomes ``run/input`` with ``{"requestId": <resolved>,
27
29
  "response": {"output": <result>, "isError"?, "artifact"?}}``; the requestId
28
30
  comes from ``get_input_request_id(toolCallId)``, whose default peeks the
@@ -33,16 +35,19 @@ the command whole):
33
35
  (None rejects).
34
36
 
35
37
  A host without the target ``run/*`` handler rejects the batch via the
36
- ordinary unknown-command path. The response streams
37
- state changes as legacy frames until every submitted command settles, then
38
- EOF; a rejection or crash becomes an error frame followed by EOF. Client
39
- disconnect before EOF invokes ``on_assistant_transport_disconnect`` (a plain
40
- overridable hook, not a command; default no-op); the run itself keeps
41
- executing server-side.
38
+ ordinary unknown-command path. The response streams state changes as legacy
39
+ frames until the instance is idle every submitted command settled and no
40
+ live instance task (``create_task`` minus ``unref``) then EOF. A run error
41
+ recorded into state (``get_run_error``; default ``state["error"]``) becomes
42
+ an error frame before EOF, its fields beyond ``message`` carried as the
43
+ frame's structured payload where the format allows; a rejection or crash
44
+ becomes an error frame followed by EOF. Client disconnect before EOF invokes
45
+ ``on_assistant_transport_disconnect`` (a plain overridable hook, not a
46
+ command; default no-op); the run itself keeps executing server-side.
42
47
 
43
48
  ``POST /assistant-transport/api/resume`` (same body shape, ``commands`` must
44
49
  be absent or empty) reattaches to the run: a full-state ``set`` at the root,
45
- then the live tail until the run settles. If no run ever started, the
50
+ then the live tail until the instance is idle. If no run ever started, the
46
51
  response is 200 with an empty body and ``X-Stream-Status: not_found``. If the
47
52
  run already completed, the replay carries ``X-Stream-Status: completed``. A
48
53
  resume disconnect never invokes the cancel hook.
@@ -56,7 +61,7 @@ existed at request time.
56
61
  ``POST /assistant-transport/api/status`` responds 200 always:
57
62
  ``{"isRunning": true, "status": "running"}`` while a run is active,
58
63
  ``{"isRunning": false, "status": "completed", "completedAt": <ms epoch>}``
59
- after it settles, and ``{"isRunning": false, "status": "not_found",
64
+ once idle, and ``{"isRunning": false, "status": "not_found",
60
65
  "message": ...}`` when no run ever started on this instance.
61
66
 
62
67
  Two stream formats, selected by ``assistant_transport_protocol``:
@@ -74,7 +79,7 @@ import contextlib
74
79
  import json
75
80
  import secrets
76
81
  import time
77
- from typing import Any, AsyncIterator
82
+ from typing import Any, AsyncIterator, Coroutine
78
83
  from uuid import uuid4
79
84
 
80
85
  import httpx
@@ -102,7 +107,7 @@ class _DataStreamFormat:
102
107
  return f"aui-state:[{','.join(op_frames)}]\n"
103
108
 
104
109
  @staticmethod
105
- def error(message: str) -> str:
110
+ def error(message: str, payload: Any = None) -> str:
106
111
  return f"3:{json.dumps(message)}\n"
107
112
 
108
113
 
@@ -119,9 +124,11 @@ class _AssistantTransportFormat:
119
124
  )
120
125
 
121
126
  @staticmethod
122
- def error(message: str) -> str:
123
- frame = json.dumps({"type": "error", "error": message})
124
- return f"data: {frame}\n\n"
127
+ def error(message: str, payload: Any = None) -> str:
128
+ chunk: dict[str, Any] = {"type": "error", "error": message}
129
+ if payload is not None:
130
+ chunk["payload"] = payload
131
+ return f"data: {json.dumps(chunk)}\n\n"
125
132
 
126
133
 
127
134
  _FORMATS = {
@@ -183,7 +190,9 @@ _AT_CONSUMED_FIELDS = {"commands", "threadId", "state"}
183
190
 
184
191
 
185
192
  class _RunError(Exception):
186
- pass
193
+ def __init__(self, message: str, *, payload: Any = None) -> None:
194
+ super().__init__(message)
195
+ self.payload = payload
187
196
 
188
197
 
189
198
  def _legacy_wire_message(message: Any) -> dict[str, Any]:
@@ -215,6 +224,7 @@ class _Run:
215
224
  def __init__(self, replica: Any) -> None:
216
225
  self.replica = replica
217
226
  self.error: str | None = None
227
+ self.error_payload: Any = None
218
228
  self.done = False
219
229
  self.completed_at: int | None = None
220
230
  self.attachments: list["asyncio.Queue[_Item]"] = []
@@ -227,7 +237,7 @@ class _Run:
227
237
  self.attachments.remove(aq)
228
238
  while not aq.empty():
229
239
  aq.get_nowait()
230
- aq.put_nowait(("error", "state stream overflowed"))
240
+ aq.put_nowait(("error", ("state stream overflowed", None)))
231
241
  aq.put_nowait(("end", None))
232
242
 
233
243
 
@@ -236,6 +246,34 @@ class AssistantTransport(Statewire):
236
246
 
237
247
  _at_run: _Run | None = None
238
248
 
249
+ def __init__(self, ctx: Any) -> None:
250
+ super().__init__(ctx)
251
+ self._at_tasks: set["asyncio.Task[Any]"] = set()
252
+
253
+ def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
254
+ task = super().create_task(coro)
255
+ self._at_tasks.add(task)
256
+ task.add_done_callback(self._at_tasks.discard)
257
+ return task
258
+
259
+ def unref(self, task: "asyncio.Task[Any]") -> "asyncio.Task[Any]":
260
+ self._at_tasks.discard(task)
261
+ return super().unref(task)
262
+
263
+ async def get_run_error(self) -> dict[str, Any] | None:
264
+ """The run error to surface as the end-of-stream error frame.
265
+
266
+ The default peeks the RunManager root-state mount: a non-null
267
+ ``state["error"]`` object with a string ``message`` is the recorded
268
+ run error; its fields beyond ``message`` travel as the frame's
269
+ structured payload where the format carries one. None ends the
270
+ stream cleanly."""
271
+ state = plain(self.state)
272
+ error = state.get("error") if isinstance(state, dict) else None
273
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
274
+ return error
275
+ return None
276
+
239
277
  async def on_assistant_transport_disconnect(self) -> None:
240
278
  """The legacy client went away or asked to stop; treat as a cancel signal."""
241
279
 
@@ -308,11 +346,12 @@ class AssistantTransport(Statewire):
308
346
  parent_id = None
309
347
  run = _Run(json.loads(snapshot)["ops"][0]["value"])
310
348
  self._at_run = run
311
- self.create_task(
349
+ watcher = self.create_task(
312
350
  self._assistant_transport_run(
313
351
  run, q, client_id, lease, commands, parent_id
314
352
  )
315
353
  )
354
+ self._at_tasks.discard(watcher)
316
355
  return StreamingResponse(
317
356
  self._assistant_transport_attach(fmt, run, initial=True),
318
357
  media_type="text/event-stream",
@@ -415,11 +454,11 @@ class AssistantTransport(Statewire):
415
454
  registered = type(self)._statewire_commands
416
455
  if kind == "add-message" and "add-message" not in registered:
417
456
  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
- )
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)
423
462
  if source_id is not None:
424
463
  return {
425
464
  "method": "run/edit",
@@ -486,48 +525,82 @@ class AssistantTransport(Statewire):
486
525
  total = len(commands) or (1 if parent_id is not None else 0)
487
526
  acked = 0
488
527
  open_pending: set[int] = set()
528
+
529
+ def idle() -> bool:
530
+ return (
531
+ submit.done()
532
+ and acked >= total
533
+ and not open_pending
534
+ and not self._at_tasks
535
+ )
536
+
537
+ def consume(item: Any) -> bool:
538
+ nonlocal acked
539
+ if item is _LAGGARD:
540
+ raise _RunError("state stream overflowed")
541
+ data, finish = item
542
+ envelope = json.loads(data)
543
+ op_frames: list[str] = []
544
+ for op in envelope.get("ops", []):
545
+ run.replica, frame = _translate_op(run.replica, op)
546
+ if frame is not None:
547
+ op_frames.append(frame)
548
+ if op_frames:
549
+ run.push(("state", op_frames))
550
+ for res in envelope.get("res", []):
551
+ if res["type"] == "pending":
552
+ open_pending.add(res["seq"])
553
+ continue
554
+ open_pending.discard(res["seq"])
555
+ if res["type"] in ("rejected", "crashed"):
556
+ raise _RunError(
557
+ res.get("message") or res["type"],
558
+ payload=res.get("payload"),
559
+ )
560
+ if "ack" in envelope:
561
+ acked = envelope["ack"]
562
+ if finish and not idle():
563
+ fin = envelope.get("fin", {})
564
+ raise _RunError(
565
+ fin.get("message") or fin.get("reason", "stream ended")
566
+ )
567
+ return finish
568
+
489
569
  try:
490
- while not (submit.done() and acked >= total and not open_pending):
491
- waiters = {q_get} if submit.done() else {q_get, submit}
570
+ ended = False
571
+ while not ended:
572
+ if idle():
573
+ self.drain()
574
+ self.flush()
575
+ if q_get.done():
576
+ ended = consume(q_get.result()) or ended
577
+ q_get = asyncio.create_task(q.get())
578
+ continue
579
+ while not q.empty():
580
+ ended = consume(q.get_nowait()) or ended
581
+ if idle():
582
+ break
583
+ continue
584
+ waiters = {q_get, *self._at_tasks}
585
+ if not submit.done():
586
+ waiters.add(submit)
492
587
  done, _ = await asyncio.wait(
493
588
  waiters, return_when=asyncio.FIRST_COMPLETED
494
589
  )
495
590
  if submit in done and submit.exception() is not None:
496
591
  raise _RunError(str(submit.exception()))
497
- if q_get not in done:
498
- continue
499
- item = q_get.result()
500
- if item is _LAGGARD:
501
- raise _RunError("state stream overflowed")
502
- data, finish = item
503
- envelope = json.loads(data)
504
- op_frames: list[str] = []
505
- for op in envelope.get("ops", []):
506
- run.replica, frame = _translate_op(run.replica, op)
507
- if frame is not None:
508
- op_frames.append(frame)
509
- if op_frames:
510
- run.push(("state", op_frames))
511
- for res in envelope.get("res", []):
512
- if res["type"] == "pending":
513
- open_pending.add(res["seq"])
514
- continue
515
- open_pending.discard(res["seq"])
516
- if res["type"] in ("rejected", "crashed"):
517
- raise _RunError(res.get("message") or res["type"])
518
- if "ack" in envelope:
519
- acked = envelope["ack"]
520
- if finish:
521
- if submit.done() and acked >= total and not open_pending:
522
- break
523
- fin = envelope.get("fin", {})
524
- raise _RunError(
525
- fin.get("message") or fin.get("reason", "stream ended")
526
- )
527
- q_get = asyncio.create_task(q.get())
592
+ if q_get in done:
593
+ ended = consume(q_get.result())
594
+ if not ended:
595
+ q_get = asyncio.create_task(q.get())
596
+ error = await self.get_run_error()
597
+ if error is not None:
598
+ payload = {k: v for k, v in error.items() if k != "message"}
599
+ raise _RunError(error["message"], payload=payload or None)
528
600
  except _RunError as exc:
529
601
  run.error = str(exc)
530
- run.push(("error", run.error))
602
+ run.error_payload = exc.payload
603
+ run.push(("error", (run.error, run.error_payload)))
531
604
  finally:
532
605
  q_get.cancel()
533
606
  submit.cancel()
@@ -559,11 +632,11 @@ class AssistantTransport(Statewire):
559
632
  if kind == "state":
560
633
  yield fmt.state(value)
561
634
  elif kind == "error":
562
- yield fmt.error(value)
635
+ yield fmt.error(*value)
563
636
  else:
564
637
  break
565
638
  elif initial and run.error is not None:
566
- yield fmt.error(run.error)
639
+ yield fmt.error(run.error, run.error_payload)
567
640
  if fmt.end:
568
641
  yield fmt.end
569
642
  completed = True
@@ -2,13 +2,16 @@
2
2
 
3
3
  The legacy client POSTs ``{"commands", "state", "threadId", ...}`` and reads
4
4
  back a data-stream (``<prefix>:<json>`` lines) or assistant-transport SSE
5
- response carrying ``set``/``append-text`` state ops until the run settles.
5
+ response carrying ``set``/``append-text`` state ops until the instance is
6
+ idle.
6
7
  """
7
8
 
8
9
  import asyncio
9
10
  import contextlib
10
11
  import json
11
12
 
13
+ import pytest
14
+
12
15
  from statewire_helpers import statewire_client
13
16
 
14
17
  from statewire import AssistantTransport, StatewireReject, command
@@ -110,7 +113,7 @@ async def post_run(client, body, thread="t1"):
110
113
  return await client.post(f"/threads/{thread}/assistant-transport/api/chat", json=body)
111
114
 
112
115
 
113
- async def test_data_stream_response_streams_snapshot_then_ops_until_settle():
116
+ async def test_data_stream_response_streams_snapshot_then_ops_until_idle():
114
117
  async with statewire_client(Harness) as (app, client):
115
118
  response = await post_run(
116
119
  client,
@@ -347,6 +350,14 @@ class _PostStream:
347
350
  async def next_chunk(self, timeout: float = 5) -> bytes:
348
351
  return await asyncio.wait_for(self._chunks.get(), timeout)
349
352
 
353
+ async def until_closed(self, timeout: float = 5) -> bytes:
354
+ assert self._task is not None
355
+ await asyncio.wait_for(asyncio.shield(self._task), timeout)
356
+ tail = b""
357
+ while not self._chunks.empty():
358
+ tail += self._chunks.get_nowait()
359
+ return tail
360
+
350
361
  async def __aexit__(self, *exc):
351
362
  self._disconnected.set()
352
363
  assert self._task is not None
@@ -597,6 +608,138 @@ async def test_status_reports_not_found_running_and_completed():
597
608
  assert isinstance(body["completedAt"], int)
598
609
 
599
610
 
611
+ class BackgroundHarness(AssistantTransport):
612
+ """Host whose run/steer settles at enqueue; the run is an instance task."""
613
+
614
+ def __init__(self, ctx):
615
+ super().__init__(ctx)
616
+ self.release = asyncio.Event()
617
+ self.fail_with = None
618
+ self.cancelled = 0
619
+
620
+ async def lifespan(self):
621
+ self.state = {"text": "", "error": None}
622
+ yield
623
+
624
+ @command("run/steer")
625
+ async def run_steer(self, params):
626
+ self.create_task(self._run())
627
+
628
+ async def _run(self):
629
+ self.state["text"] += "a"
630
+ await self.release.wait()
631
+ if self.fail_with is not None:
632
+ self.state["error"] = self.fail_with
633
+ else:
634
+ self.state["text"] += "b"
635
+
636
+ async def on_assistant_transport_disconnect(self):
637
+ self.cancelled += 1
638
+
639
+
640
+ def post_background_chat(app):
641
+ return _PostStream(
642
+ app,
643
+ "/threads/t1/assistant-transport/api/chat",
644
+ {"commands": [{"type": "run/steer"}], "state": {}, "threadId": "t1"},
645
+ )
646
+
647
+
648
+ async def test_chat_stays_open_past_command_settle_and_eofs_at_idle():
649
+ async with statewire_client(BackgroundHarness) as (app, client):
650
+ async with post_background_chat(app) as stream:
651
+ assert stream.status == 200
652
+ buffer = b""
653
+ while b'"a"' not in buffer:
654
+ buffer += await stream.next_chunk()
655
+ with pytest.raises(TimeoutError):
656
+ await stream.next_chunk(0.1)
657
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
658
+ instance.release.set()
659
+ buffer += await stream.until_closed()
660
+ frames = data_stream_frames(buffer.decode())
661
+ assert all(prefix == "aui-state" for prefix, _ in frames)
662
+ replica = apply_legacy_ops({}, state_ops(frames))
663
+ assert replica["text"] == "ab"
664
+ body = (
665
+ await client.post(
666
+ "/threads/t1/assistant-transport/api/status", json={"threadId": "t1"}
667
+ )
668
+ ).json()
669
+ assert body["status"] == "completed"
670
+
671
+
672
+ async def test_run_error_recorded_into_state_is_an_error_frame_before_eof():
673
+ async with statewire_client(BackgroundHarness) as (app, client):
674
+ async with post_background_chat(app) as stream:
675
+ buffer = b""
676
+ while b'"a"' not in buffer:
677
+ buffer += await stream.next_chunk()
678
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
679
+ instance.fail_with = {
680
+ "message": "payment required",
681
+ "reason": "payment-required",
682
+ }
683
+ instance.release.set()
684
+ buffer += await stream.until_closed()
685
+ frames = data_stream_frames(buffer.decode())
686
+ assert frames[-1] == ("3", "payment required")
687
+ replica = apply_legacy_ops({}, state_ops(frames))
688
+ assert replica["error"] == {
689
+ "message": "payment required",
690
+ "reason": "payment-required",
691
+ }
692
+
693
+
694
+ async def test_sse_mode_run_error_carries_the_structured_payload():
695
+ class SSEBackgroundHarness(BackgroundHarness):
696
+ assistant_transport_protocol = "assistant-transport"
697
+
698
+ async with statewire_client(SSEBackgroundHarness) as (app, client):
699
+ async with _PostStream(
700
+ app,
701
+ "/threads/t1/assistant-transport/api/chat",
702
+ {"commands": [{"type": "run/steer"}], "state": {}, "threadId": "t1"},
703
+ ) as stream:
704
+ buffer = b""
705
+ while b'"a"' not in buffer:
706
+ buffer += await stream.next_chunk()
707
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
708
+ instance.fail_with = {
709
+ "message": "payment required",
710
+ "reason": "payment-required",
711
+ }
712
+ instance.release.set()
713
+ buffer += await stream.until_closed()
714
+ frames = sse_frames(buffer.decode())
715
+ assert frames[-1] == "[DONE]"
716
+ assert frames[-2] == {
717
+ "type": "error",
718
+ "error": "payment required",
719
+ "payload": {"reason": "payment-required"},
720
+ }
721
+
722
+
723
+ async def test_disconnect_before_idle_fires_the_hook_and_the_run_continues():
724
+ async with statewire_client(BackgroundHarness) as (app, client):
725
+ async with post_background_chat(app) as stream:
726
+ buffer = b""
727
+ while b'"a"' not in buffer:
728
+ buffer += await stream.next_chunk()
729
+ await asyncio.sleep(0.05)
730
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
731
+ assert instance.cancelled == 1
732
+ instance.release.set()
733
+ await asyncio.sleep(0.05)
734
+ response = await client.post(
735
+ "/threads/t1/assistant-transport/api/resume",
736
+ json={"commands": [], "state": {}, "threadId": "t1"},
737
+ )
738
+ assert response.headers["x-stream-status"] == "completed"
739
+ ops = state_ops(data_stream_frames(response.text))
740
+ assert ops[0]["value"]["text"] == "ab"
741
+
742
+
600
743
  class RunHarness(AssistantTransport):
601
744
  """Host speaking the canonical run/* dialect; no legacy handlers."""
602
745
 
@@ -992,6 +1135,58 @@ async def test_add_message_with_a_resolvable_parent_is_normalized_to_run_edit():
992
1135
  assert params["message"]["parts"] == [{"type": "text", "text": "again"}]
993
1136
 
994
1137
 
1138
+ async def test_add_message_with_a_source_id_is_normalized_to_run_edit_with_it():
1139
+ async with statewire_client(AnchoredHarness) as (app, client):
1140
+ response = await post_run(
1141
+ client,
1142
+ {
1143
+ "commands": [
1144
+ {
1145
+ "type": "add-message",
1146
+ "message": {
1147
+ "role": "user",
1148
+ "parts": [{"type": "text", "text": "again"}],
1149
+ },
1150
+ "sourceId": "m9",
1151
+ }
1152
+ ],
1153
+ "state": {},
1154
+ "parentId": "m1",
1155
+ },
1156
+ )
1157
+ assert response.status_code == 200
1158
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
1159
+ assert instance.steered == []
1160
+ [params] = instance.edited
1161
+ assert params["sourceId"] == "m9"
1162
+ assert params["message"]["parts"] == [{"type": "text", "text": "again"}]
1163
+
1164
+
1165
+ async def test_add_message_with_a_null_source_id_and_no_parent_is_run_steer():
1166
+ async with statewire_client(AnchoredHarness) as (app, client):
1167
+ response = await post_run(
1168
+ client,
1169
+ {
1170
+ "commands": [
1171
+ {
1172
+ "type": "add-message",
1173
+ "message": {
1174
+ "role": "user",
1175
+ "parts": [{"type": "text", "text": "hi"}],
1176
+ },
1177
+ "sourceId": None,
1178
+ }
1179
+ ],
1180
+ "state": {},
1181
+ },
1182
+ )
1183
+ assert response.status_code == 200
1184
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
1185
+ assert instance.edited == []
1186
+ [(params, _)] = instance.steered
1187
+ assert set(params) == {"message"}
1188
+
1189
+
995
1190
  async def test_add_message_with_an_unresolvable_parent_falls_back_to_run_steer():
996
1191
  async with statewire_client(AnchoredHarness) as (app, client):
997
1192
  response = await post_run(
File without changes
File without changes
File without changes
File without changes