harness-sdk-python 0.5.0__tar.gz → 0.6.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.5.0 → harness_sdk_python-0.6.0}/PKG-INFO +2 -2
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/README.md +1 -1
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/pyproject.toml +1 -1
- harness_sdk_python-0.6.0/src/harness_sdk/__init__.py +4 -0
- harness_sdk_python-0.6.0/src/harness_sdk/linear_thread.py +52 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/src/harness_sdk/run_manager.py +52 -22
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/run_helpers.py +25 -3
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_enqueue.py +1 -1
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_facade.py +1 -1
- harness_sdk_python-0.6.0/tests/test_linear_thread.py +122 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_outcomes.py +1 -1
- harness_sdk_python-0.6.0/tests/test_prepare_hooks.py +167 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_rewind_during_run.py +1 -1
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_run_leaf.py +41 -36
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_settle.py +0 -11
- harness_sdk_python-0.5.0/src/harness_sdk/__init__.py +0 -4
- harness_sdk_python-0.5.0/src/harness_sdk/linear_thread.py +0 -31
- harness_sdk_python-0.5.0/tests/test_linear_thread.py +0 -87
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/.gitignore +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_stop_continue.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -23,7 +23,7 @@ class MyHost(Statewire):
|
|
|
23
23
|
self.runs = RunManager(
|
|
24
24
|
state=self.state,
|
|
25
25
|
start=self._start,
|
|
26
|
-
|
|
26
|
+
thread=self._thread,
|
|
27
27
|
create_task=self.create_task,
|
|
28
28
|
capabilities=("rewind",),
|
|
29
29
|
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class LinearThread:
|
|
7
|
+
_messages: Callable[[], list[dict[str, Any]]]
|
|
8
|
+
_role: Callable[[dict[str, Any]], str]
|
|
9
|
+
|
|
10
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
11
|
+
items = self._messages()
|
|
12
|
+
if message_id is None:
|
|
13
|
+
return {"isLeaf": not items}
|
|
14
|
+
for index, message in enumerate(items):
|
|
15
|
+
if message.get("id") == message_id:
|
|
16
|
+
return {
|
|
17
|
+
"parentId": items[index - 1].get("id") if index > 0 else None,
|
|
18
|
+
"role": self._role(message),
|
|
19
|
+
"isLeaf": index == len(items) - 1,
|
|
20
|
+
"onActiveBranch": True,
|
|
21
|
+
}
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
async def get_leaf_message_id(self) -> str | None:
|
|
25
|
+
items = self._messages()
|
|
26
|
+
return items[-1].get("id") if items else None
|
|
27
|
+
|
|
28
|
+
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
29
|
+
"""The id of the first non-tool message after parent_id; None when parent_id is unknown or only tools follow."""
|
|
30
|
+
items = self._messages()
|
|
31
|
+
index = next(
|
|
32
|
+
(i for i, m in enumerate(items) if m.get("id") == parent_id), None
|
|
33
|
+
)
|
|
34
|
+
if index is None:
|
|
35
|
+
return None
|
|
36
|
+
for message in items[index + 1 :]:
|
|
37
|
+
if message.get("type") != "tool":
|
|
38
|
+
return message.get("id")
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def linear_thread(
|
|
43
|
+
*,
|
|
44
|
+
messages: Callable[[], list[dict[str, Any]]],
|
|
45
|
+
role: Callable[[dict[str, Any]], str],
|
|
46
|
+
) -> LinearThread:
|
|
47
|
+
"""RunManager thread projection over a linear message list: ``messages`` returns the current list, ``role`` maps a message to its role."""
|
|
48
|
+
if not callable(messages):
|
|
49
|
+
raise TypeError("messages must be callable")
|
|
50
|
+
if not callable(role):
|
|
51
|
+
raise TypeError("role must be callable")
|
|
52
|
+
return LinearThread(messages, role)
|
|
@@ -19,7 +19,7 @@ initiators settle rejected (``stopped`` or the failure).
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import uuid
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
|
-
from typing import Any, Awaitable, Callable, Iterable
|
|
22
|
+
from typing import Any, Awaitable, Callable, Iterable, Protocol
|
|
23
23
|
|
|
24
24
|
from statewire import StatewireReject
|
|
25
25
|
from statewire.state import plain
|
|
@@ -51,10 +51,6 @@ _TRIGGERS = (
|
|
|
51
51
|
|
|
52
52
|
_DECISIONS = ("approve", "reject", "edit", "respond")
|
|
53
53
|
|
|
54
|
-
# Called with None it answers for the thread root: isLeaf True iff the thread is empty.
|
|
55
|
-
GetMessageMeta = Callable[[str | None], Awaitable[dict[str, Any] | None]]
|
|
56
|
-
|
|
57
|
-
|
|
58
54
|
def _reject(reason: str, message: str) -> StatewireReject:
|
|
59
55
|
return StatewireReject(message, payload={"reason": reason})
|
|
60
56
|
|
|
@@ -143,16 +139,31 @@ class _Effects:
|
|
|
143
139
|
|
|
144
140
|
|
|
145
141
|
class RunManager:
|
|
142
|
+
class Thread(Protocol):
|
|
143
|
+
"""The thread projection RunManager reads; the host owns the tree."""
|
|
144
|
+
|
|
145
|
+
async def get_message_meta(
|
|
146
|
+
self, message_id: str | None
|
|
147
|
+
) -> dict[str, Any] | None:
|
|
148
|
+
"""Meta for a known id ({parentId, role, isLeaf, onActiveBranch}), None for an unknown one; a None id probes the root ({isLeaf})."""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
async def get_leaf_message_id(self) -> str | None:
|
|
152
|
+
"""The active branch's current leaf id, None while the thread is empty."""
|
|
153
|
+
...
|
|
154
|
+
|
|
146
155
|
def __init__(
|
|
147
156
|
self,
|
|
148
157
|
*,
|
|
149
158
|
state: Any,
|
|
150
159
|
run: Callable[["RunManager.RunContext"], Awaitable[Any]],
|
|
151
|
-
|
|
160
|
+
thread: "RunManager.Thread",
|
|
152
161
|
create_task: Callable[[Any], "asyncio.Task[Any]"],
|
|
153
162
|
schedule: Callable[[Callable[[], None]], None],
|
|
154
163
|
capabilities: Iterable[str] = (),
|
|
155
164
|
max_queued: int = 50,
|
|
165
|
+
prepare_message: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
166
|
+
prepare_input: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
156
167
|
) -> None:
|
|
157
168
|
caps = frozenset(capabilities)
|
|
158
169
|
unknown = caps - _CAPABILITIES
|
|
@@ -164,11 +175,13 @@ class RunManager:
|
|
|
164
175
|
raise ValueError("max_queued must be >= 1")
|
|
165
176
|
self._state = state
|
|
166
177
|
self._run = run
|
|
167
|
-
self.
|
|
178
|
+
self._thread = thread
|
|
168
179
|
self._capabilities = caps
|
|
169
180
|
self._create_task = create_task
|
|
170
181
|
self._schedule = schedule
|
|
171
182
|
self._max_queued = max_queued
|
|
183
|
+
self._prepare_message = prepare_message
|
|
184
|
+
self._prepare_input = prepare_input
|
|
172
185
|
self._task: "asyncio.Task[Any] | None" = None
|
|
173
186
|
self._ctx: "RunManager.RunContext | None" = None
|
|
174
187
|
self._dispatched_ids: tuple[str, ...] = ()
|
|
@@ -480,6 +493,7 @@ class RunManager:
|
|
|
480
493
|
"run settled without acking its messages (call ctx.ack_messages())"
|
|
481
494
|
)
|
|
482
495
|
except asyncio.CancelledError:
|
|
496
|
+
await self._pull_leaf()
|
|
483
497
|
self._settle(ctx)
|
|
484
498
|
self._set_status("stopped")
|
|
485
499
|
self._entry()["runId"] = None
|
|
@@ -488,6 +502,7 @@ class RunManager:
|
|
|
488
502
|
self._idle.set()
|
|
489
503
|
raise # no drain, no freeze
|
|
490
504
|
except Exception as exc:
|
|
505
|
+
await self._pull_leaf()
|
|
491
506
|
self._settle(ctx)
|
|
492
507
|
message = str(exc) or type(exc).__name__
|
|
493
508
|
if isinstance(exc, StatewireReject):
|
|
@@ -499,6 +514,7 @@ class RunManager:
|
|
|
499
514
|
self._revert_dispatching()
|
|
500
515
|
self._drain()
|
|
501
516
|
return
|
|
517
|
+
await self._pull_leaf()
|
|
502
518
|
self._settle(ctx)
|
|
503
519
|
if isinstance(outcome, RunManager.Error):
|
|
504
520
|
error = _reject("run-error", "run ended in error")
|
|
@@ -511,6 +527,12 @@ class RunManager:
|
|
|
511
527
|
self._outcome = outcome
|
|
512
528
|
self._drain()
|
|
513
529
|
|
|
530
|
+
async def _pull_leaf(self) -> None:
|
|
531
|
+
# An unacked end reverts instead of recording a leaf.
|
|
532
|
+
if self._dispatching or not self._run_acked:
|
|
533
|
+
return
|
|
534
|
+
self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
|
|
535
|
+
|
|
514
536
|
def _settle(self, ctx: "RunManager.RunContext") -> None:
|
|
515
537
|
if self._ctx is ctx:
|
|
516
538
|
self._ctx = None
|
|
@@ -585,7 +607,22 @@ class RunManager:
|
|
|
585
607
|
|
|
586
608
|
# ─── Message and placement validation ───────────────────
|
|
587
609
|
|
|
610
|
+
def _prepared(
|
|
611
|
+
self,
|
|
612
|
+
hook: Callable[[dict[str, Any]], dict[str, Any]] | None,
|
|
613
|
+
name: str,
|
|
614
|
+
value: Any,
|
|
615
|
+
) -> Any:
|
|
616
|
+
# Non-dict input skips the hook and falls through to validation's reject.
|
|
617
|
+
if hook is None or not isinstance(value, dict):
|
|
618
|
+
return value
|
|
619
|
+
result = hook(value)
|
|
620
|
+
if not isinstance(result, dict):
|
|
621
|
+
raise _reject("invalid-message", f"{name} must return an object")
|
|
622
|
+
return result
|
|
623
|
+
|
|
588
624
|
def _validated_message(self, message: Any) -> dict[str, Any]:
|
|
625
|
+
message = self._prepared(self._prepare_message, "prepare_message", message)
|
|
589
626
|
if not isinstance(message, dict):
|
|
590
627
|
raise _reject("invalid-message", "message must be an object")
|
|
591
628
|
if not isinstance(message.get("id"), str) or message["id"] == "":
|
|
@@ -907,7 +944,7 @@ class RunManager:
|
|
|
907
944
|
anchor_meta: dict[str, Any] | None = None
|
|
908
945
|
thread_empty = False
|
|
909
946
|
if anchor is None:
|
|
910
|
-
root = await self.
|
|
947
|
+
root = await self._thread.get_message_meta(None)
|
|
911
948
|
assert root is not None, "get_message_meta(None) must answer for the root"
|
|
912
949
|
thread_empty = bool(root["isLeaf"])
|
|
913
950
|
elif anchor is not _ABSENT:
|
|
@@ -915,7 +952,7 @@ class RunManager:
|
|
|
915
952
|
raise _reject(
|
|
916
953
|
"invalid-message", "anchorMessageId must be an id or null"
|
|
917
954
|
)
|
|
918
|
-
anchor_meta = await self.
|
|
955
|
+
anchor_meta = await self._thread.get_message_meta(anchor)
|
|
919
956
|
if not has_message:
|
|
920
957
|
message_id = params["messageId"]
|
|
921
958
|
if not isinstance(message_id, str):
|
|
@@ -935,7 +972,7 @@ class RunManager:
|
|
|
935
972
|
)
|
|
936
973
|
)
|
|
937
974
|
message = self._validated_message(params["message"])
|
|
938
|
-
source_meta = await self.
|
|
975
|
+
source_meta = await self._thread.get_message_meta(message["id"])
|
|
939
976
|
return await self._stage(
|
|
940
977
|
_Send(
|
|
941
978
|
lane,
|
|
@@ -984,7 +1021,7 @@ class RunManager:
|
|
|
984
1021
|
source_id = params.get("sourceId") if isinstance(params, dict) else None
|
|
985
1022
|
if not isinstance(source_id, str):
|
|
986
1023
|
raise _reject("invalid-message", "sourceId must be a string")
|
|
987
|
-
source_meta = await self.
|
|
1024
|
+
source_meta = await self._thread.get_message_meta(source_id)
|
|
988
1025
|
if source_meta is None:
|
|
989
1026
|
raise _reject("unknown-id", f"message {source_id} is unknown")
|
|
990
1027
|
if source_meta["role"] != "user" and "assistant-edit" not in self._capabilities:
|
|
@@ -996,7 +1033,7 @@ class RunManager:
|
|
|
996
1033
|
)
|
|
997
1034
|
if (
|
|
998
1035
|
message["id"] != source_id
|
|
999
|
-
and await self.
|
|
1036
|
+
and await self._thread.get_message_meta(message["id"]) is not None
|
|
1000
1037
|
):
|
|
1001
1038
|
raise _reject("duplicate-id", f"message id {message['id']} is already used")
|
|
1002
1039
|
return await self._stage(_Edit(source_id, source_meta, message, meta, ack))
|
|
@@ -1008,13 +1045,13 @@ class RunManager:
|
|
|
1008
1045
|
source_id = params.get("sourceId") if isinstance(params, dict) else None
|
|
1009
1046
|
if not isinstance(source_id, str):
|
|
1010
1047
|
raise _reject("invalid-message", "sourceId must be a string")
|
|
1011
|
-
source_meta = await self.
|
|
1048
|
+
source_meta = await self._thread.get_message_meta(source_id)
|
|
1012
1049
|
if source_meta is None:
|
|
1013
1050
|
raise _reject("unknown-id", f"message {source_id} is unknown")
|
|
1014
1051
|
if source_meta["role"] != "assistant":
|
|
1015
1052
|
raise _reject("invalid-message", "sourceId must name an assistant message")
|
|
1016
1053
|
if source_meta["parentId"] is not None:
|
|
1017
|
-
parent = await self.
|
|
1054
|
+
parent = await self._thread.get_message_meta(source_meta["parentId"])
|
|
1018
1055
|
if (
|
|
1019
1056
|
parent is not None
|
|
1020
1057
|
and parent["role"] == "assistant"
|
|
@@ -1049,6 +1086,7 @@ class RunManager:
|
|
|
1049
1086
|
return await entry.future
|
|
1050
1087
|
|
|
1051
1088
|
def _validated_response(self, request_type: str, response: Any) -> dict[str, Any]:
|
|
1089
|
+
response = self._prepared(self._prepare_input, "prepare_input", response)
|
|
1052
1090
|
if not isinstance(response, dict):
|
|
1053
1091
|
raise _reject("invalid-message", "response must be an object")
|
|
1054
1092
|
if request_type == "tool-call":
|
|
@@ -1230,14 +1268,6 @@ class RunManager:
|
|
|
1230
1268
|
self._ensure_active()
|
|
1231
1269
|
self._manager._ack_messages()
|
|
1232
1270
|
|
|
1233
|
-
def set_leaf_message_id(self, message_id: str) -> None:
|
|
1234
|
-
self._ensure_active()
|
|
1235
|
-
if not isinstance(message_id, str) or message_id == "":
|
|
1236
|
-
raise ValueError("message_id must be a non-empty string")
|
|
1237
|
-
if self._manager._dispatching or not self._manager._run_acked:
|
|
1238
|
-
raise RuntimeError("ack_messages must precede set_leaf_message_id")
|
|
1239
|
-
self._manager._entry()["runLeafMessageId"] = message_id
|
|
1240
|
-
|
|
1241
1271
|
def set_recovery_state(self, value: Any) -> None:
|
|
1242
1272
|
self._ensure_active()
|
|
1243
1273
|
record = self._manager._dispatch_record
|
|
@@ -32,6 +32,7 @@ class Script:
|
|
|
32
32
|
def __init__(self) -> None:
|
|
33
33
|
self.calls: asyncio.Queue[Call] = asyncio.Queue()
|
|
34
34
|
self.thread: dict[str, dict[str, Any]] = {}
|
|
35
|
+
self.leaf: str | None = None
|
|
35
36
|
|
|
36
37
|
async def run(self, ctx: RunManager.RunContext) -> Any:
|
|
37
38
|
call = Call(ctx, asyncio.get_running_loop().create_future())
|
|
@@ -43,6 +44,9 @@ class Script:
|
|
|
43
44
|
return {"isLeaf": not self.thread}
|
|
44
45
|
return self.thread.get(message_id)
|
|
45
46
|
|
|
47
|
+
async def get_leaf_message_id(self) -> str | None:
|
|
48
|
+
return self.leaf
|
|
49
|
+
|
|
46
50
|
async def next_call(self, timeout: float = 5) -> Call:
|
|
47
51
|
return await asyncio.wait_for(self.calls.get(), timeout)
|
|
48
52
|
|
|
@@ -55,7 +59,14 @@ def _meta(params: Any) -> Any:
|
|
|
55
59
|
return params.pop("meta", None) if isinstance(params, dict) else None
|
|
56
60
|
|
|
57
61
|
|
|
58
|
-
def make_host(
|
|
62
|
+
def make_host(
|
|
63
|
+
script: Script,
|
|
64
|
+
capabilities=(),
|
|
65
|
+
initial_runs=None,
|
|
66
|
+
max_queued=50,
|
|
67
|
+
prepare_message=None,
|
|
68
|
+
prepare_input=None,
|
|
69
|
+
):
|
|
59
70
|
class Host(Statewire):
|
|
60
71
|
live: "Host | None" = None
|
|
61
72
|
|
|
@@ -67,11 +78,13 @@ def make_host(script: Script, capabilities=(), initial_runs=None, max_queued=50)
|
|
|
67
78
|
self.runs = RunManager(
|
|
68
79
|
state=self.state,
|
|
69
80
|
run=script.run,
|
|
70
|
-
|
|
81
|
+
thread=script,
|
|
71
82
|
create_task=self.create_task,
|
|
72
83
|
schedule=self.schedule,
|
|
73
84
|
capabilities=capabilities,
|
|
74
85
|
max_queued=max_queued,
|
|
86
|
+
prepare_message=prepare_message,
|
|
87
|
+
prepare_input=prepare_input,
|
|
75
88
|
)
|
|
76
89
|
yield
|
|
77
90
|
|
|
@@ -218,12 +231,21 @@ class RunDriver:
|
|
|
218
231
|
|
|
219
232
|
|
|
220
233
|
@asynccontextmanager
|
|
221
|
-
async def run_host(
|
|
234
|
+
async def run_host(
|
|
235
|
+
script: Script,
|
|
236
|
+
capabilities=(),
|
|
237
|
+
initial_runs=None,
|
|
238
|
+
max_queued=50,
|
|
239
|
+
prepare_message=None,
|
|
240
|
+
prepare_input=None,
|
|
241
|
+
):
|
|
222
242
|
host_cls = make_host(
|
|
223
243
|
script,
|
|
224
244
|
capabilities=capabilities,
|
|
225
245
|
initial_runs=initial_runs,
|
|
226
246
|
max_queued=max_queued,
|
|
247
|
+
prepare_message=prepare_message,
|
|
248
|
+
prepare_input=prepare_input,
|
|
227
249
|
)
|
|
228
250
|
async with statewire_client(host_cls) as (app, client):
|
|
229
251
|
async with stream_of(app) as stream:
|
|
@@ -301,7 +301,7 @@ async def test_max_queued_below_one_rejects_at_construction():
|
|
|
301
301
|
RunManager(
|
|
302
302
|
state={},
|
|
303
303
|
run=script.run,
|
|
304
|
-
|
|
304
|
+
thread=script,
|
|
305
305
|
create_task=lambda coro: None,
|
|
306
306
|
schedule=lambda fn: None,
|
|
307
307
|
max_queued=0,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Contract: ``linear_thread`` projects a linear message list for RunManager —
|
|
2
|
+
``get_message_meta(None)`` probes the root (isLeaf iff empty), a known id gets
|
|
3
|
+
``{parentId, role, isLeaf, onActiveBranch}`` chained by list order, an unknown
|
|
4
|
+
id gets ``None``; ``get_leaf_message_id`` is the last message's id; the extra
|
|
5
|
+
``get_message_child_id`` resolves the first non-tool successor."""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from harness_sdk import linear_thread
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _role(message):
|
|
13
|
+
return "user" if message["type"] == "human" else "assistant"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _thread_for(messages):
|
|
17
|
+
return linear_thread(messages=lambda: messages, role=_role)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
THREAD = [
|
|
21
|
+
{"id": "u1", "type": "human"},
|
|
22
|
+
{"id": "a1", "type": "ai"},
|
|
23
|
+
{"id": "u2", "type": "human"},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_root_probe_reports_empty_thread():
|
|
28
|
+
thread = _thread_for([])
|
|
29
|
+
assert await thread.get_message_meta(None) == {"isLeaf": True}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_root_probe_reports_nonempty_thread():
|
|
33
|
+
thread = _thread_for(THREAD)
|
|
34
|
+
assert await thread.get_message_meta(None) == {"isLeaf": False}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_first_message_has_no_parent():
|
|
38
|
+
thread = _thread_for(THREAD)
|
|
39
|
+
assert await thread.get_message_meta("u1") == {
|
|
40
|
+
"parentId": None,
|
|
41
|
+
"role": "user",
|
|
42
|
+
"isLeaf": False,
|
|
43
|
+
"onActiveBranch": True,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_middle_message_chains_to_predecessor():
|
|
48
|
+
thread = _thread_for(THREAD)
|
|
49
|
+
assert await thread.get_message_meta("a1") == {
|
|
50
|
+
"parentId": "u1",
|
|
51
|
+
"role": "assistant",
|
|
52
|
+
"isLeaf": False,
|
|
53
|
+
"onActiveBranch": True,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_last_message_is_leaf():
|
|
58
|
+
thread = _thread_for(THREAD)
|
|
59
|
+
assert await thread.get_message_meta("u2") == {
|
|
60
|
+
"parentId": "a1",
|
|
61
|
+
"role": "user",
|
|
62
|
+
"isLeaf": True,
|
|
63
|
+
"onActiveBranch": True,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_unknown_id_returns_none():
|
|
68
|
+
thread = _thread_for(THREAD)
|
|
69
|
+
assert await thread.get_message_meta("nope") is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_leaf_is_the_last_message_id():
|
|
73
|
+
assert await _thread_for(THREAD).get_leaf_message_id() == "u2"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_leaf_of_an_empty_thread_is_none():
|
|
77
|
+
assert await _thread_for([]).get_leaf_message_id() is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_child_id_skips_tool_messages():
|
|
81
|
+
thread = _thread_for(
|
|
82
|
+
[
|
|
83
|
+
{"id": "u1", "type": "human"},
|
|
84
|
+
{"id": "t1", "type": "tool"},
|
|
85
|
+
{"id": "t2", "type": "tool"},
|
|
86
|
+
{"id": "u2", "type": "human"},
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
assert await thread.get_message_child_id("u1") == "u2"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def test_child_id_with_only_tool_successors_is_none():
|
|
93
|
+
thread = _thread_for(
|
|
94
|
+
[
|
|
95
|
+
{"id": "u1", "type": "human"},
|
|
96
|
+
{"id": "t1", "type": "tool"},
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
assert await thread.get_message_child_id("u1") is None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def test_child_id_of_an_unknown_parent_is_none():
|
|
103
|
+
assert await _thread_for(THREAD).get_message_child_id("nope") is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_reads_the_live_list():
|
|
107
|
+
messages = []
|
|
108
|
+
thread = linear_thread(messages=lambda: messages, role=_role)
|
|
109
|
+
assert await thread.get_message_meta(None) == {"isLeaf": True}
|
|
110
|
+
messages.append({"id": "u1", "type": "human"})
|
|
111
|
+
assert await thread.get_message_meta(None) == {"isLeaf": False}
|
|
112
|
+
assert await thread.get_leaf_message_id() == "u1"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_non_callable_messages_raises():
|
|
116
|
+
with pytest.raises(TypeError, match="messages must be callable"):
|
|
117
|
+
linear_thread(messages=THREAD, role=_role)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_non_callable_role_raises():
|
|
121
|
+
with pytest.raises(TypeError, match="role must be callable"):
|
|
122
|
+
linear_thread(messages=lambda: THREAD, role="user")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Contract: admission hooks. prepare_message rewrites each incoming wire
|
|
2
|
+
message before validation on every message-carrying path (enqueue, steer,
|
|
3
|
+
edit), once per message at admission; prepare_input rewrites each run/input
|
|
4
|
+
response before validation. A non-dict return rejects invalid-message."""
|
|
5
|
+
|
|
6
|
+
from run_helpers import Script, assert_rejected, msg, run_host
|
|
7
|
+
|
|
8
|
+
from harness_sdk import RunManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def noisy(id: str) -> dict:
|
|
12
|
+
return {
|
|
13
|
+
"id": id,
|
|
14
|
+
"role": "user",
|
|
15
|
+
"parts": [{"type": "text", "text": id}, {"type": "x-note", "note": "n"}],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def strip_notes(message: dict) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
**message,
|
|
22
|
+
"parts": [p for p in message["parts"] if p["type"] != "x-note"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_unhooked_noisy_message_rejects():
|
|
27
|
+
script = Script()
|
|
28
|
+
async with run_host(script) as (drv, host):
|
|
29
|
+
assert_rejected(
|
|
30
|
+
await drv.command(
|
|
31
|
+
"run/enqueue",
|
|
32
|
+
{"message": noisy("m1"), "anchorMessageId": None},
|
|
33
|
+
terminal=False,
|
|
34
|
+
),
|
|
35
|
+
"invalid-message",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_prepare_message_runs_once_at_enqueue_admission():
|
|
40
|
+
calls: list[str] = []
|
|
41
|
+
|
|
42
|
+
def hook(message):
|
|
43
|
+
calls.append(message["id"])
|
|
44
|
+
return strip_notes(message)
|
|
45
|
+
|
|
46
|
+
script = Script()
|
|
47
|
+
async with run_host(script, prepare_message=hook) as (drv, host):
|
|
48
|
+
await drv.command(
|
|
49
|
+
"run/enqueue",
|
|
50
|
+
{"message": noisy("m1"), "anchorMessageId": None},
|
|
51
|
+
terminal=False,
|
|
52
|
+
)
|
|
53
|
+
call = await script.next_call()
|
|
54
|
+
await drv.command(
|
|
55
|
+
"run/enqueue",
|
|
56
|
+
{"message": noisy("m2"), "anchorMessageId": None},
|
|
57
|
+
terminal=False,
|
|
58
|
+
)
|
|
59
|
+
call.ack()
|
|
60
|
+
call.finish(RunManager.Complete())
|
|
61
|
+
queued = await script.next_call()
|
|
62
|
+
assert calls == ["m1", "m2"]
|
|
63
|
+
assert [m["parts"] for m in call.ctx.messages] == [
|
|
64
|
+
[{"type": "text", "text": "m1"}]
|
|
65
|
+
]
|
|
66
|
+
assert [m["parts"] for m in queued.ctx.messages] == [
|
|
67
|
+
[{"type": "text", "text": "m2"}]
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_prepare_message_applies_to_steer():
|
|
72
|
+
script = Script()
|
|
73
|
+
async with run_host(script, prepare_message=strip_notes) as (drv, host):
|
|
74
|
+
await drv.command(
|
|
75
|
+
"run/enqueue", {"message": msg("m1"), "anchorMessageId": None}, terminal=False
|
|
76
|
+
)
|
|
77
|
+
call = await script.next_call()
|
|
78
|
+
await drv.command(
|
|
79
|
+
"run/steer", {"message": noisy("s1"), "anchorMessageId": "m1"}, terminal=False
|
|
80
|
+
)
|
|
81
|
+
taken = call.ctx.steering.take()
|
|
82
|
+
assert [m["parts"] for m in taken] == [[{"type": "text", "text": "s1"}]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_prepare_message_applies_to_edit():
|
|
86
|
+
script = Script()
|
|
87
|
+
script.thread.update(
|
|
88
|
+
{
|
|
89
|
+
"u1": {
|
|
90
|
+
"parentId": None,
|
|
91
|
+
"role": "user",
|
|
92
|
+
"isLeaf": True,
|
|
93
|
+
"onActiveBranch": True,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
async with run_host(
|
|
98
|
+
script, capabilities=("rewind",), prepare_message=strip_notes
|
|
99
|
+
) as (drv, host):
|
|
100
|
+
await drv.command(
|
|
101
|
+
"run/edit", {"sourceId": "u1", "message": noisy("u2")}, terminal=False
|
|
102
|
+
)
|
|
103
|
+
call = await script.next_call()
|
|
104
|
+
assert call.ctx.trigger == "message-edit"
|
|
105
|
+
assert [m["parts"] for m in call.ctx.messages] == [
|
|
106
|
+
[{"type": "text", "text": "u2"}]
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_prepare_input_transforms_response_before_validation():
|
|
111
|
+
def hook(response):
|
|
112
|
+
return {"output": response["result"]}
|
|
113
|
+
|
|
114
|
+
script = Script()
|
|
115
|
+
async with run_host(script, prepare_input=hook) as (drv, host):
|
|
116
|
+
await drv.command(
|
|
117
|
+
"run/enqueue", {"message": msg("m1"), "anchorMessageId": None}, terminal=False
|
|
118
|
+
)
|
|
119
|
+
call = await script.next_call()
|
|
120
|
+
call.ack()
|
|
121
|
+
call.finish(
|
|
122
|
+
RunManager.InputRequired(
|
|
123
|
+
({"type": "tool-call", "id": "r1", "toolCallId": "tc1"},)
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
await drv.wait_status("input-required")
|
|
127
|
+
res = await drv.command(
|
|
128
|
+
"run/input", {"requestId": "r1", "response": {"result": "ok"}}
|
|
129
|
+
)
|
|
130
|
+
assert res["type"] == "accepted"
|
|
131
|
+
resumed = await script.next_call()
|
|
132
|
+
assert resumed.ctx.input_outcomes[0][1] == {"output": "ok"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def test_prepare_message_non_dict_return_rejects():
|
|
136
|
+
script = Script()
|
|
137
|
+
async with run_host(script, prepare_message=lambda m: None) as (drv, host):
|
|
138
|
+
assert_rejected(
|
|
139
|
+
await drv.command(
|
|
140
|
+
"run/enqueue",
|
|
141
|
+
{"message": msg("m1"), "anchorMessageId": None},
|
|
142
|
+
terminal=False,
|
|
143
|
+
),
|
|
144
|
+
"invalid-message",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_prepare_input_non_dict_return_rejects():
|
|
149
|
+
script = Script()
|
|
150
|
+
async with run_host(script, prepare_input=lambda r: "nope") as (drv, host):
|
|
151
|
+
await drv.command(
|
|
152
|
+
"run/enqueue", {"message": msg("m1"), "anchorMessageId": None}, terminal=False
|
|
153
|
+
)
|
|
154
|
+
call = await script.next_call()
|
|
155
|
+
call.ack()
|
|
156
|
+
call.finish(
|
|
157
|
+
RunManager.InputRequired(
|
|
158
|
+
({"type": "tool-call", "id": "r1", "toolCallId": "tc1"},)
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
await drv.wait_status("input-required")
|
|
162
|
+
assert_rejected(
|
|
163
|
+
await drv.command(
|
|
164
|
+
"run/input", {"requestId": "r1", "response": {"output": "ok"}}
|
|
165
|
+
),
|
|
166
|
+
"invalid-message",
|
|
167
|
+
)
|
|
@@ -130,7 +130,7 @@ async def test_rewind_during_run_requires_rewind():
|
|
|
130
130
|
RunManager(
|
|
131
131
|
state={},
|
|
132
132
|
run=script.run,
|
|
133
|
-
|
|
133
|
+
thread=script,
|
|
134
134
|
create_task=asyncio.create_task,
|
|
135
135
|
schedule=lambda fn: None,
|
|
136
136
|
capabilities=("rewind-during-run",),
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import pytest
|
|
2
1
|
from run_helpers import Script, add, msg, run_host, run_of
|
|
3
2
|
|
|
4
3
|
from harness_sdk import RunManager
|
|
@@ -66,15 +65,48 @@ async def test_steering_take_sets_the_leaf():
|
|
|
66
65
|
call.finish(RunManager.Complete())
|
|
67
66
|
|
|
68
67
|
|
|
69
|
-
async def
|
|
68
|
+
async def test_settle_pulls_the_thread_leaf():
|
|
69
|
+
for outcome in [
|
|
70
|
+
RunManager.Error(dispatch_queue=False),
|
|
71
|
+
RunManager.Stop(dispatch_queue=False),
|
|
72
|
+
]:
|
|
73
|
+
script = Script()
|
|
74
|
+
async with run_host(script) as (drv, host):
|
|
75
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
76
|
+
call = await script.next_call()
|
|
77
|
+
call.ack()
|
|
78
|
+
script.leaf = "a1"
|
|
79
|
+
call.finish(outcome)
|
|
80
|
+
await drv.wait_status("error", "stopped")
|
|
81
|
+
assert drv.run["runLeafMessageId"] == "a1"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_input_required_settle_pulls_the_thread_leaf():
|
|
70
85
|
script = Script()
|
|
71
86
|
async with run_host(script) as (drv, host):
|
|
72
87
|
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
73
88
|
call = await script.next_call()
|
|
74
89
|
call.ack()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
script.leaf = "a1"
|
|
91
|
+
call.finish(
|
|
92
|
+
RunManager.InputRequired(
|
|
93
|
+
requests=({"id": "r1", "type": "tool-call", "toolCallId": "t1"},)
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
await drv.wait_status("input-required")
|
|
97
|
+
assert drv.run["runLeafMessageId"] == "a1"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_unacked_end_reverts_instead_of_pulling():
|
|
101
|
+
script = Script()
|
|
102
|
+
async with run_host(script) as (drv, host):
|
|
103
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
104
|
+
call = await script.next_call()
|
|
105
|
+
script.leaf = "a1"
|
|
106
|
+
call.fail(RuntimeError("boom"))
|
|
107
|
+
await drv.wait_status("error")
|
|
108
|
+
assert drv.run["runLeafMessageId"] is None
|
|
109
|
+
assert [item["id"] for item in drv.run["queue"]] == ["m1"]
|
|
78
110
|
|
|
79
111
|
|
|
80
112
|
async def test_complete_clears_the_leaf_on_ready():
|
|
@@ -83,8 +115,7 @@ async def test_complete_clears_the_leaf_on_ready():
|
|
|
83
115
|
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
84
116
|
call = await script.next_call()
|
|
85
117
|
call.ack()
|
|
86
|
-
|
|
87
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a1")
|
|
118
|
+
script.leaf = "a1"
|
|
88
119
|
call.finish(RunManager.Complete())
|
|
89
120
|
await drv.wait_status("ready")
|
|
90
121
|
assert drv.replica["runs"] == []
|
|
@@ -97,7 +128,7 @@ async def test_complete_with_a_queued_item_keeps_the_leaf_flowing():
|
|
|
97
128
|
call = await script.next_call()
|
|
98
129
|
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
99
130
|
call.ack()
|
|
100
|
-
|
|
131
|
+
script.leaf = "a1"
|
|
101
132
|
call.finish(RunManager.Complete())
|
|
102
133
|
drain = await script.next_call()
|
|
103
134
|
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m2")
|
|
@@ -107,22 +138,6 @@ async def test_complete_with_a_queued_item_keeps_the_leaf_flowing():
|
|
|
107
138
|
assert drv.replica["runs"] == []
|
|
108
139
|
|
|
109
140
|
|
|
110
|
-
async def test_error_and_stop_keep_the_in_flight_leaf():
|
|
111
|
-
for outcome in [
|
|
112
|
-
RunManager.Error(dispatch_queue=False),
|
|
113
|
-
RunManager.Stop(dispatch_queue=False),
|
|
114
|
-
]:
|
|
115
|
-
script = Script()
|
|
116
|
-
async with run_host(script) as (drv, host):
|
|
117
|
-
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
118
|
-
call = await script.next_call()
|
|
119
|
-
call.ack()
|
|
120
|
-
call.ctx.set_leaf_message_id("a1")
|
|
121
|
-
call.finish(outcome)
|
|
122
|
-
await drv.wait_status("error", "stopped")
|
|
123
|
-
assert drv.run["runLeafMessageId"] == "a1"
|
|
124
|
-
|
|
125
|
-
|
|
126
141
|
async def test_edit_dispatch_sets_the_leaf_to_the_replacement():
|
|
127
142
|
script = Script()
|
|
128
143
|
thread_with_turn(script)
|
|
@@ -142,16 +157,6 @@ async def test_reload_dispatch_sets_the_leaf_to_the_rollback_target():
|
|
|
142
157
|
call = await script.next_call()
|
|
143
158
|
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u1")
|
|
144
159
|
call.ack()
|
|
145
|
-
|
|
160
|
+
script.leaf = "a2"
|
|
161
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
146
162
|
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a2")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
async def test_report_rejects_an_empty_id():
|
|
150
|
-
script = Script()
|
|
151
|
-
async with run_host(script) as (drv, host):
|
|
152
|
-
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
153
|
-
call = await script.next_call()
|
|
154
|
-
with pytest.raises(ValueError, match="non-empty"):
|
|
155
|
-
call.ctx.set_leaf_message_id("")
|
|
156
|
-
call.ack()
|
|
157
|
-
call.finish(RunManager.Complete())
|
|
@@ -146,17 +146,6 @@ async def test_complete_without_ack_is_a_run_error():
|
|
|
146
146
|
assert (await drv.res(pending["seq"]))["type"] == "accepted"
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
async def test_set_leaf_before_ack_raises():
|
|
150
|
-
script = Script()
|
|
151
|
-
async with run_host(script) as (drv, host):
|
|
152
|
-
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
153
|
-
call = await script.next_call()
|
|
154
|
-
with pytest.raises(RuntimeError, match="ack_messages"):
|
|
155
|
-
call.ctx.set_leaf_message_id("a1")
|
|
156
|
-
call.ack()
|
|
157
|
-
call.finish(RunManager.Complete())
|
|
158
|
-
|
|
159
|
-
|
|
160
149
|
async def test_dequeue_removes_a_parked_entry():
|
|
161
150
|
script = Script()
|
|
162
151
|
async with run_host(script) as (drv, host):
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
from typing import Any, Callable
|
|
2
|
-
|
|
3
|
-
from .run_manager import GetMessageMeta
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def linear_thread_meta(
|
|
7
|
-
*,
|
|
8
|
-
messages: Callable[[], list[dict[str, Any]]],
|
|
9
|
-
role: Callable[[dict[str, Any]], str],
|
|
10
|
-
) -> GetMessageMeta:
|
|
11
|
-
"""RunManager ``get_message_meta`` for a linear message list: ``messages`` returns the current list, ``role`` maps a message to its role."""
|
|
12
|
-
if not callable(messages):
|
|
13
|
-
raise TypeError("messages must be callable")
|
|
14
|
-
if not callable(role):
|
|
15
|
-
raise TypeError("role must be callable")
|
|
16
|
-
|
|
17
|
-
async def get_message_meta(message_id: str | None) -> dict[str, Any] | None:
|
|
18
|
-
items = messages()
|
|
19
|
-
if message_id is None:
|
|
20
|
-
return {"isLeaf": not items}
|
|
21
|
-
for index, message in enumerate(items):
|
|
22
|
-
if message.get("id") == message_id:
|
|
23
|
-
return {
|
|
24
|
-
"parentId": items[index - 1].get("id") if index > 0 else None,
|
|
25
|
-
"role": role(message),
|
|
26
|
-
"isLeaf": index == len(items) - 1,
|
|
27
|
-
"onActiveBranch": True,
|
|
28
|
-
}
|
|
29
|
-
return None
|
|
30
|
-
|
|
31
|
-
return get_message_meta
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"""Contract: ``linear_thread_meta`` answers ``get_message_meta`` over a linear
|
|
2
|
-
message list — ``None`` probes the root (isLeaf iff empty), a known id gets
|
|
3
|
-
``{parentId, role, isLeaf, onActiveBranch}`` chained by list order, an unknown
|
|
4
|
-
id gets ``None``."""
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from harness_sdk import linear_thread_meta
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _role(message):
|
|
12
|
-
return "user" if message["type"] == "human" else "assistant"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _meta_for(messages):
|
|
16
|
-
return linear_thread_meta(messages=lambda: messages, role=_role)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
THREAD = [
|
|
20
|
-
{"id": "u1", "type": "human"},
|
|
21
|
-
{"id": "a1", "type": "ai"},
|
|
22
|
-
{"id": "u2", "type": "human"},
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
async def test_root_probe_reports_empty_thread():
|
|
27
|
-
meta = _meta_for([])
|
|
28
|
-
assert await meta(None) == {"isLeaf": True}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
async def test_root_probe_reports_nonempty_thread():
|
|
32
|
-
meta = _meta_for(THREAD)
|
|
33
|
-
assert await meta(None) == {"isLeaf": False}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
async def test_first_message_has_no_parent():
|
|
37
|
-
meta = _meta_for(THREAD)
|
|
38
|
-
assert await meta("u1") == {
|
|
39
|
-
"parentId": None,
|
|
40
|
-
"role": "user",
|
|
41
|
-
"isLeaf": False,
|
|
42
|
-
"onActiveBranch": True,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
async def test_middle_message_chains_to_predecessor():
|
|
47
|
-
meta = _meta_for(THREAD)
|
|
48
|
-
assert await meta("a1") == {
|
|
49
|
-
"parentId": "u1",
|
|
50
|
-
"role": "assistant",
|
|
51
|
-
"isLeaf": False,
|
|
52
|
-
"onActiveBranch": True,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
async def test_last_message_is_leaf():
|
|
57
|
-
meta = _meta_for(THREAD)
|
|
58
|
-
assert await meta("u2") == {
|
|
59
|
-
"parentId": "a1",
|
|
60
|
-
"role": "user",
|
|
61
|
-
"isLeaf": True,
|
|
62
|
-
"onActiveBranch": True,
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
async def test_unknown_id_returns_none():
|
|
67
|
-
meta = _meta_for(THREAD)
|
|
68
|
-
assert await meta("nope") is None
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
async def test_reads_the_live_list():
|
|
72
|
-
messages = []
|
|
73
|
-
meta = linear_thread_meta(messages=lambda: messages, role=_role)
|
|
74
|
-
assert await meta(None) == {"isLeaf": True}
|
|
75
|
-
messages.append({"id": "u1", "type": "human"})
|
|
76
|
-
assert await meta(None) == {"isLeaf": False}
|
|
77
|
-
assert (await meta("u1"))["isLeaf"] is True
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_non_callable_messages_raises():
|
|
81
|
-
with pytest.raises(TypeError, match="messages must be callable"):
|
|
82
|
-
linear_thread_meta(messages=THREAD, role=_role)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_non_callable_role_raises():
|
|
86
|
-
with pytest.raises(TypeError, match="role must be callable"):
|
|
87
|
-
linear_thread_meta(messages=lambda: THREAD, role="user")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|