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.
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/PKG-INFO +2 -2
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/pyproject.toml +2 -2
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/run_manager.py +108 -7
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/run_helpers.py +8 -1
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_batches.py +10 -10
- harness_sdk_python-0.4.0/tests/test_branch_anchor.py +145 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_caller.py +17 -17
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_edit_dispatched.py +7 -7
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_edit_reload.py +24 -24
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_enqueue.py +31 -31
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_input_required.py +10 -10
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_outcomes.py +12 -12
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_placement.py +16 -16
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_rewind_during_run.py +12 -12
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_settle.py +11 -11
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_steer.py +14 -14
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_stop_continue.py +16 -16
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/.gitignore +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/README.md +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.3.1 → harness_sdk_python-0.4.0}/tests/test_facade.py +0 -0
- {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
|
+
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.
|
|
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.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.
|
|
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
|
|
|
@@ -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,
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,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,
|
|
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()
|
|
@@ -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",
|
|
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())
|