harness-sdk-python 0.12.0__tar.gz → 0.13.0__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 (31) hide show
  1. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/run_manager.py +133 -58
  4. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_ack_visibility.py +1 -1
  5. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_batches.py +70 -2
  6. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_branch_anchor.py +1 -1
  7. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_dispatching.py +3 -3
  8. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_edit_dispatched.py +4 -4
  9. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_edit_reload.py +7 -7
  10. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_enqueue.py +7 -6
  11. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_input_required.py +31 -15
  12. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_meta.py +2 -2
  13. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_outcomes.py +3 -3
  14. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_prepare_hooks.py +1 -1
  15. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_restore.py +47 -6
  16. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_rewind_during_run.py +6 -6
  17. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_settle.py +1 -1
  18. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_steer.py +1 -1
  19. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_stop_continue.py +60 -3
  20. harness_sdk_python-0.13.0/tests/test_stopping_window.py +263 -0
  21. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/.gitignore +0 -0
  22. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/README.md +0 -0
  23. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/examples/__init__.py +0 -0
  24. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/examples/runs_app.py +0 -0
  25. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/__init__.py +0 -0
  26. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/fenced_postgres.py +0 -0
  27. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/linear_thread.py +0 -0
  28. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/run_helpers.py +0 -0
  29. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_fenced_postgres.py +0 -0
  30. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_placement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
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 = "harness-sdk-python"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -5,19 +5,28 @@ Implements the full state x command matrix from runs.mdx on a single
5
5
 
6
6
  Commands stage entries into an intake list and schedule a drain; the drain
7
7
  reduces the whole intake in order, then takes exactly one action based on
8
- status and net effect (interrupt, dispatch, or continue). A dispatchable send
9
- appends straight into ``dispatching`` — it never transits a queue lane — and
10
- everything staged there by the end of the drain dispatches as one run. Every
8
+ status and net effect (rewind > send-dispatch > continue > input-resume). A
9
+ dispatchable send appends straight into ``dispatching`` — it never transits a
10
+ queue lane — and everything staged there by the end of the drain dispatches
11
+ as one run; in error/stop the dispatch is the continuation
12
+ (``error-continue`` / ``stop-continue``) carrying the send's messages. Every
11
13
  initiator acks and settles accepted with its state-visible application —
12
14
  sends with their insert (lane or ``dispatching``), edit/reload/continue at
13
15
  their dispatch; ``run/stop`` awaits a future the drain resolves once the
14
16
  in-flight run has ended.
15
17
 
