harness-sdk-python 0.3.1__tar.gz → 0.4.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 (24) hide show
  1. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/run_manager.py +93 -7
  4. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/run_helpers.py +19 -1
  5. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_batches.py +10 -10
  6. harness_sdk_python-0.4.1/tests/test_branch_anchor.py +137 -0
  7. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_caller.py +17 -16
  8. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_edit_dispatched.py +7 -7
  9. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_edit_reload.py +15 -15
  10. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_enqueue.py +31 -31
  11. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_facade.py +1 -0
  12. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_input_required.py +9 -9
  13. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_outcomes.py +14 -12
  14. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_placement.py +16 -16
  15. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_rewind_during_run.py +9 -8
  16. harness_sdk_python-0.4.1/tests/test_run_leaf.py +116 -0
  17. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_settle.py +11 -11
  18. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_steer.py +14 -14
  19. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_stop_continue.py +16 -16
  20. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/.gitignore +0 -0
  21. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/README.md +0 -0
  22. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/__init__.py +0 -0
  23. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/fenced_postgres.py +0 -0
  24. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_fenced_postgres.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.3.1
3
+ Version: 0.4.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
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.1"
3
+ version = "0.4.1"
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
 
@@ -133,6 +137,7 @@ class RunManager:
133
137
  get_message_meta: GetMessageMeta,
134
138
  create_task: Callable[[Any], "asyncio.Task[Any]"],
135
139
  schedule: Callable[[Callable[[], None]], None],
140
+ leaf_message_id: str | None,
136
141
  capabilities: Iterable[str] = (),
137
142
  max_queued: int = 50,
138
143
  ) -> None:
@@ -144,6 +149,10 @@ class RunManager:
144
149
  raise ValueError("rewind-during-run requires the rewind capability")
145
150
  if max_queued < 1:
146
151
  raise ValueError("max_queued must be >= 1")
152
+ if leaf_message_id is not None and (
153
+ not isinstance(leaf_message_id, str) or leaf_message_id == ""
154
+ ):
155
+ raise ValueError("leaf_message_id must be a non-empty string or None")
147
156
  self._state = state
148
157
  self._start = start
149
158
  self._get_message_meta = get_message_meta
@@ -165,14 +174,15 @@ class RunManager:
165
174
  self._run_futures: list["asyncio.Future[Any]"] = []
166
175
  self._input_requests: list[dict[str, Any]] = []
167
176
  self._input_answers: dict[str, Any] = {}
168
- self._init_state()
177
+ self._init_state(leaf_message_id)
169
178
 
170
- def _init_state(self) -> None:
179
+ def _init_state(self, leaf_message_id: str | None) -> None:
171
180
  # Runs state is write-only and not durable: overwrite whatever is there.
172
181
  self._state["status"] = "ready"
173
182
  self._state["error"] = None
174
183
  self._state["queue"] = []
175
184
  self._state["steerQueue"] = []
185
+ self._state["runLeafMessageId"] = leaf_message_id
176
186
  self._state.pop("dispatch", None)
177
187
  self._state.pop("inputRequests", None)
178
188
 
@@ -217,7 +227,9 @@ class RunManager:
217
227
  if self._staged_rewinds:
218
228
  rewind = self._staged_rewinds.pop(0)
219
229
  self._dispatch(
220
- rewind.type, rewind.messages, rollback_to=rewind.rollback_to
230
+ rewind.type,
231
+ rewind.messages,
232
+ rollback_to=rewind.rollback_to,
221
233
  )
222
234
  rewind.ack()
223
235
  if not rewind.future.done():
@@ -332,6 +344,10 @@ class RunManager:
332
344
  self._state["error"] = None
333
345
  if messages:
334
346
  self._dispatched_ids = tuple(m["id"] for m in messages)
347
+ if rollback_to is not _ABSENT:
348
+ self._state["runLeafMessageId"] = rollback_to
349
+ if messages:
350
+ self._state["runLeafMessageId"] = messages[-1]["id"]
335
351
  self._state["status"] = "running"
