harness-sdk-python 0.12.0__tar.gz → 0.13.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 (31) hide show
  1. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/run_manager.py +125 -50
  4. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_batches.py +69 -1
  5. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_dispatching.py +1 -1
  6. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_enqueue.py +4 -3
  7. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_input_required.py +26 -10
  8. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_outcomes.py +2 -2
  9. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_restore.py +46 -5
  10. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_stop_continue.py +59 -2
  11. harness_sdk_python-0.13.1/tests/test_stopping_window.py +263 -0
  12. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/.gitignore +0 -0
  13. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/README.md +0 -0
  14. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/examples/__init__.py +0 -0
  15. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/examples/runs_app.py +0 -0
  16. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/__init__.py +0 -0
  17. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/fenced_postgres.py +0 -0
  18. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/linear_thread.py +0 -0
  19. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/run_helpers.py +0 -0
  20. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_ack_visibility.py +0 -0
  21. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_edit_dispatched.py +0 -0
  23. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_edit_reload.py +0 -0
  24. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_fenced_postgres.py +0 -0
  25. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_linear_thread.py +0 -0
  26. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_meta.py +0 -0
  27. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_placement.py +0 -0
  28. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_prepare_hooks.py +0 -0
  29. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_rewind_during_run.py +0 -0
  30. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_settle.py +0 -0
  31. {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_steer.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.1
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.1"
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
@@ -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 "message-send"
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
  )
@@ -635,6 +683,30 @@ class RunManager:
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,7 +987,7 @@ 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
992
  "message-edit",
921
993
  [self._stamped(e.message, e.meta)],
@@ -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,7 +1042,7 @@ 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
1047
  "message-edit",
973
1048
  [self._stamped(e.message, e.meta)],
@@ -980,7 +1055,7 @@ 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
1060
  "message-reload",
986
1061
  [],
@@ -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:
@@ -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
 
@@ -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 == "message-edit"
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 == "message-send"
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")
@@ -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
 
@@ -80,7 +80,7 @@ async def test_steer_lane_drains_first_and_whole():
80
80
  await drv.wait_status("ready")
81
81
 
82
82
 
83
- async def test_error_add_with_empty_lanes_dispatches_immediately():
83
+ async def test_error_add_with_empty_lanes_dispatches_error_continue_with_messages():
84
84
  script = Script()
85
85
  async with run_host(script) as (drv, host):
86
86
  drv.post("run/enqueue", add("m1"))
@@ -90,9 +90,10 @@ async def test_error_add_with_empty_lanes_dispatches_immediately():
90
90
  await drv.wait_status("error")
91
91
  drv.post("run/enqueue", add("m2"))
92
92
  call = await script.next_call()
93
- assert call.ctx.trigger == "message-send"
93
+ assert call.ctx.trigger == "error-continue"
94
94
  assert [m["id"] for m in call.ctx.messages] == ["m2"]
95
- await drv.wait(lambda s: run_of(s).get("error") is None)
95
+ await drv.wait(lambda s: "error" not in run_of(s))
96
+ assert drv.run["dispatching"]["trigger"] == "error-continue"
96
97
 
97
98
 
98
99
  async def test_error_add_with_queued_items_stays_queued():
@@ -37,15 +37,12 @@ async def park(drv, script, requests, *, anchor=None):
37
37
  await drv.wait_status("input-required")
38
38
 
39
39
 
40
- async def test_park_replicates_pending_requests_with_null_responses():
40
+ async def test_park_replicates_pending_requests_without_response_keys():
41
41
  script = Script()
42
42
  async with run_host(script) as (drv, host):
43
43
  await park(drv, script, [tool_call("r1"), approval("r2", "tc2")])
44
44
  assert "dispatch" not in drv.replica
45
- assert drv.run["inputRequests"] == [
46
- {**tool_call("r1"), "response": None},
47
- {**approval("r2", "tc2"), "response": None},
48
- ]
45
+ assert drv.run["inputRequests"] == [tool_call("r1"), approval("r2", "tc2")]
49
46
 
50
47
 
51
48
  async def test_partial_answer_stays_parked_and_echoes_response():
@@ -57,11 +54,12 @@ async def test_partial_answer_stays_parked_and_echoes_response():
57
54
  )
58
55
  assert res["type"] == "accepted"
59
56
  await drv.wait(
60
- lambda s: run_of(s)["inputRequests"][0]["response"] is not None
57
+ lambda s: "response" in run_of(s)["inputRequests"][0]
61
58
  )
