harness-sdk-python 0.3.1__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.1 → harness_sdk_python-0.4.0}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/run_manager.py +108 -7
  4. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/run_helpers.py +8 -1
  5. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_batches.py +10 -10
  6. harness_sdk_python-0.4.0/tests/test_branch_anchor.py +145 -0
  7. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_caller.py +17 -17
  8. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_edit_dispatched.py +7 -7
  9. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_edit_reload.py +24 -24
  10. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_enqueue.py +31 -31
  11. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_input_required.py +10 -10
  12. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_outcomes.py +12 -12
  13. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_placement.py +16 -16
  14. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_rewind_during_run.py +12 -12
  15. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_settle.py +11 -11
  16. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_steer.py +14 -14
  17. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_stop_continue.py +16 -16
  18. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/.gitignore +0 -0
  19. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/README.md +0 -0
  20. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/__init__.py +0 -0
  21. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/fenced_postgres.py +0 -0
  22. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_facade.py +0 -0
  23. {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/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.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.1"
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] = []
@@ -214,10 +221,14 @@ class RunManager:
214
221
  self._staged_stops.clear()
215
222
  if isinstance(outcome, RunManager.Complete):
216
223
  self._dispatched_ids = ()
224
+ self._reload_message_id = None
217
225
  if self._staged_rewinds:
218
226
  rewind = self._staged_rewinds.pop(0)
219
227
  self._dispatch(
220
- 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,
221
232
  )
222
233
  rewind.ack()
223
234
  if not rewind.future.done():
@@ -310,6 +321,7 @@ class RunManager:
310
321
  *,
311
322
  rollback_to: Any = _ABSENT,
312
323
  responses: Any = _ABSENT,
324
+ message_id: str | None = None,
313
325
  ) -> None:
314
326
  if type not in _ENTRY_TYPES:
315
327
  raise ValueError(f"invalid entry type: {type!r}")
@@ -327,11 +339,14 @@ class RunManager:
327
339
  record["rollbackTo"] = rollback_to
328
340
  if responses is not _ABSENT:
329
341
  record["responses"] = responses
342
+ if message_id is not None:
343
+ record["messageId"] = message_id
330
344
  self._dispatch_record = record
331
345
  self._stop_reason = None
332
346
  self._state["error"] = None
333
347
  if messages:
334
348
  self._dispatched_ids = tuple(m["id"] for m in messages)
349
+ self._reload_message_id = message_id
335
350
  self._state["status"] = "running"
336
351
  ctx = RunManager.StartContext(
337
352
  type=type,
@@ -341,6 +356,7 @@ class RunManager:
341
356
  _manager=self,
342
357
  _rollback_to=rollback_to,
343
358
  _responses=responses,
359
+ _message_id=message_id,
344
360
  )
345
361
  self._ctx = ctx
346
362
  self._task = self._create_task(self._run(ctx))
@@ -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 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
+
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":
@@ -586,10 +621,13 @@ class RunManager:
586
621
  return None
587
622
  if e.message_id in self._dispatched_ids:
588
623
  return self._park_dispatched_edit(e)
589
- if e.meta is not None:
624
+ if e.meta is not None or e.message_id == self._reload_message_id:
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(
@@ -696,8 +735,23 @@ class RunManager:
696
735
 
697
736
  def _apply_reload(self, e: _Reload) -> Any:
698
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
+ )
699
746
  self._staged_rewinds.append(
700
- _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
+ )
701
755
  )
702
756
  return _PARKED
703
757
 
@@ -727,17 +781,50 @@ class RunManager:
727
781
  raise _reject(
728
782
  "invalid-message", "exactly one of message and messageId is required"
729
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)
730
795
  if not has_message:
731
796
  message_id = params["messageId"]
732
797
  if not isinstance(message_id, str):
733
798
  raise _reject("invalid-message", "messageId must be a string")
734
799
  return await self._stage(
735
- _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
+ )
736
812
  )
737
813
  message = self._validated_message(params["message"])
738
814
  meta = await self._get_message_meta(message["id"])
739
815
  return await self._stage(
740
- _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
+ )
741
828
  )
742
829
 
743
830
  async def enqueue(
@@ -809,6 +896,11 @@ class RunManager:
809
896
  source_id = params.get("sourceId") if isinstance(params, dict) else None
810
897
  if not isinstance(source_id, str):
811
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")
812
904
  meta = await self._get_message_meta(source_id)
813
905
  if meta is None:
814
906
  raise _reject("unknown-id", f"message {source_id} is unknown")
@@ -826,7 +918,7 @@ class RunManager:
826
918
  "capability-missing",
827
919
  "the assistant-continuation capability is not enabled",
828
920
  )
829
- return await self._stage(_Reload(meta, ack))
921
+ return await self._stage(_Reload(meta, message_id, ack))
830
922
 
831
923
  async def stop(self, params: Any = None, *, ack: Callable[[], None]) -> Any:
832
924
  if params is not None and not isinstance(params, dict):
@@ -960,11 +1052,20 @@ class RunManager:
960
1052
  _manager: "RunManager"
961
1053
  _rollback_to: Any
962
1054
  _responses: Any
1055
+ _message_id: str | None
963
1056
 
964
1057
  @property
965
1058
  def stop_reason(self) -> str | None:
966
1059
  return self._manager._stop_reason
967
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
+
968
1069
  @property
969
1070
  def has_rollback(self) -> bool:
970
1071
  return self._rollback_to is not _ABSENT
@@ -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,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,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")
@@ -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()
@@ -47,7 +47,7 @@ async def test_dispatch_without_queue_entry_has_no_caller():
47
47
  script = Script()
48
48
  async with run_host(script, capabilities=("rewind",)) as (drv, _):
49
49
  script.thread["a1"] = {"role": "assistant", "parentId": None, "isLeaf": True}
50
- assert (await drv.command("run/reload", {"sourceId": "a1"}, terminal=False))[
50
+ assert (await drv.command("run/reload", {"sourceId": "a1", "messageId": "r1"}, terminal=False))[
51
51
  "type"
52
52
  ] == "pending"
53
53
  call = 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())