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.
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/PKG-INFO +2 -2
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/pyproject.toml +2 -2
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/run_manager.py +93 -7
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/run_helpers.py +19 -1
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_batches.py +10 -10
- harness_sdk_python-0.4.1/tests/test_branch_anchor.py +137 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_caller.py +17 -16
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_edit_dispatched.py +7 -7
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_edit_reload.py +15 -15
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_enqueue.py +31 -31
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_facade.py +1 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_input_required.py +9 -9
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_outcomes.py +14 -12
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_placement.py +16 -16
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_rewind_during_run.py +9 -8
- harness_sdk_python-0.4.1/tests/test_run_leaf.py +116 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_settle.py +11 -11
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_steer.py +14 -14
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/tests/test_stop_continue.py +16 -16
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/.gitignore +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/README.md +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {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
|
+
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.
|
|
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
|
+
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.
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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",
|
|
19
|
-
("run/steer",
|
|
20
|
-
("run/steer",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
22
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
23
23
|
call = await script.next_call()
|
|
24
|
-
await drv.command("run/enqueue",
|
|
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",
|
|
33
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
34
34
|
call = await script.next_call()
|
|
35
|
-
await drv.command("run/enqueue",
|
|
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",
|
|
61
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
62
62
|
call = await script.next_call()
|
|
63
|
-
await drv.command("run/steer",
|
|
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",
|
|
74
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
75
75
|
call = await script.next_call()
|
|
76
|
-
assert (await drv.command("run/enqueue",
|
|
76
|
+
assert (await drv.command("run/enqueue", add("m2"), terminal=False))[
|
|
77
77
|
"type"
|
|
78
78
|
] == "pending"
|
|
79
|
-
full = await drv.command("run/enqueue",
|
|
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",
|
|
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",
|
|
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",
|
|
94
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
95
95
|
call = await script.next_call()
|
|
96
|
-
await drv.command("run/enqueue",
|
|
97
|
-
await drv.command("run/steer",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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"))
|