336
352
  ctx = RunManager.StartContext(
337
353
  type=type,
@@ -504,6 +520,24 @@ class RunManager:
504
520
  raise _reject("not-adjacent", "insertAfter and insertBefore are not adjacent")
505
521
  return slot
506
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:
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
+
507
541
  # ─── Queue mutations ────────────────────────────────────
508
542
 
509
543
  def _stamped(self, message: dict[str, Any]) -> dict[str, Any]:
@@ -579,6 +613,7 @@ class RunManager:
579
613
  return self._apply_move(e, fx)
580
614
  current = self._lane_of(e.message_id)
581
615
  if current is not None:
616
+ self._check_anchor(e)
582
617
  with self._caller_stamp(e.message_id, e.caller):
583
618
  self._place(e.lane, current, e.message, e.params)
584
619
  if e.lane == "steerQueue":
@@ -590,6 +625,9 @@ class RunManager:
590
625
  raise _reject(
591
626
  "duplicate-id", f"message id {e.message_id} is already used"
592
627
  )
628
+ if e.anchor is _ABSENT:
629
+ raise _reject("invalid-message", "anchorMessageId is required")
630
+ self._check_anchor(e)
593
631
  with self._caller_stamp(e.message_id, e.caller):
594
632
  self._insert_new(e.lane, e.message, e.params)
595
633
  self._entity_futures[e.message_id] = e.future
@@ -630,6 +668,7 @@ class RunManager:
630
668
  raise _reject("unknown-id", f"message {e.message_id} is unknown")
631
669
  if self._task is not None and not e.meta["isLeaf"]:
632
670
  raise _reject("not-leaf", "only the leaf may be edited during a run")
671
+ self._check_anchor(e)
633
672
  with self._caller_stamp(e.message_id, e.caller):
634
673
  self._staged_rewinds.append(
635
674
  _Rewind(
@@ -697,7 +736,13 @@ class RunManager:
697
736
  def _apply_reload(self, e: _Reload) -> Any:
698
737
  self._check_leaf_lanes(e.source_meta, "run/reload")
699
738
  self._staged_rewinds.append(
700
- _Rewind("message-reload", [], e.source_meta["parentId"], e.ack, e.future)
739
+ _Rewind(
740
+ "message-reload",
741
+ [],
742
+ e.source_meta["parentId"],
743
+ e.ack,
744
+ e.future,
745
+ )
701
746
  )
702
747
  return _PARKED
703
748
 
@@ -727,17 +772,50 @@ class RunManager:
727
772
  raise _reject(
728
773
  "invalid-message", "exactly one of message and messageId is required"
729
774
  )
775
+ anchor = params.get("anchorMessageId", _ABSENT)
776
+ anchor_meta: dict[str, Any] | None = None
777
+ thread_empty = False
778
+ if anchor is None:
779
+ root = await self._get_message_meta(None)
780
+ assert root is not None, "get_message_meta(None) must answer for the root"
781
+ thread_empty = bool(root["isLeaf"])
782
+ elif anchor is not _ABSENT:
783
+ if not isinstance(anchor, str):
784
+ raise _reject("invalid-message", "anchorMessageId must be an id or null")
785
+ anchor_meta = await self._get_message_meta(anchor)
730
786
  if not has_message:
731
787
  message_id = params["messageId"]
732
788
  if not isinstance(message_id, str):
733
789
  raise _reject("invalid-message", "messageId must be a string")
734
790
  return await self._stage(
735
- _Send(lane, params, None, message_id, None, None, ack)
791
+ _Send(
792
+ lane,
793
+ params,
794
+ None,
795
+ message_id,
796
+ None,
797
+ None,
798
+ ack,
799
+ anchor=anchor,
800
+ anchor_meta=anchor_meta,
801
+ thread_empty=thread_empty,
802
+ )
736
803
  )
737
804
  message = self._validated_message(params["message"])
738
805
  meta = await self._get_message_meta(message["id"])
739
806
  return await self._stage(
740
- _Send(lane, params, message, message["id"], meta, caller, ack)
807
+ _Send(
808
+ lane,
809
+ params,
810
+ message,
811
+ message["id"],
812
+ meta,
813
+ caller,
814
+ ack,
815
+ anchor=anchor,
816
+ anchor_meta=anchor_meta,
817
+ thread_empty=thread_empty,
818
+ )
741
819
  )
742
820
 
743
821
  async def enqueue(
@@ -1000,10 +1078,18 @@ class RunManager:
1000
1078
  for item in items:
1001
1079
  self._manager._callers.pop(item["id"], None)
1002
1080
  self._manager._adopt_entities(item["id"] for item in items)
1081
+ if items:
1082
+ self._manager._state["runLeafMessageId"] = items[-1]["id"]
1003
1083
  return tuple(
1004
1084
  {k: v for k, v in item.items() if k != "caller"} for item in items
1005
1085
  )
1006
1086
 
1087
+ def set_leaf_message_id(self, message_id: str) -> None:
1088
+ self._ensure_active()
1089
+ if not isinstance(message_id, str) or message_id == "":
1090
+ raise ValueError("message_id must be a non-empty string")
1091
+ self._manager._state["runLeafMessageId"] = message_id
1092
+
1007
1093
  def set_recovery_state(self, value: Any) -> None:
1008
1094
  self._ensure_active()
1009
1095
  record = self._manager._dispatch_record
@@ -35,9 +35,21 @@ 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
 
43
+ def leaf_id(self) -> str | None:
44
+ return next(
45
+ (
46
+ id
47
+ for id, meta in self.thread.items()
48
+ if meta.get("isLeaf") and meta.get("onActiveBranch", True)
49
+ ),
50
+ None,
51
+ )
52
+
41
53
  async def next_call(self, timeout: float = 5) -> Call:
42
54
  return await asyncio.wait_for(self.calls.get(), timeout)
43
55
 
@@ -60,6 +72,7 @@ def make_host(script: Script, capabilities=(), initial_runs=None, max_queued=50)
60
72
  get_message_meta=script.get_message_meta,
61
73
  create_task=self.create_task,
62
74
  schedule=self.schedule,
75
+ leaf_message_id=script.leaf_id(),
63
76
  capabilities=capabilities,
64
77
  max_queued=max_queued,
65
78
  )
@@ -202,6 +215,11 @@ def msg(id: str, text: str | None = None) -> dict[str, Any]:
202
215
  return {"id": id, "role": "user", "parts": [{"type": "text", "text": text or id}]}
203
216
 
204
217
 
218
+ def add(id: str, text: str | None = None, *, anchor: str | None = None, **fields: Any) -> dict[str, Any]:
219
+ """Params for an enqueue/steer add: message + the required branch anchor."""
220
+ return {"message": msg(id, text), "anchorMessageId": anchor, **fields}
221
+
222
+
205
223
  def assert_rejected(rsp: dict[str, Any], reason: str) -> None:
206
224
  assert rsp["type"] == "rejected", rsp
207
225
  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,9 +15,9 @@ 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):
@@ -35,10 +35,10 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
35
35
  async def test_steer_and_stop_in_one_batch_nets_to_stop():
36
36
  script = Script()
37
37
  async with run_host(script) as (drv, host):
38
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
38
+ await drv.command("run/enqueue", add("m1"), terminal=False)
39
39
  call = await script.next_call()
40
40
  first = await drv.batch(
41
- [("run/steer", {"message": msg("s1")}), ("run/stop", None)]
41
+ [("run/steer", add("s1")), ("run/stop", None)]
42
42
  )
43
43
  assert (await drv.res(first, terminal=False))["type"] == "pending"
44
44
  assert (await drv.res(first + 1, terminal=False))["type"] == "pending"
@@ -53,7 +53,7 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
53
53
  async def test_stop_settles_only_after_the_run_task_ends():
54
54
  script = Script()
55
55
  async with run_host(script) as (drv, host):
56
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
56
+ await drv.command("run/enqueue", add("m1"), terminal=False)
57
57
  call = await script.next_call()
58
58
  pending = await drv.command("run/stop", terminal=False)
59
59
  assert pending["type"] == "pending"
@@ -70,15 +70,15 @@ async def test_stop_settles_only_after_the_run_task_ends():
70
70
  class TailScript(Script):
71
71
  """get_message_meta suspends, pushing staging past the handler prefix."""
72
72
 
73
- 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:
74
74
  await asyncio.sleep(0)
75
- return self.thread.get(message_id)
75
+ return await super().get_message_meta(message_id)
76
76
 
77
77
 
78
78
  async def test_handler_tail_staging_decides_in_its_own_envelope():
79
79
  script = TailScript()
80
80
  async with run_host(script) as (drv, host):
81
- res = await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
81
+ res = await drv.command("run/enqueue", add("m1"), terminal=False)
82
82
  assert res["type"] == "pending"
83
83
  call = await script.next_call()
84
84
  assert [m["id"] for m in call.ctx.messages] == ["m1"]
@@ -0,0 +1,137 @@
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_post_reload_sends_anchor_on_the_reload_targets_parent():
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
+ res = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
128
+ assert res["type"] == "pending"
129
+ call = await script.next_call()
130
+ assert call.ctx.type == "message-reload"
131
+ script.thread["a1"] = {
132
+ "parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
133
+ }
134
+ res = await drv.command("run/enqueue", add("m1", anchor="u1"), terminal=False)
135
+ assert res["type"] == "pending"
136
+ call.finish(RunManager.Complete())
137
+ await drv.wait_status("running")
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from run_helpers import Script, msg, run_host
2
+ from run_helpers import Script, add, run_host
3
3
 
4
4
  from harness_sdk import RunManager
5
5
 
@@ -7,7 +7,7 @@ from harness_sdk import RunManager
7
7
  async def test_immediate_dispatch_carries_caller():
8
8
  script = Script()
9
9
  async with run_host(script) as (drv, _):
10
- assert (await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False))[
10
+ assert (await drv.command("run/enqueue", add("m1"), terminal=False))[
11
11
  "type"
12
12
  ] == "pending"
13
13
  call = await script.next_call()
@@ -19,9 +19,9 @@ async def test_immediate_dispatch_carries_caller():
19
19
  async def test_queue_entry_projects_caller_client_id():
20
20
  script = Script()
21
21
  async with run_host(script) as (drv, _):
22
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
22
+ await drv.command("run/enqueue", add("m1"), terminal=False)
23
23
  call = await script.next_call()
24
- await drv.command("run/enqueue", {"message": msg("m2")}, terminal=False)
24
+ await drv.command("run/enqueue", add("m2"), terminal=False)
25
25
  await drv.wait(lambda s: len(s["queue"]) == 1)
26
26
  assert drv.replica["queue"][0]["caller"] == {"clientId": "c1"}
27
27
  call.finish(RunManager.Complete())
@@ -30,9 +30,9 @@ async def test_queue_entry_projects_caller_client_id():
30
30
  async def test_caller_context_is_read_at_dispatch_time():
31
31
  script = Script()
32
32
  async with run_host(script) as (drv, _):
33
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
33
+ await drv.command("run/enqueue", add("m1"), terminal=False)
34
34
  call = await script.next_call()
35
- await drv.command("run/enqueue", {"message": msg("m2")}, terminal=False)
35
+ await drv.command("run/enqueue", add("m2"), terminal=False)
36
36
  assert (await drv.context({"user": "simon"}))["type"] == "accepted"
37
37
  call.finish(RunManager.Complete())
38
38
  dispatched = await script.next_call()
@@ -58,9 +58,9 @@ async def test_dispatch_without_queue_entry_has_no_caller():
58
58
  async def test_take_steered_strips_the_caller_stamp():
59
59
  script = Script()
60
60
  async with run_host(script) as (drv, _):
61
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
61
+ await drv.command("run/enqueue", add("m1"), terminal=False)
62
62
  call = await script.next_call()
63
- await drv.command("run/steer", {"message": msg("s1")}, terminal=False)
63
+ await drv.command("run/steer", add("s1"), terminal=False)
64
64
  await drv.wait(lambda s: len(s["steerQueue"]) == 1)
65
65
  assert drv.replica["steerQueue"][0]["caller"] == {"clientId": "c1"}
66
66
  [taken] = call.ctx.take_steered()
@@ -71,19 +71,19 @@ async def test_take_steered_strips_the_caller_stamp():
71
71
  async def test_max_queued_caps_each_lane():
72
72
  script = Script()
73
73
  async with run_host(script, max_queued=1) as (drv, _):
74
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
74
+ await drv.command("run/enqueue", add("m1"), terminal=False)
75
75
  call = await script.next_call()
76
- assert (await drv.command("run/enqueue", {"message": msg("m2")}, terminal=False))[
76
+ assert (await drv.command("run/enqueue", add("m2"), terminal=False))[
77
77
  "type"
78
78
  ] == "pending"
79
- full = await drv.command("run/enqueue", {"message": msg("m3")}, terminal=False)
79
+ full = await drv.command("run/enqueue", add("m3"), terminal=False)
80
80
  assert full["type"] == "rejected"
81
81
  assert full["payload"] == {"reason": "queue-full"}
82
82
  # The steer lane has its own budget.
83
- assert (await drv.command("run/steer", {"message": msg("s1")}, terminal=False))[
83
+ assert (await drv.command("run/steer", add("s1"), terminal=False))[
84
84
  "type"
85
85
  ] == "pending"
86
- steer_full = await drv.command("run/steer", {"message": msg("s2")}, terminal=False)
86
+ steer_full = await drv.command("run/steer", add("s2"), terminal=False)
87
87
  assert steer_full["payload"] == {"reason": "queue-full"}
88
88
  call.finish(RunManager.Complete())
89
89
 
@@ -91,10 +91,10 @@ async def test_max_queued_caps_each_lane():
91
91
  async def test_lane_change_into_a_full_lane_rejects():
92
92
  script = Script()
93
93
  async with run_host(script, max_queued=1) as (drv, _):
94
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
94
+ await drv.command("run/enqueue", add("m1"), terminal=False)
95
95
  call = await script.next_call()
96
- await drv.command("run/enqueue", {"message": msg("m2")}, terminal=False)
97
- await drv.command("run/steer", {"message": msg("s1")}, terminal=False)
96
+ await drv.command("run/enqueue", add("m2"), terminal=False)
97
+ await drv.command("run/steer", add("s1"), terminal=False)
98
98
  moved = await drv.command("run/steer", {"messageId": "m2"}, terminal=False)
99
99
  assert moved["payload"] == {"reason": "queue-full"}
100
100
  call.finish(RunManager.Complete())
@@ -109,5 +109,6 @@ async def test_max_queued_below_one_rejects_at_construction():
109
109
  get_message_meta=script.get_message_meta,
110
110
  create_task=lambda coro: None,
111
111
  schedule=lambda fn: None,
112
+ leaf_message_id=None,
112
113
  max_queued=0,
113
114
  )
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
 
3
3
  import pytest
4
- from run_helpers import Script, assert_rejected, msg, run_host
4
+ from run_helpers import Script, add, assert_rejected, msg, run_host
5
5
 
6
6
  from harness_sdk import RunManager
7
7
 
@@ -10,7 +10,7 @@ from harness_sdk import RunManager
10
10
  async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
11
11
  script = Script()
12
12
  async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
13
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
13
+ await drv.command("run/enqueue", add("m1"), terminal=False)
14
14
  call = await script.next_call()
15
15
  script.thread["m1"] = {"parentId": "p0", "role": "user", "isLeaf": True}
16
16
  pending = await drv.command(
@@ -35,7 +35,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
35
35
  async def test_edit_of_dispatched_item_requires_rewind_during_run(capabilities):
36
36
  script = Script()
37
37
  async with run_host(script, capabilities=capabilities) as (drv, host):
38
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
38
+ await drv.command("run/enqueue", add("m1"), terminal=False)
39
39
  await script.next_call()
40
40
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
41
41
  assert_rejected(
@@ -48,7 +48,7 @@ async def test_edit_of_dispatched_item_requires_rewind_during_run(capabilities):
48
48
  async def test_edit_of_dispatched_non_leaf_rejects(command):
49
49
  script = Script()
50
50
  async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
51
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
51
+ await drv.command("run/enqueue", add("m1"), terminal=False)
52
52
  await script.next_call()
53
53
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": False}
54
54
  assert_rejected(
@@ -59,10 +59,10 @@ async def test_edit_of_dispatched_non_leaf_rejects(command):
59
59
  async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
60
60
  script = Script()
61
61
  async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
62
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
62
+ await drv.command("run/enqueue", add("m1"), terminal=False)
63
63
  call = await script.next_call()
64
64
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
65
- await drv.command("run/enqueue", {"message": msg("m2")}, terminal=False)
65
+ await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
66
66
  pending = await drv.command(
67
67
  "run/enqueue", {"message": msg("m1", "edited")}, terminal=False
68
68
  )
@@ -78,7 +78,7 @@ async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
78
78
  async def test_edit_of_dispatched_item_in_error_reruns_without_stop():
79
79
  script = Script()
80
80
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
81
- await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False)
81
+ await drv.command("run/enqueue", add("m1"), terminal=False)
82
82
  call = await script.next_call()
83
83
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
84
84
  call.fail(RuntimeError("boom"))