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.
Files changed (30) hide show
  1. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/README.md +1 -1
  3. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/pyproject.toml +1 -1
  4. harness_sdk_python-0.6.0/src/harness_sdk/__init__.py +4 -0
  5. harness_sdk_python-0.6.0/src/harness_sdk/linear_thread.py +52 -0
  6. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/src/harness_sdk/run_manager.py +52 -22
  7. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/run_helpers.py +25 -3
  8. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_enqueue.py +1 -1
  9. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_facade.py +1 -1
  10. harness_sdk_python-0.6.0/tests/test_linear_thread.py +122 -0
  11. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_outcomes.py +1 -1
  12. harness_sdk_python-0.6.0/tests/test_prepare_hooks.py +167 -0
  13. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_rewind_during_run.py +1 -1
  14. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_run_leaf.py +41 -36
  15. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_settle.py +0 -11
  16. harness_sdk_python-0.5.0/src/harness_sdk/__init__.py +0 -4
  17. harness_sdk_python-0.5.0/src/harness_sdk/linear_thread.py +0 -31
  18. harness_sdk_python-0.5.0/tests/test_linear_thread.py +0 -87
  19. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/.gitignore +0 -0
  20. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/src/harness_sdk/fenced_postgres.py +0 -0
  21. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_batches.py +0 -0
  22. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_branch_anchor.py +0 -0
  23. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_edit_dispatched.py +0 -0
  24. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_edit_reload.py +0 -0
  25. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_fenced_postgres.py +0 -0
  26. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_input_required.py +0 -0
  27. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_meta.py +0 -0
  28. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_placement.py +0 -0
  29. {harness_sdk_python-0.5.0 → harness_sdk_python-0.6.0}/tests/test_steer.py +0 -0
  30. {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.5.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
- get_message_meta=self._get_message_meta,
26
+ thread=self._thread,
27
27
  create_task=self.create_task,
28
28
  capabilities=("rewind",),
29
29
  )
@@ -11,7 +11,7 @@ class MyHost(Statewire):
11
11
  self.runs = RunManager(
12
12
  state=self.state,
13
13
  start=self._start,
14
- get_message_meta=self._get_message_meta,
14
+ thread=self._thread,
15
15
  create_task=self.create_task,
16
16
  capabilities=("rewind",),
17
17
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ from .linear_thread import linear_thread
2
+ from .run_manager import RunManager
3
+
4
+ __all__ = ["RunManager", "linear_thread"]
@@ -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
- get_message_meta: GetMessageMeta,
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._get_message_meta = get_message_meta
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._get_message_meta(None)
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._get_message_meta(anchor)
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._get_message_meta(message["id"])
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._get_message_meta(source_id)
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._get_message_meta(message["id"]) is not None
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._get_message_meta(source_id)
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._get_message_meta(source_meta["parentId"])
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(script: Script, capabilities=(), initial_runs=None, max_queued=50):
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
- get_message_meta=script.get_message_meta,
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(script: Script, capabilities=(), initial_runs=None, max_queued=50):
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
- get_message_meta=script.get_message_meta,
304
+ thread=script,
305
305
  create_task=lambda coro: None,
306
306
  schedule=lambda fn: None,
307
307
  max_queued=0,
@@ -22,7 +22,7 @@ def make_facade_host(script: Script):
22
22
  self.runs = RunManager(
23
23
  state=self.state,
24
24
  run=script.run,
25
- get_message_meta=script.get_message_meta,
25
+ thread=script,
26
26
  create_task=self.create_task,
27
27
  schedule=self.schedule,
28
28
  )
@@ -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")
@@ -127,7 +127,7 @@ async def test_unknown_capability_rejects_at_construction():
127
127
  RunManager(
128
128
  state={},
129
129
  run=None,
130
- get_message_meta=None,
130
+ thread=None,
131
131
  create_task=asyncio.create_task,
132
132
  schedule=lambda fn: None,
133
133
  capabilities=("time-travel",),
@@ -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
- get_message_meta=script.get_message_meta,
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 test_executor_report_sets_the_leaf():
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
- call.ctx.set_leaf_message_id("a1")
76
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a1")
77
- call.finish(RunManager.Complete())
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
- call.ctx.set_leaf_message_id("a1")
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
- call.ctx.set_leaf_message_id("a1")
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
- call.ctx.set_leaf_message_id("a2")
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,4 +0,0 @@
1
- from .linear_thread import linear_thread_meta
2
- from .run_manager import RunManager
3
-
4
- __all__ = ["RunManager", "linear_thread_meta"]
@@ -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")