harness-sdk-python 0.3.0__tar.gz → 0.4.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 (23) hide show
  1. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/.gitignore +3 -0
  2. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/PKG-INFO +2 -2
  3. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/pyproject.toml +2 -2
  4. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/run_manager.py +157 -12
  5. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/run_helpers.py +8 -1
  6. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_batches.py +19 -16
  7. harness_sdk_python-0.4.0/tests/test_branch_anchor.py +145 -0
  8. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_caller.py +22 -22
  9. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_edit_dispatched.py +12 -10
  10. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_edit_reload.py +43 -43
  11. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_enqueue.py +42 -42
  12. harness_sdk_python-0.4.0/tests/test_facade.py +84 -0
  13. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_input_required.py +11 -11
  14. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_outcomes.py +12 -12
  15. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_placement.py +19 -19
  16. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_rewind_during_run.py +18 -15
  17. harness_sdk_python-0.4.0/tests/test_settle.py +131 -0
  18. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_steer.py +17 -17
  19. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_stop_continue.py +16 -16
  20. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/README.md +0 -0
  21. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/__init__.py +0 -0
  22. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/fenced_postgres.py +0 -0
  23. {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_fenced_postgres.py +0 -0
@@ -9,6 +9,9 @@ dist-test
9
9
  .dev.vars
10
10
  *.tsbuildinfo
11
11
  .harness-runs
12
+ *.rdb
13
+ .source
14
+ next-env.d.ts
12
15
  __pycache__
13
16
  .venv
14
17
  .pytest_cache
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.3.0
3
+ Version: 0.4.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
7
7
  Requires-Python: <4.0,>=3.11
8
- Requires-Dist: statewire<0.4,>=0.3.0
8
+ Requires-Dist: statewire<0.5,>=0.4.0
9
9
  Provides-Extra: postgres
10
10
  Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
11
11
  Description-Content-Type: text/markdown
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.11,<4.0"
8
- dependencies = ["statewire>=0.3.0,<0.4"]
8
+ dependencies = ["statewire>=0.4.0,<0.5"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -45,7 +45,8 @@ _ENTRY_TYPES = (
45
45
 
46
46
  _DECISIONS = ("approve", "reject", "edit", "respond")
47
47
 
48
- GetMessageMeta = Callable[[str], Awaitable[dict[str, Any] | None]]
48
+ # Called with None it answers for the thread root: isLeaf True iff the thread is empty.
49
+ GetMessageMeta = Callable[[str | None], Awaitable[dict[str, Any] | None]]
49
50
 
50
51
 
51
52
  def _reject(reason: str, message: str) -> StatewireReject:
@@ -65,6 +66,9 @@ class _Send:
65
66
  meta: dict[str, Any] | None
66
67
  caller: StatewireClientHandle | None
67
68
  ack: Callable[[], None]
69
+ anchor: Any = _ABSENT
70
+ anchor_meta: dict[str, Any] | None = None
71
+ thread_empty: bool = False
68
72
  future: "asyncio.Future[Any]" = field(default_factory=_future)
69
73
 
70
74
 
@@ -86,6 +90,7 @@ class _Edit:
86
90
  @dataclass
87
91
  class _Reload:
88
92
  source_meta: dict[str, Any]
93
+ message_id: str
89
94
  ack: Callable[[], None]
90
95
  future: "asyncio.Future[Any]" = field(default_factory=_future)
91
96
 
@@ -114,6 +119,7 @@ class _Rewind:
114
119
  rollback_to: Any
115
120
  ack: Callable[[], None]
116
121
  future: "asyncio.Future[Any]"
122
+ message_id: str | None = None
117
123
  acked: bool = False
118
124
 
119
125
 
@@ -154,6 +160,7 @@ class RunManager:
154
160
  self._task: "asyncio.Task[Any] | None" = None
155
161
  self._ctx: "RunManager.StartContext | None" = None
156
162
  self._dispatched_ids: tuple[str, ...] = ()
163
+ self._reload_message_id: str | None = None
157
164
  self._dispatch_record: dict[str, Any] | None = None
158
165
  self._callers: dict[str, StatewireClientHandle] = {}
159
166
  self._intake: list[Any] = []
@@ -161,6 +168,8 @@ class RunManager:
161
168
  self._staged_stops: list["asyncio.Future[Any]"] = []
162
169
  self._stop_reason: str | None = None
163
170
  self._staged_rewinds: list[_Rewind] = []
171
+ self._entity_futures: dict[str, "asyncio.Future[Any]"] = {}
172
+ self._run_futures: list["asyncio.Future[Any]"] = []
164
173
  self._input_requests: list[dict[str, Any]] = []
165
174
  self._input_answers: dict[str, Any] = {}
166
175
  self._init_state()
@@ -212,13 +221,18 @@ class RunManager:
212
221
  self._staged_stops.clear()
213
222
  if isinstance(outcome, RunManager.Complete):
214
223
  self._dispatched_ids = ()
224
+ self._reload_message_id = None
215
225
  if self._staged_rewinds:
216
226
  rewind = self._staged_rewinds.pop(0)
217
227
  self._dispatch(
218
- rewind.type, rewind.messages, rollback_to=rewind.rollback_to
228
+ rewind.type,
229
+ rewind.messages,
230
+ rollback_to=rewind.rollback_to,
231
+ message_id=rewind.message_id,
219
232
  )
233
+ rewind.ack()
220
234
  if not rewind.future.done():
221
- rewind.future.set_result(None)
235
+ self._run_futures.append(rewind.future)
222
236
  elif outcome is not None:
223
237
  self._settle_outcome(outcome)
224
238
  else:
@@ -307,6 +321,7 @@ class RunManager:
307
321
  *,
308
322
  rollback_to: Any = _ABSENT,
309
323
  responses: Any = _ABSENT,
324
+ message_id: str | None = None,
310
325
  ) -> None:
311
326
  if type not in _ENTRY_TYPES:
312
327
  raise ValueError(f"invalid entry type: {type!r}")
@@ -318,16 +333,20 @@ class RunManager:
318
333
  ]
319
334
  for message in messages:
320
335
  self._callers.pop(message["id"], None)
336
+ self._adopt_entities(message["id"] for message in messages)
321
337
  record: dict[str, Any] = {"type": type, "messages": list(messages)}
322
338
  if rollback_to is not _ABSENT:
323
339
  record["rollbackTo"] = rollback_to
324
340
  if responses is not _ABSENT:
325
341
  record["responses"] = responses
342
+ if message_id is not None:
343
+ record["messageId"] = message_id
326
344
  self._dispatch_record = record
327
345
  self._stop_reason = None
328
346
  self._state["error"] = None
329
347
  if messages:
330
348
  self._dispatched_ids = tuple(m["id"] for m in messages)
349
+ self._reload_message_id = message_id
331
350
  self._state["status"] = "running"
332
351
  ctx = RunManager.StartContext(
333
352
  type=type,
@@ -337,6 +356,7 @@ class RunManager:
337
356
  _manager=self,
338
357
  _rollback_to=rollback_to,
339
358
  _responses=responses,
359
+ _message_id=message_id,
340
360
  )
341
361
  self._ctx = ctx
342
362
  self._task = self._create_task(self._run(ctx))
@@ -359,10 +379,21 @@ class RunManager:
359
379
  )