16
- ``entry["dispatching"]`` is the dispatch record (or None): the payload the
17
- run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
18
- refill it — at most one unacked batch ever exists. Settle consumes the
19
- delivered batch, acked or not; steer entries still undelivered in the lane
20
- stay queued for the next run.
18
+ ``entry["dispatching"]`` is the dispatch record (absent between an ack and
19
+ the next dispatch or take): the payload the run callback receives.
20
+ ``ctx.ack()`` clears it; ``steering.take()`` may refill it — at most one
21
+ unacked batch ever exists. Settle consumes the delivered batch, acked or not;
22
+ steer entries still undelivered in the lane stay queued for the next run.
23
+
24
+ A mid-run stop or rewind opens a stopping window on the entry:
25
+ ``stopping: {reason}`` plus, for rewinds, the staged dispatch as
26
+ ``nextDispatch`` — written in the same envelope as the opener's pending
27
+ answer. On run end one envelope promotes ``nextDispatch`` into
28
+ ``dispatching`` and clears the window; a later stop/rewind supersedes the
29
+ staged one, which settles rejected (``superseded``).
21
30
  """
22
31
 
23
32
  import asyncio
@@ -44,9 +53,9 @@ _CAPABILITIES = frozenset(
44
53
  )
45
54
 
46
55
  _TRIGGERS = (
47
- "message-send",
48
- "message-edit",
49
- "message-reload",
56
+ "send-message",
57
+ "edit-message",
58
+ "reload-message",
50
59
  "input-resume",
51
60
  "error-continue",
52
61
  "stop-continue",
@@ -140,6 +149,7 @@ class _Rewind:
140
149
  future: "asyncio.Future[Any]"
141
150
  run_id: str
142
151
  root_meta: Any = None
152
+ acked: bool = False
143
153
 
144
154
 
145
155
  @dataclass
@@ -201,7 +211,7 @@ class RunManager:
201
211
  self._outcome: Any = None
202
212
  self._staged_stops: list["asyncio.Future[Any]"] = []
203
213
  self._stop_reason: str | None = None
204
- self._staged_rewinds: list[_Rewind] = []
214
+ self._staged_rewind: _Rewind | None = None
205
215
  self._dispatching: list[tuple[str, dict[str, Any]]] = []
206
216
  self._input_requests: list[dict[str, Any]] = []
207
217
  self._input_answers: dict[str, Any] = {}
@@ -248,26 +258,33 @@ class RunManager:
248
258
  for lane in ("queue", "steerQueue"):
249
259
  if not isinstance(entry.get(lane), list):
250
260
  raise ValueError(f"the restored {lane} must be a list")
251
- for key in ("dispatching", "error"):
252
- if entry.get(key, _ABSENT) is not None:
253
- raise ValueError(f"the restored entry must carry {key}: None")
261
+ for key in ("dispatching", "error", "stopping", "nextDispatch"):
262
+ if key in entry:
263
+ raise ValueError(f"the restored entry must not carry {key}")
254
264
  requests = entry.get("inputRequests")
255
265
  if not isinstance(requests, list) or not requests:
256
266
  raise ValueError("the restored inputRequests must be a non-empty list")
267
+ answers: dict[str, Any] = {}
257
268
  for request in requests:
258
269
  if not isinstance(request, dict):
259
270
  raise ValueError("restored input requests must be objects")
260
- if request.get("response") is not None:
261
- raise ValueError(
262
- "restored input requests must carry response: None"
271
+ if "response" in request:
272
+ if not isinstance(request["response"], dict):
273
+ raise ValueError("a restored response must be an object")
274
+ answers[request.get("id")] = (
275
+ request["response"],
276
+ request.get("meta"),
263
277
  )
264
278
  outcome = RunManager.InputRequired(
265
279
  tuple(
266
- {k: v for k, v in request.items() if k != "response"}
280
+ {k: v for k, v in request.items() if k not in ("response", "meta")}
267
281
  for request in requests
268
282
  )
269
283
  )
284
+ if len(answers) == len(requests):
285
+ raise ValueError("a fully answered pending set cannot restore")
270
286
  self._input_requests = [dict(r) for r in outcome.requests]
287
+ self._input_answers = answers
271
288
  self._state["status"] = "input-required"
272
289
 
273
290
  # ─── State access ───────────────────────────────────────
@@ -287,8 +304,6 @@ class RunManager:
287
304
  "status": "ready",
288
305
  "queue": [],
289
306
  "steerQueue": [],
290
- "dispatching": None,
291
- "error": None,
292
307
  }
293
308
  )
294
309
  return self._state["runs"][0]
@@ -345,12 +360,14 @@ class RunManager:
345
360
  self._staged_stops.clear()
346
361
  if isinstance(outcome, RunManager.Complete):
347
362
  self._dispatched_ids = ()
348
- if self._staged_rewinds:
349
- rewind = self._staged_rewinds.pop(0)
363
+ if self._staged_rewind is not None:
364
+ rewind, self._staged_rewind = self._staged_rewind, None
350
365
  self._clear_input()
351
366
  if self._dispatching:
352
367
  self._unstage_dispatching()
353
368
  entry = self._ensure_entry()
369
+ entry.pop("stopping", None)
370
+ entry.pop("nextDispatch", None)
354
371
  if entry["runId"] is None:
355
372
  entry["runId"] = rewind.run_id
356
373
  self._dispatch(
@@ -359,14 +376,17 @@ class RunManager:
359
376
  rollback_to=rewind.rollback_to,
360
377
  root_meta=rewind.root_meta,
361
378
  )
362
- rewind.ack()
379
+ if not rewind.acked:
380
+ rewind.ack()
363
381
  if not rewind.future.done():
364
382
  rewind.future.set_result(None)
365
383
  elif outcome is not None:
366
384
  self._settle_outcome(outcome)
367
385
  else:
368
386
  self._idle_action(fx)
369
- if self._task is not None and (self._staged_stops or self._staged_rewinds):
387
+ if self._task is not None and (
388
+ self._staged_stops or self._staged_rewind is not None
389
+ ):
370
390
  assert self._ctx is not None
371
391
  self._ctx.stop_requested.set()
372
392
  for future, ack, result in fx.staged_sends:
@@ -382,6 +402,11 @@ class RunManager:
382
402
  def _apply(self, entry: Any, fx: _Effects) -> None:
383
403
  if isinstance(entry, _Stop):
384
404
  self._staged_stops.append(entry.future)
405
+ if self._task is not None:
406
+ if self._staged_rewind is not None:
407
+ self._supersede()
408
+ self._entry().pop("nextDispatch", None)
409
+ self._entry()["stopping"] = {"reason": "stop"}
385
410
  return
386
411
  if isinstance(entry, _Continue):
387
412
  if not fx.continue_requested:
@@ -413,10 +438,10 @@ class RunManager:
413
438
  if self._dispatching:
414
439
  self._dispatch_staged()
415
440
  elif status in ("error", "stopped"):
416
- if fx.continue_requested or fx.steer_added:
417
- self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
418
- elif self._dispatching:
441
+ if self._dispatching:
419
442
  self._dispatch_staged()
443
+ elif fx.continue_requested or fx.steer_added:
444
+ self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
420
445
  elif status == "input-required":
421
446
  if self._dispatching:
422
447
  self._dispatch_staged(input_outcomes=self._take_input_outcomes())
@@ -438,9 +463,7 @@ class RunManager:
438
463
  self._input_requests = [dict(r) for r in outcome.requests]
439
464
  self._input_answers = {}
440
465
  entry = self._entry()
441
- entry["inputRequests"] = [
442
- {**r, "response": None} for r in outcome.requests
443
- ]
466
+ entry["inputRequests"] = [dict(r) for r in outcome.requests]
444
467
  self._set_status("input-required")
445
468
  return
446
469
  status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
@@ -465,6 +488,27 @@ class RunManager:
465
488
  self._clear_input()
466
489
  return outcomes
467
490
 
491
+ def _record_of(
492
+ self,
493
+ trigger: str,
494
+ items: list[dict[str, Any]],
495
+ *,
496
+ rollback_to: Any = _ABSENT,
497
+ root_meta: Any = None,
498
+ input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = (),
499
+ ) -> dict[str, Any]:
500
+ record: dict[str, Any] = {"trigger": trigger, "messages": items}
501
+ if rollback_to is not _ABSENT:
502
+ record["rollbackTo"] = rollback_to
503
+ if root_meta is not None:
504
+ record["rootMeta"] = root_meta
505
+ if input_outcomes:
506
+ record["inputOutcomes"] = [
507
+ {"request": request, "response": response, "meta": meta}
508
+ for request, response, meta in input_outcomes
509
+ ]
510
+ return record
511
+
468
512
  def _dispatch(
469
513
  self,
470
514
  trigger: str,
@@ -483,19 +527,18 @@ class RunManager:
483
527
  messages = [
484
528
  {k: v for k, v in message.items() if k != "meta"} for message in messages
485
529
  ]
486
- record: dict[str, Any] = {"trigger": trigger, "messages": items}
487
- if rollback_to is not _ABSENT:
488
- record["rollbackTo"] = rollback_to
489
- if input_outcomes:
490
- record["inputOutcomes"] = [
491
- {"request": request, "response": response, "meta": meta}
492
- for request, response, meta in input_outcomes
493
- ]
530
+ record = self._record_of(
531
+ trigger,
532
+ items,
533
+ rollback_to=rollback_to,
534
+ root_meta=root_meta,
535
+ input_outcomes=input_outcomes,
536
+ )
494
537
  self._dispatch_record = dict(record)
495
538
  self._stop_reason = None
496
539
  entry = self._ensure_entry()
497
540
  entry["dispatching"] = record
498
- entry["error"] = None
541
+ entry.pop("error", None)
499
542
  if messages:
500
543
  self._dispatched_ids = tuple(m["id"] for m in messages)
501
544
  self._set_status("running")
@@ -555,30 +598,31 @@ class RunManager:
555
598
  self._dispatch_record = None
556
599
  self._dispatching = []
557
600
  entry = self._ensure_entry()
558
- record = plain(entry["dispatching"])
601
+ record = plain(entry.get("dispatching"))
559
602
  if record is not None and record["trigger"] != "steer" and record["messages"]:
560
603
  self._dispatched_ids = ()
561
- entry["dispatching"] = None
604
+ entry.pop("dispatching", None)
605
+ entry.pop("stopping", None)
562
606
 
563
607
  def _ack(self) -> None:
564
608
  entry = self._entry()
565
- record = plain(entry["dispatching"])
609
+ record = plain(entry.get("dispatching"))
566
610
  if record is None:
567
611
  raise RuntimeError("ack() with no unacked batch")
568
612
  self._dispatching = []
569
- entry["dispatching"] = None
613
+ entry.pop("dispatching", None)
570
614
 
571
615
  def _unstage_dispatching(self) -> None:
572
616
  taken, self._dispatching = self._dispatching, []
573
617
  entry = self._ensure_entry()
574
- entry["dispatching"] = None
618
+ entry.pop("dispatching", None)
575
619
  for lane in ("steerQueue", "queue"):
576
620
  front = [item for taken_lane, item in taken if taken_lane == lane]
577
621
  if front:
578
622
  entry[lane] = front + self._lane_items(lane)
579
623
 
580
624
  def _dispatchable(self, lane: str) -> bool:
581
- if self._task is not None or self._staged_rewinds:
625
+ if self._task is not None or self._staged_rewind is not None:
582
626
  return False
583
627
  if self._dispatching:
584
628
  return True
@@ -595,6 +639,10 @@ class RunManager:
595
639
  )
596
640
  return False
597
641
 
642
+ def _send_trigger(self) -> str:
643
+ status = self._status()
644
+ return self._continue_type() if status in ("error", "stopped") else "send-message"
645
+
598
646
  def _stage_dispatch(self, e: _Send) -> None:
599
647
  assert e.message is not None
600
648
  steer = self._lane_items("steerQueue") if e.lane == "steerQueue" else []
@@ -605,7 +653,7 @@ class RunManager:
605
653
  self._dispatching.extend(("steerQueue", item) for item in steer)
606
654
  self._dispatching.append((e.lane, self._stamped(e.message, e.meta)))
607
655
  self._ensure_entry()["dispatching"] = {
608
- "trigger": "message-send",
656
+ "trigger": self._send_trigger(),
609
657
  "messages": [item for _, item in self._dispatching],
610
658
  }
611
659
 
@@ -613,7 +661,7 @@ class RunManager:
613
661
  self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
614
662
  ) -> None:
615
663
  self._dispatch(
616
- "message-send",
664
+ self._send_trigger(),
617
665
  [item for _, item in self._dispatching],
618
666
  input_outcomes=input_outcomes,
619
667
  )
@@ -625,16 +673,40 @@ class RunManager:
625
673
  if steer:
626
674
  self._entry()["steerQueue"] = []
627
675
  self._dispatching = [("steerQueue", item) for item in steer]
628
- self._dispatch("message-send", steer, input_outcomes=input_outcomes)
676
+ self._dispatch("send-message", steer, input_outcomes=input_outcomes)
629
677
  return True
630
678
  queue = self._lane_items("queue")
631
679
  if queue:
632
680
  self._entry()["queue"].pop(0)
633
681
  self._dispatching = [("queue", queue[0])]
634
- self._dispatch("message-send", [queue[0]], input_outcomes=input_outcomes)
682
+ self._dispatch("send-message", [queue[0]], input_outcomes=input_outcomes)
635
683
  return True
636
684
  return False
637
685
 
686
+ def _supersede(self) -> None:
687
+ rewind, self._staged_rewind = self._staged_rewind, None
688
+ assert rewind is not None
689
+ if not rewind.future.done():
690
+ rewind.future.set_exception(
691
+ _reject("superseded", "a later stop/rewind replaced this command")
692
+ )
693
+
694
+ def _stage_rewind(self, rewind: _Rewind) -> None:
695
+ if self._staged_rewind is not None:
696
+ self._supersede()
697
+ self._staged_rewind = rewind
698
+ if self._task is not None:
699
+ entry = self._entry()
700
+ entry["stopping"] = {"reason": rewind.type}
701
+ entry["nextDispatch"] = self._record_of(
702
+ rewind.type,
703
+ list(rewind.messages),
704
+ rollback_to=rewind.rollback_to,
705
+ root_meta=rewind.root_meta,
706
+ )
707
+ rewind.ack()
708
+ rewind.acked = True
709
+
638
710
  def _freeze(self, message: str, payload: Any = None) -> None:
639
711
  self._set_status("error")
640
712
  entry = self._entry()
@@ -915,9 +987,9 @@ class RunManager:
915
987
  if e.source_meta is None:
916
988
  raise _reject("unknown-id", f"message {e.message_id} is unknown")
917
989
  self._check_anchor(e)
918
- self._staged_rewinds.append(
990
+ self._stage_rewind(
919
991
  _Rewind(
920
- "message-edit",
992
+ "edit-message",
921
993
  [self._stamped(e.message, e.meta)],
922
994
  e.source_meta["parentId"],
923
995
  e.ack,
@@ -956,7 +1028,10 @@ class RunManager:
956
1028
  f"input request {e.request_id} is already answered",
957
1029
  )
958
1030
  self._input_answers[e.request_id] = (e.response, e.meta)
959
- self._entry()["inputRequests"][index]["response"] = e.response
1031
+ replicated = self._entry()["inputRequests"][index]
1032
+ replicated["response"] = e.response
1033
+ if e.meta is not None:
1034
+ replicated["meta"] = e.meta
960
1035
  return None
961
1036
 
962
1037
  def _apply_edit(self, e: _Edit) -> Any:
@@ -967,9 +1042,9 @@ class RunManager:
967
1042
  raise _reject(
968
1043
  "duplicate-id", f"message id {e.message['id']} is already used"
969
1044
  )
970
- self._staged_rewinds.append(
1045
+ self._stage_rewind(
971
1046
  _Rewind(
972
- "message-edit",
1047
+ "edit-message",
973
1048
  [self._stamped(e.message, e.meta)],
974
1049
  e.source_meta["parentId"],
975
1050
  e.ack,
@@ -980,9 +1055,9 @@ class RunManager:
980
1055
  return _PARKED
981
1056
 
982
1057
  def _apply_reload(self, e: _Reload) -> Any:
983
- self._staged_rewinds.append(
1058
+ self._stage_rewind(
984
1059
  _Rewind(
985
- "message-reload",
1060
+ "reload-message",
986
1061
  [],
987
1062
  e.source_meta["parentId"],
988
1063
  e.ack,
@@ -1280,7 +1355,7 @@ class RunManager:
1280
1355
  self._ctx._ensure_active()
1281
1356
  manager = self._ctx._manager
1282
1357
  entry = manager._entry()
1283
- if plain(entry["dispatching"]) is not None:
1358
+ if plain(entry.get("dispatching")) is not None:
1284
1359
  raise RuntimeError("take before the current batch is acked")
1285
1360
  items = manager._lane_items("steerQueue")
1286
1361
  if not items:
@@ -91,7 +91,7 @@ async def test_rewind_edit_acks_with_its_dispatch():
91
91
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
92
92
  seq = drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
93
93
  rerun = await script.next_call()
94
- assert rerun.ctx.trigger == "message-edit"
94
+ assert rerun.ctx.trigger == "edit-message"
95
95
  await drv.wait_status("running")
96
96
  assert dispatching_ids(covered_state(drv, seq)) == ["u2"]
97
97
  host.live.state["messages"] = [msg("u2")]
@@ -5,7 +5,7 @@ drain takes exactly one action; late-settling commands ride a later envelope.
5
5
  import asyncio
6
6
  from typing import Any
7
7
 
8
- from run_helpers import Script, add, queue_ids, run_host
8
+ from run_helpers import Script, add, edit_params, msg, queue_ids, run_host
9
9
 
10
10
  from harness_sdk import RunManager
11
11
 
@@ -21,7 +21,7 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
21
21
  ]
22
22
  )
23
23
  call = await script.next_call()
24
- assert call.ctx.trigger == "message-send"
24
+ assert call.ctx.trigger == "send-message"
25
25
  assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
26
26
  script.no_call()
27
27
  assert queue_ids(drv.replica, "steerQueue") == []
@@ -114,3 +114,71 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
114
114
  assert dispatch_env.get("ack", 0) >= seq
115
115
  assert (await drv.res(seq))["type"] == "accepted"
116
116
  call.ack()
117
+
118
+
119
+ async def test_rewind_outranks_send_and_continue_in_one_batch():
120
+ script = Script()
121
+ async with run_host(
122
+ script, capabilities=("rewind", "incomplete-continuation")
123
+ ) as (drv, host):
124
+ drv.post("run/enqueue", add("m1"))
125
+ call = await script.next_call()
126
+ script.thread["m1"] = {
127
+ "parentId": None,
128
+ "role": "user",
129
+ "isLeaf": True,
130
+ "onActiveBranch": True,
131
+ }
132
+ script.thread["a1"] = {
133
+ "parentId": "m1",
134
+ "role": "assistant",
135
+ "isLeaf": True,
136
+ "onActiveBranch": True,
137
+ }
138
+ call.ack()
139
+ call.fail(RuntimeError("boom"))
140
+ await drv.wait_status("error")
141
+ first = drv.batch(
142
+ [
143
+ ("run/enqueue", add("q1", anchor="a1")),
144
+ ("run/edit", edit_params("m1", msg("m2"), anchor=None)),
145
+ ("run/continue", None),
146
+ ]
147
+ )
148
+ rerun = await script.next_call()
149
+ assert rerun.ctx.trigger == "edit-message"
150
+ assert [m["id"] for m in rerun.ctx.messages] == ["m2"]
151
+ script.no_call()
152
+ assert (await drv.res(first))["type"] == "accepted"
153
+ assert (await drv.res(first + 1))["type"] == "accepted"
154
+ assert (await drv.res(first + 2))["type"] == "accepted"
155
+ assert queue_ids(drv.replica, "queue") == ["q1"]
156
+ rerun.ack()
157
+ rerun.finish(RunManager.Complete())
158
+
159
+
160
+ async def test_send_outranks_the_completing_input_answer():
161
+ script = Script()
162
+ async with run_host(script) as (drv, host):
163
+ drv.post("run/enqueue", add("m1"))
164
+ call = await script.next_call()
165
+ call.ack()
166
+ request = {"type": "tool-call", "id": "r1", "toolCallId": "tc1"}
167
+ call.finish(RunManager.InputRequired((request,)))
168
+ await drv.wait_status("input-required")
169
+ first = drv.batch(
170
+ [
171
+ ("run/input", {"requestId": "r1", "response": {"output": "ok"}}),
172
+ ("run/steer", add("s1")),
173
+ ]
174
+ )
175
+ run = await script.next_call()
176
+ assert run.ctx.trigger == "send-message"
177
+ assert [m["id"] for m in run.ctx.messages] == ["s1"]
178
+ assert run.ctx.input_outcomes == ((request, {"output": "ok"}, None),)
179
+ script.no_call()
180
+ assert (await drv.res(first))["type"] == "accepted"
181
+ assert (await drv.res(first + 1))["type"] == "accepted"
182
+ run.ack()
183
+ run.finish(RunManager.Complete())
184
+ await drv.wait_status("ready")
@@ -196,7 +196,7 @@ async def test_post_reload_sends_anchor_on_the_reload_targets_parent():
196
196
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
197
197
  drv.post("run/reload", reload_params("a1", anchor="u1"))
198
198
  call = await script.next_call()
199
- assert call.ctx.trigger == "message-reload"
199
+ assert call.ctx.trigger == "reload-message"
200
200
  call.ack()
201
201
  script.thread["a1"] = {
202
202
  "parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
@@ -17,7 +17,7 @@ from statewire_helpers import apply_ops
17
17
 
18
18
  def dispatching_ids(replica):
19
19
  runs = replica["runs"]
20
- record = runs[0]["dispatching"] if runs else None
20
+ record = runs[0].get("dispatching") if runs else None
21
21
  return [item["id"] for item in record["messages"]] if record else []
22
22
 
23
23
 
@@ -58,7 +58,7 @@ async def test_idle_send_dispatches_straight_into_dispatching():
58
58
  drv.post("run/enqueue", add("m1"))
59
59
  await script.next_call()
60
60
  await drv.wait_status("running")
61
- assert drv.run["dispatching"]["trigger"] == "message-send"
61
+ assert drv.run["dispatching"]["trigger"] == "send-message"
62
62
  assert dispatching_ids(drv.replica) == ["m1"]
63
63
  assert queue_ids(drv.replica, "queue") == []
64
64
  assert residence(drv, "m1") == ["dispatching"]
@@ -119,7 +119,7 @@ async def test_edit_replacement_rides_dispatching_until_ack():
119
119
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
120
120
  seq = drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
121
121
  rerun = await script.next_call()
122
- assert rerun.ctx.trigger == "message-edit"
122
+ assert rerun.ctx.trigger == "edit-message"
123
123
  await drv.wait_status("running")
124
124
  assert dispatching_ids(drv.replica) == ["u2"]
125
125
  host.live.state["messages"] = [msg("u2")]
@@ -19,7 +19,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
19
19
  await drv.wait_status("running")
20
20
  call.finish(RunManager.Stop(dispatch_queue=False))
21
21
  rerun = await script.next_call()
22
- assert rerun.ctx.trigger == "message-edit"
22
+ assert rerun.ctx.trigger == "edit-message"
23
23
  assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
24
24
  assert rerun.ctx.has_rollback
25
25
  assert rerun.ctx.rollback_to == "p0"
@@ -54,7 +54,7 @@ async def test_edit_of_dispatched_non_leaf_stops_and_reruns(command):
54
54
  seq = drv.post(command, target(message=msg("m1", "edited")))
55
55
  call.finish(RunManager.Stop(dispatch_queue=False))
56
56
  rerun = await script.next_call()
57
- assert rerun.ctx.trigger == "message-edit"
57
+ assert rerun.ctx.trigger == "edit-message"
58
58
  assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
59
59
  rerun.ack()
60
60
  rerun.finish(RunManager.Stop(dispatch_queue=False))
@@ -72,7 +72,7 @@ async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
72
72
  seq = drv.post("run/enqueue", target(message=msg("m1", "edited")))
73
73
  call.finish(RunManager.Stop(dispatch_queue=False))
74
74
  rerun = await script.next_call()
75
- assert rerun.ctx.trigger == "message-edit"
75
+ assert rerun.ctx.trigger == "edit-message"
76
76
  rerun.ack()
77
77
  rerun.finish(RunManager.Stop(dispatch_queue=False))
78
78
  assert (await drv.res(seq))["type"] == "accepted"
@@ -90,5 +90,5 @@ async def test_edit_of_dispatched_item_in_error_reruns_without_stop():
90
90
  await drv.wait_status("error")
91
91
  drv.post("run/enqueue", target(message=msg("m1", "edited")))
92
92
  rerun = await script.next_call()
93
- assert rerun.ctx.trigger == "message-edit"
93
+ assert rerun.ctx.trigger == "edit-message"
94
94
  assert rerun.ctx.rollback_to is None
@@ -27,7 +27,7 @@ async def test_edit_in_ready_dispatches_message_edit():
27
27
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
28
28
  drv.post("run/edit", edit_params("u1", msg("u2", "v2"), anchor=None))
29
29
  call = await script.next_call()
30
- assert call.ctx.trigger == "message-edit"
30
+ assert call.ctx.trigger == "edit-message"
31
31
  assert [m["id"] for m in call.ctx.messages] == ["u2"]
32
32
  assert call.ctx.has_rollback
33
33
  assert call.ctx.rollback_to is None
@@ -71,7 +71,7 @@ async def test_deep_edit_with_queue_non_empty_reruns_and_keeps_queue():
71
71
  await drv.wait_status("error")
72
72
  drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
73
73
  rerun = await script.next_call()
74
- assert rerun.ctx.trigger == "message-edit"
74
+ assert rerun.ctx.trigger == "edit-message"
75
75
  assert rerun.ctx.rollback_to is None
76
76
  assert [item["id"] for item in drv.run["queue"]] == ["m2"]
77
77
 
@@ -88,7 +88,7 @@ async def test_leaf_edit_in_error_accepted_with_queue_non_empty():
88
88
  await drv.wait_status("error")
89
89
  drv.post("run/edit", edit_params("m1", msg("u2"), anchor=None))
90
90
  call = await script.next_call()
91
- assert call.ctx.trigger == "message-edit"
91
+ assert call.ctx.trigger == "edit-message"
92
92
  assert call.ctx.rollback_to is None
93
93
  assert [item["id"] for item in drv.run["queue"]] == ["m2"]
94
94
 
@@ -140,7 +140,7 @@ async def test_reload_dispatches_with_empty_messages():
140
140
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
141
141
  drv.post("run/reload", reload_params("a1", anchor="u1"))
142
142
  call = await script.next_call()
143
- assert call.ctx.trigger == "message-reload"
143
+ assert call.ctx.trigger == "reload-message"
144
144
  assert call.ctx.messages == ()
145
145
  assert call.ctx.rollback_to == "u1"
146
146
  call.ack()
@@ -205,7 +205,7 @@ async def test_deep_reload_with_queue_non_empty_reruns_and_keeps_queue():
205
205
  await drv.wait_status("error")
206
206
  drv.post("run/reload", reload_params("a0", anchor="u0"))
207
207
  call = await script.next_call()
208
- assert call.ctx.trigger == "message-reload"
208
+ assert call.ctx.trigger == "reload-message"
209
209
  assert call.ctx.rollback_to == "u0"
210
210
  assert [item["id"] for item in drv.run["queue"]] == ["m2"]
211
211
 
@@ -260,7 +260,7 @@ async def test_edit_anchor_assertion():
260
260
  script.no_call()
261
261
  drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
262
262
  call = await script.next_call()
263
- assert call.ctx.trigger == "message-edit"
263
+ assert call.ctx.trigger == "edit-message"
264
264
  assert call.ctx.rollback_to is None
265
265
 
266
266
 
@@ -283,5 +283,5 @@ async def test_reload_anchor_assertion():
283
283
  script.no_call()
284
284
  drv.post("run/reload", reload_params("a1", anchor="u1"))
285
285
  call = await script.next_call()
286
- assert call.ctx.trigger == "message-reload"
286
+ assert call.ctx.trigger == "reload-message"
287
287
  assert call.ctx.rollback_to == "u1"