62
59
  assert drv.replica["status"] == "input-required"
63
60
  assert drv.run["inputRequests"][0]["response"] == {"output": "ok"}
64
- assert drv.run["inputRequests"][1]["response"] is None
61
+ assert "meta" not in drv.run["inputRequests"][0]
62
+ assert "response" not in drv.run["inputRequests"][1]
65
63
  script.no_call()
66
64
 
67
65
 
@@ -238,7 +236,7 @@ async def test_custom_request_type_passes_through():
238
236
  async with run_host(script) as (drv, host):
239
237
  request = {"type": "free-form", "id": "r1", "prompt": "where to?"}
240
238
  await park(drv, script, [request])
241
- assert drv.run["inputRequests"] == [{**request, "response": None}]
239
+ assert drv.run["inputRequests"] == [request]
242
240
  response = {"anything": [1, 2], "goes": True}
243
241
  assert (
244
242
  await drv.command("run/input", {"requestId": "r1", "response": response})
@@ -256,7 +254,7 @@ async def test_stop_while_parked_rejects_and_keeps_the_pending_set():
256
254
  await park(drv, script, [tool_call("r1")])
257
255
  assert_rejected(await drv.command("run/stop"), "wrong-state")
258
256
  assert drv.replica["status"] == "input-required"
259
- assert drv.run["inputRequests"] == [{**tool_call("r1"), "response": None}]
257
+ assert drv.run["inputRequests"] == [tool_call("r1")]
260
258
  await drv.command(
261
259
  "run/input", {"requestId": "r1", "response": {"output": "x"}}
262
260
  )
@@ -412,7 +410,7 @@ async def test_enqueue_while_parked_parks_and_keeps_the_pending_set():
412
410
  await drv.command("run/enqueue", add("m2"), terminal=False)
413
411
  assert queue_ids(drv.replica, "queue") == ["m2"]
414
412
  assert drv.replica["status"] == "input-required"
415
- assert drv.run["inputRequests"] == [{**tool_call("r1"), "response": None}]
413
+ assert drv.run["inputRequests"] == [tool_call("r1")]
416
414
  script.no_call()
417
415
 
418
416
 
@@ -447,3 +445,21 @@ async def test_steer_add_with_partial_answers_hands_over_the_answered_subset():
447
445
  run.ack()
448
446
  run.finish(RunManager.Complete())
449
447
  await drv.wait_status("ready")
448
+
449
+
450
+ async def test_answer_meta_replicates_only_when_carried():
451
+ script = Script()
452
+ async with run_host(script) as (drv, host):
453
+ await park(drv, script, [tool_call("r1"), approval("r2")])
454
+ await drv.command(
455
+ "run/input",
456
+ {"requestId": "r1", "response": {"output": "ok"}, "meta": {"src": "c1"}},
457
+ )
458
+ await drv.wait(lambda s: "response" in run_of(s)["inputRequests"][0])
459
+ assert drv.run["inputRequests"][0] == {
460
+ **tool_call("r1"),
461
+ "response": {"output": "ok"},
462
+ "meta": {"src": "c1"},
463
+ }
464
+ assert drv.run["inputRequests"][1] == approval("r2")
465
+ script.no_call()
@@ -61,7 +61,7 @@ async def test_state_error_cleared_by_next_user_initiated_entry():
61
61
  drv.post("run/enqueue", add("m2"))
62
62
  await script.next_call()
63
63
  await drv.wait(
64
- lambda s: s["status"] == "running" and run_of(s)["error"] is None
64
+ lambda s: s["status"] == "running" and "error" not in run_of(s)
65
65
  )
66
66
 
67
67
 
@@ -83,7 +83,7 @@ async def test_error_outcome_freezes_without_state_error():
83
83
  await drv.command("run/enqueue", add("m2"), terminal=False)
84
84
  call.finish(RunManager.Error(dispatch_queue=False))
85
85
  await drv.wait_status("error")
86
- assert drv.run["error"] is None
86
+ assert "error" not in drv.run
87
87
  assert queue_ids(drv.replica, "queue") == ["m2"]
88
88
  script.no_call()
89
89
 
@@ -7,7 +7,7 @@ raises."""
7
7
  import asyncio
8
8
 
9
9
  import pytest
10
- from run_helpers import Script, add, msg, run_host
10
+ from run_helpers import Script, add, assert_rejected, msg, run_host
11
11
 
12
12
  from harness_sdk import RunManager
13
13
 
@@ -22,9 +22,7 @@ def parked_entry(**overrides) -> dict:
22
22
  "status": "input-required",
23
23
  "queue": [],
24
24
  "steerQueue": [],
25
- "dispatching": None,
26
- "error": None,
27
- "inputRequests": [{**tool_call("r1"), "response": None}],
25
+ "inputRequests": [tool_call("r1")],
28
26
  **overrides,
29
27
  }
30
28
 
@@ -112,9 +110,23 @@ async def test_malformed_restore_states_raise():
112
110
  ({"runs": [parked_entry(queue=None)]}, "queue"),
113
111
  ({"runs": [parked_entry(steerQueue=None)]}, "steerQueue"),
114
112
  ({"runs": [parked_entry(dispatching={"trigger": "steer"})]}, "dispatching"),
113
+ ({"runs": [parked_entry(dispatching=None)]}, "dispatching"),
115
114
  ({"runs": [parked_entry(error={"message": "x"})]}, "error"),
115
+ ({"runs": [parked_entry(error=None)]}, "error"),
116
+ ({"runs": [parked_entry(stopping={"reason": "stop"})]}, "stopping"),
117
+ ({"runs": [parked_entry(nextDispatch={"trigger": "message-edit"})]}, "nextDispatch"),
116
118
  ({"runs": [parked_entry(inputRequests=[])]}, "non-empty list"),
117
119
  ({"runs": [parked_entry(inputRequests=["nope"])]}, "objects"),
120
+ (
121
+ {
122
+ "runs": [
123
+ parked_entry(
124
+ inputRequests=[{**tool_call("r1"), "response": "nope"}]
125
+ )
126
+ ]
127
+ },
128
+ "response",
129
+ ),
118
130
  (
119
131
  {
120
132
  "runs": [
@@ -125,7 +137,7 @@ async def test_malformed_restore_states_raise():
125
137
  )
126
138
  ]
127
139
  },
128
- "response",
140
+ "fully answered",
129
141
  ),
130
142
  (
131
143
  {"runs": [parked_entry(inputRequests=[{"type": "tool-call"}])]},
@@ -134,3 +146,32 @@ async def test_malformed_restore_states_raise():
134
146
  ]:
135
147
  with pytest.raises(ValueError, match=match):
136
148
  construct(state)
149
+
150
+
151
+ async def test_answered_entries_restore_and_the_last_answer_resumes():
152
+ script = Script()
153
+ entry = parked_entry(
154
+ inputRequests=[
155
+ {**tool_call("r1"), "response": {"output": "a"}, "meta": {"src": "c1"}},
156
+ tool_call("r2", "tc2"),
157
+ ]
158
+ )
159
+ async with run_host(script, initial_runs={"runs": [entry]}) as (drv, host):
160
+ assert_rejected(
161
+ await drv.command(
162
+ "run/input", {"requestId": "r1", "response": {"output": "again"}}
163
+ ),
164
+ "already-answered",
165
+ )
166
+ await drv.command(
167
+ "run/input", {"requestId": "r2", "response": {"output": "b"}}
168
+ )
169
+ resumed = await script.next_call()
170
+ assert resumed.ctx.trigger == "input-resume"
171
+ assert resumed.ctx.input_outcomes == (
172
+ (tool_call("r1"), {"output": "a"}, {"src": "c1"}),
173
+ (tool_call("r2", "tc2"), {"output": "b"}, None),
174
+ )
175
+ resumed.ack()
176
+ resumed.finish(RunManager.Complete())
177
+ await drv.wait_status("ready")
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
 
3
3
  import pytest
4
- from run_helpers import Script, add, assert_rejected, run_host, run_of
4
+ from run_helpers import Script, add, assert_rejected, queue_ids, run_host, run_of
5
5
 
6
6
  from harness_sdk import RunManager
7
7
 
@@ -20,7 +20,7 @@ async def test_stop_is_not_optimistic():
20
20
  settled = await drv.res(pending["seq"])
21
21
  assert settled["type"] == "accepted"
22
22
  await drv.wait_status("stopped")
23
- assert drv.run["error"] is None
23
+ assert "error" not in drv.run
24
24
 
25
25
 
26
26
  async def test_stop_outcome_can_keep_the_queue_draining():
@@ -279,3 +279,60 @@ async def test_continue_settles_before_the_continued_run_ends():
279
279
  continued.fail(RuntimeError("boom again"))
280
280
  await drv.wait_status("error")
281
281
  assert drv.run["error"] == {"message": "boom again"}
282
+
283
+
284
+ @pytest.mark.parametrize(
285
+ "end,continue_type",
286
+ [("error", "error-continue"), ("stopped", "stop-continue")],
287
+ )
288
+ async def test_enqueue_send_in_error_stop_dispatches_continue_with_messages(
289
+ end, continue_type
290
+ ):
291
+ script = Script()
292
+ async with run_host(script) as (drv, host):
293
+ drv.post("run/enqueue", add("m1"))
294
+ call = await script.next_call()
295
+ call.ack()
296
+ if end == "error":
297
+ call.fail(RuntimeError("boom"))
298
+ else:
299
+ await drv.stop(terminal=False)
300
+ call.finish(RunManager.Stop(dispatch_queue=False))
301
+ await drv.wait_status(end)
302
+ seq = drv.post("run/enqueue", add("m2"))
303
+ continued = await script.next_call()
304
+ assert continued.ctx.trigger == continue_type
305
+ assert [m["id"] for m in continued.ctx.messages] == ["m2"]
306
+ await drv.wait(
307
+ lambda s: (run_of(s).get("dispatching") or {}).get("trigger")
308
+ == continue_type
309
+ )
310
+ continued.ack()
311
+ assert (await drv.res(seq))["type"] == "accepted"
312
+ continued.finish(RunManager.Complete())
313
+ await drv.wait_status("ready")
314
+
315
+
316
+ async def test_same_tick_send_and_continue_dispatch_one_run_with_messages():
317
+ script = Script()
318
+ async with run_host(script, capabilities=("incomplete-continuation",)) as (
319
+ drv,
320
+ host,
321
+ ):
322
+ drv.post("run/enqueue", add("m1"))
323
+ call = await script.next_call()
324
+ call.ack()
325
+ call.fail(RuntimeError("boom"))
326
+ await drv.wait_status("error")
327
+ first = drv.batch(
328
+ [("run/continue", None), ("run/enqueue", add("m2"))]
329
+ )
330
+ continued = await script.next_call()
331
+ assert continued.ctx.trigger == "error-continue"
332
+ assert [m["id"] for m in continued.ctx.messages] == ["m2"]
333
+ script.no_call()
334
+ assert (await drv.res(first))["type"] == "accepted"
335
+ assert (await drv.res(first + 1))["type"] == "accepted"
336
+ continued.ack()
337
+ continued.finish(RunManager.Complete())
338
+ await drv.wait_status("ready")
@@ -0,0 +1,263 @@
1
+ """Contract: the stopping window. Three openers — run/stop, mid-run
2
+ edit/reload, and the enqueue/steer edit-of-dispatched form — write
3
+ ``stopping: {reason}`` (plus the staged rewind as ``nextDispatch``) in the
4
+ same envelope as the opener's pending answer, ``status`` staying "running".
5
+ On run end one envelope promotes ``nextDispatch`` into ``dispatching`` and
6
+ clears the window; a later stop/rewind supersedes the staged command, which
7
+ settles rejected (``superseded``)."""
8
+
9
+ import asyncio
10
+ import copy
11
+
12
+ import pytest
13
+ from run_helpers import (
14
+ Script,
15
+ add,
16
+ assert_rejected,
17
+ edit_params,
18
+ msg,
19
+ queue_ids,
20
+ reload_params,
21
+ run_host,
22
+ target,
23
+ )
24
+
25
+ from harness_sdk import RunManager
26
+ from statewire_helpers import apply_ops
27
+
28
+
29
+ def states(drv):
30
+ state: dict = {}
31
+ for env in copy.deepcopy(drv.envelopes):
32
+ apply_ops(state, env)
33
+ yield env, copy.deepcopy(state)
34
+
35
+
36
+ def window_of(state):
37
+ runs = state.get("runs") or [{}]
38
+ return runs[0].get("stopping"), runs[0].get("nextDispatch")
39
+
40
+
41
+ async def start_run(drv, script, *, parent="p0"):
42
+ drv.post("run/enqueue", add("m1"))
43
+ call = await script.next_call()
44
+ script.thread["m1"] = {
45
+ "parentId": parent,
46
+ "role": "user",
47
+ "isLeaf": True,
48
+ "onActiveBranch": True,
49
+ }
50
+ script.thread["a1"] = {
51
+ "parentId": "m1",
52
+ "role": "assistant",
53
+ "isLeaf": True,
54
+ "onActiveBranch": True,
55
+ }
56
+ call.ack()
57
+ return call
58
+
59
+
60
+ async def test_stop_opens_the_window_with_the_pending_answer():
61
+ script = Script()
62
+ async with run_host(script) as (drv, host):
63
+ call = await start_run(drv, script)
64
+ pending = await drv.stop(terminal=False)
65
+ assert pending["type"] == "pending"
66
+ assert drv.replica["status"] == "running"
67
+ assert drv.run["stopping"] == {"reason": "stop"}
68
+ assert "nextDispatch" not in drv.run
69
+ opened = next(
70
+ state
71
+ for env, state in states(drv)
72
+ if any(r["seq"] == pending["seq"] for r in env.get("res", []))
73
+ )
74
+ assert window_of(opened) == ({"reason": "stop"}, None)
75
+ call.finish(RunManager.Stop(dispatch_queue=False))
76
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
77
+ await drv.wait_status("stopped")
78
+ assert "stopping" not in drv.run
79
+
80
+
81
+ async def test_mid_run_edit_stages_the_window_and_promotes_in_one_envelope():
82
+ script = Script()
83
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
84
+ drv,
85
+ host,
86
+ ):
87
+ call = await start_run(drv, script)
88
+ seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
89
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
90
+ await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
91
+ assert drv.replica["status"] == "running"
92
+ assert drv.run["stopping"] == {"reason": "message-edit"}
93
+ staged = drv.run["nextDispatch"]
94
+ assert staged == {
95
+ "trigger": "message-edit",
96
+ "messages": [msg("m2")],
97
+ "rollbackTo": "p0",
98
+ }
99
+ opened = next(
100
+ state
101
+ for env, state in states(drv)
102
+ if any(r["seq"] == seq for r in env.get("res", []))
103
+ )
104
+ assert window_of(opened) == ({"reason": "message-edit"}, staged)
105
+ call.finish(RunManager.Stop(dispatch_queue=False))
106
+ rerun = await script.next_call()
107
+ assert rerun.ctx.trigger == "message-edit"
108
+ assert rerun.ctx.rollback_to == "p0"
109
+ await drv.wait(lambda s: (s["runs"][0].get("dispatching") or {}).get("trigger") == "message-edit")
110
+ trail = [
111
+ (window_of(state), (state["runs"] or [{}])[0].get("dispatching"))
112
+ for _, state in states(drv)
113
+ ]
114
+ promoted = next(
115
+ i
116
+ for i, (_, record) in enumerate(trail)
117
+ if record is not None and record["trigger"] == "message-edit"
118
+ )
119
+ assert trail[promoted][0] == (None, None)
120
+ assert trail[promoted][1] == staged
121
+ assert trail[promoted - 1][0] == ({"reason": "message-edit"}, staged)
122
+ assert (await drv.res(seq))["type"] == "accepted"
123
+ rerun.ack()
124
+ rerun.finish(RunManager.Complete())
125
+ await drv.wait_status("ready")
126
+
127
+
128
+ async def test_mid_run_reload_stages_reason_and_empty_messages():
129
+ script = Script()
130
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
131
+ drv,
132
+ host,
133
+ ):
134
+ call = await start_run(drv, script)
135
+ seq = drv.post("run/reload", reload_params("a1", anchor="m1", meta={"retry": 1}))
136
+ await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
137
+ assert drv.run["stopping"] == {"reason": "message-reload"}
138
+ assert drv.run["nextDispatch"] == {
139
+ "trigger": "message-reload",
140
+ "messages": [],
141
+ "rollbackTo": "m1",
142
+ "rootMeta": {"retry": 1},
143
+ }
144
+ call.finish(RunManager.Stop(dispatch_queue=False))
145
+ rerun = await script.next_call()
146
+ assert rerun.ctx.trigger == "message-reload"
147
+ assert rerun.ctx.meta() == {"retry": 1}
148
+ await drv.wait(lambda s: "stopping" not in (s["runs"] or [{}])[0])
149
+ assert drv.run["dispatching"]["rootMeta"] == {"retry": 1}
150
+ assert (await drv.res(seq))["type"] == "accepted"
151
+ rerun.ack()
152
+ rerun.finish(RunManager.Complete())
153
+
154
+
155
+ @pytest.mark.parametrize("command", ["run/enqueue", "run/steer"])
156
+ async def test_edit_of_dispatched_message_opens_the_window(command):
157
+ script = Script()
158
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
159
+ drv,
160
+ host,
161
+ ):
162
+ call = await start_run(drv, script)
163
+ seq = drv.post(command, target(message=msg("m1", "edited")))
164
+ await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
165
+ assert drv.replica["status"] == "running"
166
+ assert drv.run["stopping"] == {"reason": "message-edit"}
167
+ assert drv.run["nextDispatch"] == {
168
+ "trigger": "message-edit",
169
+ "messages": [msg("m1", "edited")],
170
+ "rollbackTo": "p0",
171
+ }
172
+ call.finish(RunManager.Stop(dispatch_queue=False))
173
+ rerun = await script.next_call()
174
+ assert rerun.ctx.trigger == "message-edit"
175
+ assert rerun.ctx.rollback_to == "p0"
176
+ assert (await drv.res(seq))["type"] == "accepted"
177
+ rerun.ack()
178
+ rerun.finish(RunManager.Complete())
179
+
180
+
181
+ async def test_later_rewind_supersedes_the_staged_one():
182
+ script = Script()
183
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
184
+ drv,
185
+ host,
186
+ ):
187
+ call = await start_run(drv, script)
188
+ first = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
189
+ await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
190
+ second = drv.post("run/reload", reload_params("a1", anchor="m1"))
191
+ assert_rejected(await drv.res(first), "superseded")
192
+ await drv.wait(
193
+ lambda s: s["runs"][0]["nextDispatch"]["trigger"] == "message-reload"
194
+ )
195
+ assert drv.run["stopping"] == {"reason": "message-reload"}
196
+ call.finish(RunManager.Stop(dispatch_queue=False))
197
+ rerun = await script.next_call()
198
+ assert rerun.ctx.trigger == "message-reload"
199
+ assert (await drv.res(second))["type"] == "accepted"
200
+ rerun.ack()
201
+ rerun.finish(RunManager.Complete())
202
+
203
+
204
+ async def test_stop_supersedes_a_staged_rewind():
205
+ script = Script()
206
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
207
+ drv,
208
+ host,
209
+ ):
210
+ call = await start_run(drv, script)
211
+ seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
212
+ await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
213
+ pending = await drv.stop(terminal=False)
214
+ assert_rejected(await drv.res(seq), "superseded")
215
+ await drv.wait(lambda s: (s["runs"] or [{}])[0].get("stopping") == {"reason": "stop"})
216
+ assert "nextDispatch" not in drv.run
217
+ call.finish(RunManager.Stop(dispatch_queue=False))
218
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
219
+ await drv.wait_status("stopped")
220
+ script.no_call()
221
+
222
+
223
+ async def test_sends_during_the_window_park_in_their_lanes():
224
+ script = Script()
225
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
226
+ drv,
227
+ host,
228
+ ):
229
+ call = await start_run(drv, script)
230
+ seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
231
+ await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
232
+ await drv.command("run/enqueue", add("q1", anchor="a1"), terminal=False)
233
+ await drv.command("run/steer", add("s1", anchor="a1"), terminal=False)
234
+ assert queue_ids(drv.replica, "queue") == ["q1"]
235
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
236
+ call.finish(RunManager.Stop(dispatch_queue=False))
237
+ rerun = await script.next_call()
238
+ assert rerun.ctx.trigger == "message-edit"
239
+ assert (await drv.res(seq))["type"] == "accepted"
240
+ assert queue_ids(drv.replica, "queue") == ["q1"]
241
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
242
+ rerun.ack()
243
+ rerun.finish(RunManager.Stop(dispatch_queue=False))
244
+
245
+
246
+ async def test_entry_keys_are_absent_when_empty():
247
+ script = Script()
248
+ async with run_host(script) as (drv, host):
249
+ drv.post("run/enqueue", add("m1"))
250
+ call = await script.next_call()
251
+ await drv.wait_status("running")
252
+ assert set(drv.run.keys()) == {
253
+ "runId",
254
+ "status",
255
+ "queue",
256
+ "steerQueue",
257
+ "dispatching",
258
+ }
259
+ call.ack()
260
+ await drv.wait(lambda s: "dispatching" not in s["runs"][0])
261
+ call.fail(RuntimeError("boom"))
262
+ await drv.wait_status("error")
263
+ assert set(drv.run.keys()) == {"runId", "status", "queue", "steerQueue", "error"}