360
380
  except Exception as exc:
361
381
  self._settle(ctx)
362
- self._freeze(str(exc) or type(exc).__name__)
382
+ message = str(exc) or type(exc).__name__
383
+ if isinstance(exc, StatewireReject):
384
+ self._freeze(exc.message, exc.payload)
385
+ self._settle_entities(exc)
386
+ else:
387
+ self._freeze(message)
388
+ self._settle_entities(_reject("run-error", message))
363
389
  self._drain()
364
390
  return
365
391
  self._settle(ctx)
392
+ self._settle_entities(
393
+ _reject("run-error", "run ended in error")
394
+ if isinstance(outcome, RunManager.Error)
395
+ else None
396
+ )
366
397
  self._outcome = outcome
367
398
  self._drain()
368
399
 
@@ -372,6 +403,22 @@ class RunManager:
372
403
  self._task = None
373
404
  self._dispatch_record = None
374
405
 
406
+ def _settle_entities(self, error: StatewireReject | None) -> None:
407
+ futures, self._run_futures = self._run_futures, []
408
+ for future in futures:
409
+ if future.done():
410
+ continue
411
+ if error is None:
412
+ future.set_result(None)
413
+ else:
414
+ future.set_exception(error)
415
+
416
+ def _adopt_entities(self, ids: Iterable[str]) -> None:
417
+ for id in ids:
418
+ future = self._entity_futures.pop(id, None)
419
+ if future is not None:
420
+ self._run_futures.append(future)
421
+
375
422
  def _pop_dispatchable(self) -> bool:
376
423
  steer = self._lane_items("steerQueue")
377
424
  if steer:
@@ -385,9 +432,14 @@ class RunManager:
385
432
  return True
386
433
  return False
387
434
 
388
- def _freeze(self, message: str) -> None:
435
+ def _freeze(self, message: str, payload: Any = None) -> None:
389
436
  self._state["status"] = "error"
390
- self._state["error"] = {"message": message}
437
+ if isinstance(payload, dict):
438
+ self._state["error"] = {**payload, "message": message}
439
+ elif payload is not None:
440
+ self._state["error"] = {"message": message, "payload": payload}
441
+ else:
442
+ self._state["error"] = {"message": message}
391
443
 
392
444
  # ─── Message and placement validation ───────────────────
393
445
 
@@ -468,6 +520,24 @@ class RunManager:
468
520
  raise _reject("not-adjacent", "insertAfter and insertBefore are not adjacent")
469
521
  return slot
470
522
 
523
+ def _check_anchor(self, e: _Send) -> None:
524
+ if e.anchor is _ABSENT:
525
+ return
526
+ if e.anchor is None:
527
+ if not e.thread_empty:
528
+ raise _reject("wrong-anchor", "a null anchor asserts an empty thread")
529
+ return
530
+ if self._lane_of(e.anchor) is not None:
531
+ return
532
+ if e.anchor in self._dispatched_ids or e.anchor == self._reload_message_id:
533
+ return
534
+ if e.anchor_meta is None:
535
+ raise _reject("unknown-id", f"anchor {e.anchor} names nothing")
536
+ if not e.anchor_meta["onActiveBranch"]:
537
+ raise _reject(
538
+ "wrong-anchor", f"anchor {e.anchor} is off the active branch"
539
+ )
540
+
471
541
  # ─── Queue mutations ────────────────────────────────────
472
542
 
473
543
  def _stamped(self, message: dict[str, Any]) -> dict[str, Any]:
@@ -543,6 +613,7 @@ class RunManager:
543
613
  return self._apply_move(e, fx)
544
614
  current = self._lane_of(e.message_id)
545
615
  if current is not None:
616
+ self._check_anchor(e)
546
617
  with self._caller_stamp(e.message_id, e.caller):
547
618
  self._place(e.lane, current, e.message, e.params)
548
619
  if e.lane == "steerQueue":
@@ -550,16 +621,21 @@ class RunManager:
550
621
  return None
551
622
  if e.message_id in self._dispatched_ids:
552
623
  return self._park_dispatched_edit(e)
553
- if e.meta is not None:
624
+ if e.meta is not None or e.message_id == self._reload_message_id:
554
625
  raise _reject(
555
626
  "duplicate-id", f"message id {e.message_id} is already used"
556
627
  )
628
+ if e.anchor is _ABSENT:
629
+ raise _reject("invalid-message", "anchorMessageId is required")
630
+ self._check_anchor(e)
557
631
  with self._caller_stamp(e.message_id, e.caller):
558
632
  self._insert_new(e.lane, e.message, e.params)
633
+ self._entity_futures[e.message_id] = e.future
634
+ e.ack()
559
635
  fx.new_added = True
560
636
  if e.lane == "steerQueue":
561
637
  fx.steer_added = True
562
- return None
638
+ return _PARKED
563
639
 
564
640
  def _apply_move(self, e: _Send, fx: _Effects) -> Any:
565
641
  current = self._lane_of(e.message_id)
@@ -592,6 +668,7 @@ class RunManager:
592
668
  raise _reject("unknown-id", f"message {e.message_id} is unknown")
593
669
  if self._task is not None and not e.meta["isLeaf"]:
594
670
  raise _reject("not-leaf", "only the leaf may be edited during a run")
671
+ self._check_anchor(e)
595
672
  with self._caller_stamp(e.message_id, e.caller):
596
673
  self._staged_rewinds.append(
597
674
  _Rewind(
@@ -608,6 +685,11 @@ class RunManager:
608
685
  self._state[lane] = [
609
686
  item for item in self._lane_items(lane) if item["id"] != e.message_id
610
687
  ]
688
+ entity = self._entity_futures.pop(e.message_id, None)
689
+ if entity is not None and not entity.done():
690
+ entity.set_exception(
691
+ _reject("removed", f"message {e.message_id} was removed from the queue")
692
+ )
611
693
  return None
612
694
 
613
695
  def _apply_input(self, e: _Input) -> Any:
@@ -653,8 +735,23 @@ class RunManager:
653
735
 
654
736
  def _apply_reload(self, e: _Reload) -> Any:
655
737
  self._check_leaf_lanes(e.source_meta, "run/reload")
738
+ if (
739
+ self._lane_of(e.message_id) is not None
740
+ or e.message_id in self._dispatched_ids
741
+ or e.message_id == self._reload_message_id
742
+ ):
743
+ raise _reject(
744
+ "duplicate-id", f"message id {e.message_id} is already used"
745
+ )
656
746
  self._staged_rewinds.append(
657
- _Rewind("message-reload", [], e.source_meta["parentId"], e.ack, e.future)
747
+ _Rewind(
748
+ "message-reload",
749
+ [],
750
+ e.source_meta["parentId"],
751
+ e.ack,
752
+ e.future,
753
+ message_id=e.message_id,
754
+ )
658
755
  )
659
756
  return _PARKED
660
757
 
@@ -684,17 +781,50 @@ class RunManager:
684
781
  raise _reject(
685
782
  "invalid-message", "exactly one of message and messageId is required"
686
783
  )
784
+ anchor = params.get("anchorMessageId", _ABSENT)
785
+ anchor_meta: dict[str, Any] | None = None
786
+ thread_empty = False
787
+ if anchor is None:
788
+ root = await self._get_message_meta(None)
789
+ assert root is not None, "get_message_meta(None) must answer for the root"
790
+ thread_empty = bool(root["isLeaf"])
791
+ elif anchor is not _ABSENT:
792
+ if not isinstance(anchor, str):
793
+ raise _reject("invalid-message", "anchorMessageId must be an id or null")
794
+ anchor_meta = await self._get_message_meta(anchor)
687
795
  if not has_message:
688
796
  message_id = params["messageId"]
689
797
  if not isinstance(message_id, str):
690
798
  raise _reject("invalid-message", "messageId must be a string")
691
799
  return await self._stage(
692
- _Send(lane, params, None, message_id, None, None, ack)
800
+ _Send(
801
+ lane,
802
+ params,
803
+ None,
804
+ message_id,
805
+ None,
806
+ None,
807
+ ack,
808
+ anchor=anchor,
809
+ anchor_meta=anchor_meta,
810
+ thread_empty=thread_empty,
811
+ )
693
812
  )
694
813
  message = self._validated_message(params["message"])
695
814
  meta = await self._get_message_meta(message["id"])
696
815
  return await self._stage(
697
- _Send(lane, params, message, message["id"], meta, caller, ack)
816
+ _Send(
817
+ lane,
818
+ params,
819
+ message,
820
+ message["id"],
821
+ meta,
822
+ caller,
823
+ ack,
824
+ anchor=anchor,
825
+ anchor_meta=anchor_meta,
826
+ thread_empty=thread_empty,
827
+ )
698
828
  )
699
829
 
700
830
  async def enqueue(
@@ -766,6 +896,11 @@ class RunManager:
766
896
  source_id = params.get("sourceId") if isinstance(params, dict) else None
767
897
  if not isinstance(source_id, str):
768
898
  raise _reject("invalid-message", "sourceId must be a string")
899
+ message_id = params.get("messageId")
900
+ if not isinstance(message_id, str) or message_id == "":
901
+ raise _reject("invalid-message", "messageId must be a non-empty string")
902
+ if await self._get_message_meta(message_id) is not None:
903
+ raise _reject("duplicate-id", f"message id {message_id} is already used")
769
904
  meta = await self._get_message_meta(source_id)
770
905
  if meta is None:
771
906
  raise _reject("unknown-id", f"message {source_id} is unknown")
@@ -783,7 +918,7 @@ class RunManager:
783
918
  "capability-missing",
784
919
  "the assistant-continuation capability is not enabled",
785
920
  )
786
- return await self._stage(_Reload(meta, ack))
921
+ return await self._stage(_Reload(meta, message_id, ack))
787
922
 
788
923
  async def stop(self, params: Any = None, *, ack: Callable[[], None]) -> Any:
789
924
  if params is not None and not isinstance(params, dict):
@@ -917,11 +1052,20 @@ class RunManager:
917
1052
  _manager: "RunManager"
918
1053
  _rollback_to: Any
919
1054
  _responses: Any
1055
+ _message_id: str | None
920
1056
 
921
1057
  @property
922
1058
  def stop_reason(self) -> str | None:
923
1059
  return self._manager._stop_reason
924
1060
 
1061
+ @property
1062
+ def message_id(self) -> str:
1063
+ if self._message_id is None:
1064
+ raise AttributeError(
1065
+ "message_id is only present on message-reload entries"
1066
+ )
1067
+ return self._message_id
1068
+
925
1069
  @property
926
1070
  def has_rollback(self) -> bool:
927
1071
  return self._rollback_to is not _ABSENT
@@ -956,6 +1100,7 @@ class RunManager:
956
1100
  self._manager._state["steerQueue"] = []
957
1101
  for item in items:
958
1102
  self._manager._callers.pop(item["id"], None)
1103
+ self._manager._adopt_entities(item["id"] for item in items)
959
1104
  return tuple(
960
1105
  {k: v for k, v in item.items() if k != "caller"} for item in items
961
1106
  )
@@ -35,7 +35,9 @@ class Script:
35
35
  self.calls.put_nowait(call)
36
36
  return await call.outcome
37
37
 
38
- async def get_message_meta(self, message_id: str) -> dict[str, Any] | None:
38
+ async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
39
+ if message_id is None:
40
+ return {"isLeaf": not self.thread}
39
41
  return self.thread.get(message_id)
40
42
 
41
43
  async def next_call(self, timeout: float = 5) -> Call:
@@ -202,6 +204,11 @@ def msg(id: str, text: str | None = None) -> dict[str, Any]:
202
204
  return {"id": id, "role": "user", "parts": [{"type": "text", "text": text or id}]}
203
205
 
204
206
 
207
+ def add(id: str, text: str | None = None, *, anchor: str | None = None, **fields: Any) -> dict[str, Any]:
208
+ """Params for an enqueue/steer add: message + the required branch anchor."""
209
+ return {"message": msg(id, text), "anchorMessageId": anchor, **fields}
210
+
211
+
205
212
  def assert_rejected(rsp: dict[str, Any], reason: str) -> None:
206
213
  assert rsp["type"] == "rejected", rsp
207
214
  assert rsp["payload"] == {"reason": reason}, rsp
@@ -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, msg, queue_ids, run_host
8
+ from run_helpers import Script, add, queue_ids, run_host
9
9
 
10
10
  from harness_sdk import RunManager
11
11
 
@@ -15,29 +15,32 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
15
15
  async with run_host(script) as (drv, host):
16
16
  first = await drv.batch(
17
17
  [
18
- ("run/steer", {"message": msg("s1")}),
19
- ("run/steer", {"message": msg("s2")}),
20
- ("run/steer", {"message": msg("s3")}),
18
+ ("run/steer", add("s1")),
19
+ ("run/steer", add("s2")),
20
+ ("run/steer", add("s3")),
21
21
  ]
22
22
  )
23
23
  for offset in range(3):
24
- assert (await drv.res(first + offset))["type"] == "accepted"
24
+ assert (await drv.res(first + offset, terminal=False))["type"] == "pending"
25
25
  call = await script.next_call()
26
26
  assert call.ctx.type == "message-send"
27
27
  assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
28
28
  script.no_call()
29
29
  assert queue_ids(drv.replica, "steerQueue") == []
30
+ call.finish(RunManager.Complete())
31
+ for offset in range(3):
32
+ assert (await drv.res(first + offset))["type"] == "accepted"
30
33
 
31
34
 
32
35
  async def test_steer_and_stop_in_one_batch_nets_to_stop():
33
36
  script = Script()
34
37
  async with run_host(script) as (drv, host):
35
- await drv.command("run/enqueue", {"message": msg("m1")})
38
+ await drv.command("run/enqueue", add("m1"), terminal=False)
36
39
  call = await script.next_call()
37
40
  first = await drv.batch(
38
- [("run/steer", {"message": msg("s1")}), ("run/stop", None)]
41
+ [("run/steer", add("s1")), ("run/stop", None)]
39
42
  )
40
- assert (await drv.res(first))["type"] == "accepted"
43
+ assert (await drv.res(first, terminal=False))["type"] == "pending"
41
44
  assert (await drv.res(first + 1, terminal=False))["type"] == "pending"
42
45
  await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
43
46
  call.finish(RunManager.Stop(dispatch_queue=False))
@@ -50,7 +53,7 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
50
53
  async def test_stop_settles_only_after_the_run_task_ends():
51
54
  script = Script()
52
55
  async with run_host(script) as (drv, host):
53
- await drv.command("run/enqueue", {"message": msg("m1")})
56
+ await drv.command("run/enqueue", add("m1"), terminal=False)
54
57
  call = await script.next_call()
55
58
  pending = await drv.command("run/stop", terminal=False)
56
59
  assert pending["type"] == "pending"
@@ -67,16 +70,16 @@ async def test_stop_settles_only_after_the_run_task_ends():
67
70
  class TailScript(Script):
68
71
  """get_message_meta suspends, pushing staging past the handler prefix."""
69
72
 
70
- async def get_message_meta(self, message_id: str) -> dict[str, Any] | None:
73
+ async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
71
74
  await asyncio.sleep(0)
72
- return self.thread.get(message_id)
75
+ return await super().get_message_meta(message_id)
73
76
 
74
77
 
75
78
  async def test_handler_tail_staging_decides_in_its_own_envelope():
76
79
  script = TailScript()
77
80
  async with run_host(script) as (drv, host):
78
- res = await drv.command("run/enqueue", {"message": msg("m1")})
79
- assert res["type"] == "accepted"
81
+ res = await drv.command("run/enqueue", add("m1"), terminal=False)
82
+ assert res["type"] == "pending"
80
83
  call = await script.next_call()
81
84
  assert [m["id"] for m in call.ctx.messages] == ["m1"]
82
85
  dispatch_env = next(
@@ -88,6 +91,6 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
88
91
  )
89
92
  )
90
93
  # The decide ran outside the batch segment: its ops flush in an
91
- # envelope of their own, before the command's covering ack.
92
- assert "res" not in dispatch_env
93
- assert dispatch_env.get("ack", 0) < res["seq"]
94
+ # envelope of their own, alongside the pending verdict and its ack.
95
+ assert dispatch_env["res"] == [{"seq": res["seq"], "type": "pending"}]
96
+ assert dispatch_env["ack"] >= res["seq"]
@@ -0,0 +1,145 @@
1
+ from run_helpers import Script, add, assert_rejected, msg, queue_ids, run_host
2
+
3
+ from harness_sdk import RunManager
4
+
5
+
6
+ async def test_add_requires_a_branch_anchor():
7
+ script = Script()
8
+ async with run_host(script) as (drv, host):
9
+ assert_rejected(
10
+ await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False),
11
+ "invalid-message",
12
+ )
13
+ assert_rejected(
14
+ await drv.command("run/steer", {"message": msg("m1")}, terminal=False),
15
+ "invalid-message",
16
+ )
17
+
18
+
19
+ async def test_null_anchor_valid_only_on_an_empty_thread():
20
+ script = Script()
21
+ async with run_host(script) as (drv, host):
22
+ res = await drv.command("run/enqueue", add("m1"), terminal=False)
23
+ assert res["type"] == "pending"
24
+ await script.next_call()
25
+ script.thread["m1"] = {
26
+ "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
27
+ }
28
+ assert_rejected(
29
+ await drv.command("run/enqueue", add("m2"), terminal=False), "wrong-anchor"
30
+ )
31
+ res = await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
32
+ assert res["type"] == "pending"
33
+
34
+
35
+ async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
36
+ script = Script()
37
+ script.thread.update(
38
+ {
39
+ "u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
40
+ "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
41
+ "old": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False},
42
+ }
43
+ )
44
+ async with run_host(script) as (drv, host):
45
+ res = await drv.command("run/enqueue", add("m1", anchor="a1"), terminal=False)
46
+ assert res["type"] == "pending"
47
+ await script.next_call()
48
+ assert_rejected(
49
+ await drv.command("run/enqueue", add("m2", anchor="old"), terminal=False),
50
+ "wrong-anchor",
51
+ )
52
+ assert_rejected(
53
+ await drv.command("run/enqueue", add("m2", anchor="zz"), terminal=False),
54
+ "unknown-id",
55
+ )
56
+
57
+
58
+ async def test_queued_item_and_dispatched_ids_are_valid_anchors():
59
+ script = Script()
60
+ async with run_host(script) as (drv, host):
61
+ await drv.command("run/enqueue", add("m1"), terminal=False)
62
+ await script.next_call()
63
+ await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
64
+ await drv.command("run/steer", add("s1", anchor="m2"), terminal=False)
65
+ assert queue_ids(drv.replica, "queue") == ["m2"]
66
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
67
+
68
+
69
+ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
70
+ script = Script()
71
+ script.thread.update(
72
+ {
73
+ "u1": {"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True},
74
+ }
75
+ )
76
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
77
+ res = await drv.command(
78
+ "run/edit", {"sourceId": "u1", "message": msg("m2")}, terminal=False
79
+ )
80
+ assert res["type"] == "pending"
81
+ await script.next_call()
82
+ script.thread["u1"] = {
83
+ "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": False
84
+ }
85
+ script.thread["m2"] = {
86
+ "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
87
+ }
88
+ assert_rejected(
89
+ await drv.command("run/enqueue", add("q1", anchor="u1"), terminal=False),
90
+ "wrong-anchor",
91
+ )
92
+ res = await drv.command("run/enqueue", add("q1", anchor="m2"), terminal=False)
93
+ assert res["type"] == "pending"
94
+
95
+
96
+ async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
97
+ script = Script()
98
+ async with run_host(script) as (drv, host):
99
+ await drv.command("run/enqueue", add("m1"), terminal=False)
100
+ await script.next_call()
101
+ await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
102
+ assert_rejected(
103
+ await drv.command(
104
+ "run/enqueue",
105
+ {"message": msg("m2", "edited"), "anchorMessageId": "zz"},
106
+ terminal=False,
107
+ ),
108
+ "unknown-id",
109
+ )
110
+ await drv.command(
111
+ "run/enqueue",
112
+ {"message": msg("m2", "edited"), "anchorMessageId": "m1"},
113
+ terminal=False,
114
+ )
115
+ assert drv.replica["queue"][0]["parts"][0]["text"] == "edited"
116
+
117
+
118
+ async def test_reload_requires_a_message_id_and_delivers_it_on_the_start_context():
119
+ script = Script()
120
+ script.thread.update(
121
+ {
122
+ "u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
123
+ "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
124
+ }
125
+ )
126
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
127
+ assert_rejected(
128
+ await drv.command("run/reload", {"sourceId": "a1"}, terminal=False),
129
+ "invalid-message",
130
+ )
131
+ assert_rejected(
132
+ await drv.command(
133
+ "run/reload", {"sourceId": "a1", "messageId": "u1"}, terminal=False
134
+ ),
135
+ "duplicate-id",
136
+ )
137
+ res = await drv.command(
138
+ "run/reload", {"sourceId": "a1", "messageId": "r9"}, terminal=False
139
+ )
140
+ assert res["type"] == "pending"
141
+ call = await script.next_call()
142
+ assert call.ctx.type == "message-reload"
143
+ assert call.ctx.message_id == "r9"
144
+ call.finish(RunManager.Complete())
145
+ await drv.wait_status("ready")