harness-sdk-python 0.7.3__tar.gz → 0.9.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 (29) hide show
  1. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/linear_thread.py +0 -4
  4. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/run_manager.py +38 -46
  5. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/run_helpers.py +1 -5
  6. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_ack_visibility.py +2 -1
  7. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_dispatching.py +59 -9
  8. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_input_required.py +30 -0
  9. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_linear_thread.py +2 -11
  10. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_meta.py +7 -3
  11. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_settle.py +4 -3
  12. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_steer.py +2 -0
  13. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_stop_continue.py +1 -0
  14. harness_sdk_python-0.7.3/tests/test_run_leaf.py +0 -162
  15. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/.gitignore +0 -0
  16. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/README.md +0 -0
  17. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/__init__.py +0 -0
  18. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/fenced_postgres.py +0 -0
  19. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_batches.py +0 -0
  20. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_branch_anchor.py +0 -0
  21. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_edit_dispatched.py +0 -0
  22. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_edit_reload.py +0 -0
  23. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_enqueue.py +0 -0
  24. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_facade.py +0 -0
  25. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_fenced_postgres.py +0 -0
  26. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_outcomes.py +0 -0
  27. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_placement.py +0 -0
  28. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_prepare_hooks.py +0 -0
  29. {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_rewind_during_run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.7.3
3
+ Version: 0.9.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.7.3"
3
+ version = "0.9.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,10 +21,6 @@ class LinearThread:
21
21
  }
22
22
  return None
23
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
24
  async def get_message_child_id(self, parent_id: str) -> str | None:
29
25
  """The id of the first non-tool message after parent_id; None when parent_id is unknown or only tools follow."""
30
26
  items = self._messages()
@@ -14,10 +14,12 @@ and settle when their run ends — accepted once the run has acked its
14
14
  messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
15
15
  once the in-flight run has ended.
16
16
 
17
- ``ctx.ack_messages()`` is bookkeeping: it clears ``dispatching`` and confirms
18
- ``runLeafMessageId``. A run that ends with entries still in ``dispatching``
19
- reverts them by provenance: lane items to the front of their lane, direct
20
- dispatches to the front of the queue, rewind replacements dropped.
17
+ ``entry["dispatching"]`` is the dispatch record (or None): the payload the
18
+ run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
19
+ refill it at most one unacked batch ever exists. A run that ends with an
20
+ unacked record reverts its items
21
+ by provenance: lane items to the front of their lane, direct dispatches to
22
+ the front of the queue, rewind replacements dropped.
21
23
  """
22
24
 
23
25
  import asyncio
@@ -51,6 +53,7 @@ _TRIGGERS = (
51
53
  "input-resume",
52
54
  "error-continue",
53
55
  "stop-continue",
56
+ "steer",
54
57
  )
55
58
 
56
59
  _DECISIONS = ("approve", "reject", "edit", "respond")
@@ -152,10 +155,6 @@ class RunManager:
152
155
  """Meta for a known id ({parentId, role, isLeaf, onActiveBranch}), None for an unknown one; a None id probes the root ({isLeaf})."""
153
156
  ...
154
157
 
155
- async def get_leaf_message_id(self) -> str | None:
156
- """The active branch's current leaf id, None while the thread is empty."""
157
- ...
158
-
159
158
  def __init__(
160
159
  self,
161
160
  *,
@@ -197,7 +196,6 @@ class RunManager:
197
196
  self._staged_rewinds: list[_Rewind] = []
198
197
  self._dispatching: list[tuple[str, dict[str, Any]]] = []
199
198
  self._run_acked = False
200
- self._leaf_confirmed: str | None = None
201
199
  self._run_futures: list["asyncio.Future[Any]"] = []
202
200
  self._input_requests: list[dict[str, Any]] = []
203
201
  self._input_answers: dict[str, Any] = {}
@@ -219,7 +217,6 @@ class RunManager:
219
217
  "queue",
220
218
  "steerQueue",
221
219
  "runId",
222
- "runLeafMessageId",
223
220
  "dispatch",
224
221
  "inputRequests",
225
222
  ):
@@ -242,9 +239,8 @@ class RunManager:
242
239
  "status": "ready",
243
240
  "queue": [],
244
241
  "steerQueue": [],
245
- "dispatching": [],
242
+ "dispatching": None,
246
243
  "error": None,
247
- "runLeafMessageId": None,
248
244
  }
249
245
  )
250
246
  return self._state["runs"][0]
@@ -435,28 +431,26 @@ class RunManager:
435
431
  if self._task is not None:
436
432
  raise RuntimeError("a run is already in flight")
437
433
  message_meta = {m["id"]: m.get("meta") for m in messages}
438
- dispatching = list(messages)
434
+ items = list(messages)
439
435
  messages = [
440
436
  {k: v for k, v in message.items() if k != "meta"} for message in messages
441
437
  ]
442
- record: dict[str, Any] = {"trigger": trigger, "messages": list(messages)}
438
+ record: dict[str, Any] = {"trigger": trigger, "messages": items}
443
439
  if rollback_to is not _ABSENT:
444
440
  record["rollbackTo"] = rollback_to
445
441
  if input_outcomes:
446
- record["inputOutcomes"] = input_outcomes
447
- self._dispatch_record = record
442
+ record["inputOutcomes"] = [
443
+ {"request": request, "response": response, "meta": meta}
444
+ for request, response, meta in input_outcomes
445
+ ]
446
+ self._dispatch_record = dict(record)
448
447
  self._stop_reason = None
449
448
  self._run_acked = False
450
449
  entry = self._ensure_entry()
451
- self._leaf_confirmed = plain(entry["runLeafMessageId"])
452
- entry["dispatching"] = dispatching
450
+ entry["dispatching"] = record
453
451
  entry["error"] = None
454
452
  if messages:
455
453
  self._dispatched_ids = tuple(m["id"] for m in messages)
456
- if rollback_to is not _ABSENT:
457
- entry["runLeafMessageId"] = rollback_to
458
- if messages:
459
- entry["runLeafMessageId"] = messages[-1]["id"]
460
454
  entry["runId"] = uuid.uuid4().hex
461
455
  self._set_status("running")
462
456
  ctx = RunManager.RunContext(
@@ -491,7 +485,6 @@ class RunManager:
491
485
  f"{type(outcome).__name__}"
492
486
  )
493
487
  except asyncio.CancelledError:
494
- await self._pull_leaf()
495
488
  self._settle(ctx)
496
489
  self._set_status("stopped")
497
490
  self._entry()["runId"] = None
@@ -502,7 +495,6 @@ class RunManager:
502
495
  self._idle.set()
503
496
  raise # no drain, no freeze
504
497
  except Exception as exc:
505
- await self._pull_leaf()
506
498
  self._settle(ctx)
507
499
  message = str(exc) or type(exc).__name__
508
500
  if isinstance(exc, StatewireReject):
@@ -516,14 +508,13 @@ class RunManager:
516
508
  self._revert_dispatching()
517
509
  self._drain()
518
510
  return
519
- await self._pull_leaf()
520
511
  self._settle(ctx)
521
512
  if self._run_acked:
522
513
  error = None
523
514
  elif isinstance(outcome, RunManager.Error):
524
515
  error = _reject("run-error", "run ended in error")
525
516
  elif isinstance(outcome, RunManager.Stop):
526
- error = _reject("stopped", "run stopped before the messages-ack")
517
+ error = _reject("stopped", "run stopped before the ack")
527
518
  else:
528
519
  error = None
529
520
  self._settle_initiators(error)
@@ -531,12 +522,6 @@ class RunManager:
531
522
  self._outcome = outcome
532
523
  self._drain()
533
524
 
534
- async def _pull_leaf(self) -> None:
535
- # An end with unacked messages reverts instead of recording a leaf.
536
- if plain(self._entry()["dispatching"]):
537
- return
538
- self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
539
-
540
525
  def _settle(self, ctx: "RunManager.RunContext") -> None:
541
526
  if self._ctx is ctx:
542
527
  self._ctx = None
@@ -553,16 +538,19 @@ class RunManager:
553
538
  else:
554
539
  future.set_exception(error)
555
540
 
556
- def _ack_messages(self) -> None:
541
+ def _ack(self) -> None:
542
+ entry = self._entry()
543
+ record = plain(entry["dispatching"])
544
+ if record is None:
545
+ raise RuntimeError("ack() with no unacked batch")
557
546
  self._dispatching = []
558
- self._entry()["dispatching"] = []
547
+ entry["dispatching"] = None
559
548
  self._run_acked = True
560
- self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
561
549
 
562
550
  def _revert_dispatching(self) -> None:
563
551
  taken, self._dispatching = self._dispatching, []
564
552
  entry = self._ensure_entry()
565
- entry["dispatching"] = []
553
+ entry["dispatching"] = None
566
554
  if not taken:
567
555
  return
568
556
  for lane in ("steerQueue", "queue"):
@@ -571,8 +559,6 @@ class RunManager:
571
559
  entry[lane] = front + self._lane_items(lane)
572
560
  ids = {item["id"] for _, item in taken}
573
561
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
574
- if plain(entry["runLeafMessageId"]) in ids:
575
- entry["runLeafMessageId"] = self._leaf_confirmed
576
562
 
577
563
  def _dispatchable(self, lane: str) -> bool:
578
564
  if self._task is not None or self._staged_rewinds:
@@ -601,7 +587,10 @@ class RunManager:
601
587
  self._entry()["steerQueue"] = []
602
588
  self._dispatching.extend(("steerQueue", item) for item in steer)
603
589
  self._dispatching.append((e.lane, self._stamped(e.message, e.meta)))
604
- self._ensure_entry()["dispatching"] = [item for _, item in self._dispatching]
590
+ self._ensure_entry()["dispatching"] = {
591
+ "trigger": "message-send",
592
+ "messages": [item for _, item in self._dispatching],
593
+ }
605
594
 
606
595
  def _dispatch_staged(
607
596
  self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
@@ -1233,16 +1222,18 @@ class RunManager:
1233
1222
  _ctx: "RunManager.RunContext"
1234
1223
 
1235
1224
  def take(self) -> tuple[dict[str, Any], ...]:
1236
- """Drain the steer lane into this run; items leave the projection, retained until the ack."""
1225
+ """Drain the steer lane into a fresh dispatch record; requires the current batch to be acked."""
1237
1226
  self._ctx._ensure_active()
1238
1227
  manager = self._ctx._manager
1239
- items = manager._lane_items("steerQueue")
1240
1228
  entry = manager._entry()
1229
+ if plain(entry["dispatching"]) is not None:
1230
+ raise RuntimeError("take before the current batch is acked")
1231
+ items = manager._lane_items("steerQueue")
1232
+ if not items:
1233
+ return ()
1241
1234
  entry["steerQueue"] = []
1242
1235
  manager._dispatching.extend(("steerQueue", item) for item in items)
1243
- entry["dispatching"] = list(plain(entry["dispatching"])) + items
1244
- if items:
1245
- entry["runLeafMessageId"] = items[-1]["id"]
1236
+ entry["dispatching"] = {"trigger": "steer", "messages": items}
1246
1237
  self._ctx._message_meta.update(
1247
1238
  {item["id"]: item.get("meta") for item in items}
1248
1239
  )
@@ -1301,9 +1292,10 @@ class RunManager:
1301
1292
  if self._manager._ctx is not self:
1302
1293
  raise RuntimeError("this run has already settled")
1303
1294
 
1304
- def ack_messages(self) -> None:
1295
+ def ack(self) -> None:
1296
+ """Ack the current batch: clears the dispatch record."""
1305
1297
  self._ensure_active()
1306
- self._manager._ack_messages()
1298
+ self._manager._ack()
1307
1299
 
1308
1300
  def set_recovery_state(self, value: Any) -> None:
1309
1301
  self._ensure_active()
@@ -16,7 +16,7 @@ class Call:
16
16
  outcome: "asyncio.Future[Any]"
17
17
 
18
18
  def ack(self) -> None:
19
- self.ctx.ack_messages()
19
+ self.ctx.ack()
20
20
 
21
21
  def finish(self, outcome: Any) -> None:
22
22
  self.outcome.set_result(outcome)
@@ -32,7 +32,6 @@ 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
36
35
 
37
36
  async def run(self, ctx: RunManager.RunContext) -> Any:
38
37
  call = Call(ctx, asyncio.get_running_loop().create_future())
@@ -44,9 +43,6 @@ class Script:
44
43
  return {"isLeaf": not self.thread}
45
44
  return self.thread.get(message_id)
46
45
 
47
- async def get_leaf_message_id(self) -> str | None:
48
- return self.leaf
49
-
50
46
  async def next_call(self, timeout: float = 5) -> Call:
51
47
  return await asyncio.wait_for(self.calls.get(), timeout)
52
48
 
@@ -24,7 +24,8 @@ def covered_state(drv, seq):
24
24
 
25
25
  def dispatching_ids(state):
26
26
  runs = state.get("runs") or [{}]
27
- return [item["id"] for item in runs[0].get("dispatching", [])]
27
+ record = runs[0].get("dispatching")
28
+ return [item["id"] for item in record["messages"]] if record else []
28
29
 
29
30
 
30
31
  def assert_never_queued(drv, message_id):
@@ -1,13 +1,14 @@
1
- """Contract: replicated dispatch limbo. ``dispatching`` on the run entry is
2
- the current dispatch's unacked messagesqueue pops, steer-lane takes, and
3
- rewind dispatches alike. A message item lives in exactly one of the lanes,
4
- ``dispatching``, or the thread, and every transition is single-envelope
5
- atomic: dispatch moves it lane ``dispatching``, the messages-ack clears
6
- ``dispatching`` as the canonical message appears, and an unacked run end
7
- returns lane items to the front of their lane."""
1
+ """Contract: the replicated dispatch record. ``dispatching`` on the run entry
2
+ is the current unacked batcha record ``{trigger, messages, rollbackTo?,
3
+ inputOutcomes?}`` or None. A message item lives in exactly one of the lanes,
4
+ the record's ``messages``, or the thread, and every transition is
5
+ single-envelope atomic: dispatch fills the record, ``ctx.ack()`` clears it as
6
+ the canonical message appears, a steer take refills it, and an unacked run
7
+ end returns lane items to the front of their lane."""
8
8
 
9
9
  import copy
10
10
 
11
+ import pytest
11
12
  from run_helpers import Script, add, msg, queue_ids, run_host
12
13
 
13
14
  from harness_sdk import RunManager
@@ -16,16 +17,20 @@ from statewire_helpers import apply_ops
16
17
 
17
18
  def dispatching_ids(replica):
18
19
  runs = replica["runs"]
19
- return [item["id"] for item in runs[0]["dispatching"]] if runs else []
20
+ record = runs[0]["dispatching"] if runs else None
21
+ return [item["id"] for item in record["messages"]] if record else []
20
22
 
21
23
 
22
24
  def places(state, message_id):
23
25
  entry = (state.get("runs") or [{}])[0]
24
26
  found = {
25
27
  name
26
- for name in ("queue", "steerQueue", "dispatching")
28
+ for name in ("queue", "steerQueue")
27
29
  if any(item["id"] == message_id for item in entry.get(name, []))
28
30
  }
31
+ record = entry.get("dispatching")
32
+ if record and any(item["id"] == message_id for item in record["messages"]):
33
+ found.add("dispatching")
29
34
  if any(m["id"] == message_id for m in state.get("messages", [])):
30
35
  found.add("messages")
31
36
  return found
@@ -53,6 +58,7 @@ async def test_idle_send_dispatches_straight_into_dispatching():
53
58
  drv.post("run/enqueue", add("m1"))
54
59
  await script.next_call()
55
60
  await drv.wait_status("running")
61
+ assert drv.run["dispatching"]["trigger"] == "message-send"
56
62
  assert dispatching_ids(drv.replica) == ["m1"]
57
63
  assert queue_ids(drv.replica, "queue") == []
58
64
  assert residence(drv, "m1") == ["dispatching"]
@@ -97,6 +103,7 @@ async def test_steering_take_moves_the_lane_into_dispatching():
97
103
  await drv.command("run/steer", add("s1"), terminal=False)
98
104
  call.ctx.steering.take()
99
105
  await drv.wait(lambda s: dispatching_ids(s) == ["s1"])
106
+ assert drv.run["dispatching"]["trigger"] == "steer"
100
107
  assert queue_ids(drv.replica, "steerQueue") == []
101
108
  assert residence(drv, "s1") == ["steerQueue", "dispatching"]
102
109
  host.live.state["messages"] = [msg("m1"), msg("s1")]
@@ -121,3 +128,46 @@ async def test_edit_replacement_rides_dispatching_until_ack():
121
128
  assert residence(drv, "u2") == ["dispatching", "messages"]
122
129
  rerun.finish(RunManager.Complete())
123
130
  assert (await drv.res(seq))["type"] == "accepted"
131
+
132
+
133
+ async def test_take_before_the_ack_raises():
134
+ script = Script()
135
+ async with run_host(script) as (drv, host):
136
+ drv.post("run/enqueue", add("m1"))
137
+ call = await script.next_call()
138
+ await drv.side("run/steer", add("s1"))
139
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
140
+ with pytest.raises(RuntimeError, match="before the current batch is acked"):
141
+ call.ctx.steering.take()
142
+ call.ack()
143
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
144
+ call.ack()
145
+ call.finish(RunManager.Complete())
146
+
147
+
148
+ async def test_ack_with_no_unacked_batch_raises():
149
+ script = Script()
150
+ async with run_host(script) as (drv, host):
151
+ drv.post("run/enqueue", add("m1"))
152
+ call = await script.next_call()
153
+ call.ack()
154
+ with pytest.raises(RuntimeError, match="no unacked batch"):
155
+ call.ack()
156
+ call.finish(RunManager.Complete())
157
+
158
+
159
+ async def test_second_take_after_the_reack_drains_the_next_batch():
160
+ script = Script()
161
+ async with run_host(script) as (drv, host):
162
+ drv.post("run/enqueue", add("m1"))
163
+ call = await script.next_call()
164
+ call.ack()
165
+ await drv.command("run/steer", add("s1"), terminal=False)
166
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
167
+ call.ack()
168
+ await drv.command("run/steer", add("s2", anchor="m1"), terminal=False)
169
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s2"]
170
+ await drv.wait(lambda s: dispatching_ids(s) == ["s2"])
171
+ assert drv.run["dispatching"]["trigger"] == "steer"
172
+ call.ack()
173
+ call.finish(RunManager.Complete())
@@ -79,6 +79,36 @@ async def test_completing_answer_resumes_with_ordered_responses():
79
79
  await drv.wait_status("ready")
80
80
 
81
81
 
82
+ async def test_input_outcomes_ride_the_dispatch_record_until_the_ack():
83
+ script = Script()
84
+ async with run_host(script) as (drv, host):
85
+ await park(drv, script, [tool_call("r1"), approval("r2")])
86
+ await drv.command(
87
+ "run/input", {"requestId": "r2", "response": {"decision": "approve"}}
88
+ )
89
+ await drv.command(
90
+ "run/input", {"requestId": "r1", "response": {"output": "ok"}}
91
+ )
92
+ resumed = await script.next_call()
93
+ await drv.wait(lambda s: run_of(s).get("dispatching") is not None)
94
+ assert drv.run["dispatching"] == {
95
+ "trigger": "input-resume",
96
+ "messages": [],
97
+ "inputOutcomes": [
98
+ {"request": tool_call("r1"), "response": {"output": "ok"}, "meta": None},
99
+ {
100
+ "request": approval("r2"),
101
+ "response": {"decision": "approve"},
102
+ "meta": None,
103
+ },
104
+ ],
105
+ }
106
+ resumed.ack()
107
+ await drv.wait(lambda s: run_of(s).get("dispatching") is None)
108
+ resumed.finish(RunManager.Complete())
109
+ await drv.wait_status("ready")
110
+
111
+
82
112
  async def test_completing_batch_resumes_once_in_the_same_envelope():
83
113
  script = Script()
84
114
  async with run_host(script) as (drv, host):
@@ -1,8 +1,8 @@
1
1
  """Contract: ``linear_thread`` projects a linear message list for RunManager —
2
2
  ``get_message_meta(None)`` probes the root (isLeaf iff empty), a known id gets
3
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."""
4
+ id gets ``None``; the extra ``get_message_child_id`` resolves the first
5
+ non-tool successor."""
6
6
 
7
7
  import pytest
8
8
 
@@ -69,14 +69,6 @@ async def test_unknown_id_returns_none():
69
69
  assert await thread.get_message_meta("nope") is None
70
70
 
71
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
72
  async def test_child_id_skips_tool_messages():
81
73
  thread = _thread_for(
82
74
  [
@@ -109,7 +101,6 @@ async def test_reads_the_live_list():
109
101
  assert await thread.get_message_meta(None) == {"isLeaf": True}
110
102
  messages.append({"id": "u1", "type": "human"})
111
103
  assert await thread.get_message_meta(None) == {"isLeaf": False}
112
- assert await thread.get_leaf_message_id() == "u1"
113
104
 
114
105
 
115
106
  def test_non_callable_messages_raises():
@@ -131,10 +131,14 @@ async def test_meta_dies_with_a_dequeued_entry():
131
131
  async def test_unacked_run_end_reprojects_entries_with_meta():
132
132
  script = Script()
133
133
  async with run_host(script) as (drv, _):
134
- drv.post("run/enqueue", add("m1", meta={"clientId": "c1"}))
134
+ drv.batch(
135
+ [
136
+ ("run/enqueue", add("m1", meta={"clientId": "c1"})),
137
+ ("run/steer", add("s1", meta={"clientId": "c2"})),
138
+ ]
139
+ )
135
140
  call = await script.next_call()
136
- await drv.side("run/steer", add("s1", meta={"clientId": "c2"}))
137
- call.ctx.steering.take()
141
+ assert [m["id"] for m in call.ctx.messages] == ["m1", "s1"]
138
142
  call.fail(RuntimeError("boom"))
139
143
  await drv.wait_status("error")
140
144
  assert drv.run["queue"][0]["meta"] == {"clientId": "c1"}
@@ -101,10 +101,10 @@ async def test_unacked_run_end_reverts_entries_to_the_lane_front():
101
101
  await drv.wait_status("error")
102
102
  assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
103
103
  assert drv.run["error"] == {"message": "boom"}
104
- assert drv.run["runLeafMessageId"] is None
105
104
  await drv.command("run/steer", {"messageId": "m1"}, terminal=False)
106
105
  continued = await script.next_call()
107
106
  assert continued.ctx.trigger == "error-continue"
107
+ continued.ack()
108
108
  assert [m["id"] for m in continued.ctx.steering.take()] == ["m1"]
109
109
 
110
110
 
@@ -113,12 +113,14 @@ async def test_unacked_run_end_reverts_taken_steered_items():
113
113
  async with run_host(script) as (drv, host):
114
114
  drv.post("run/enqueue", add("m1"))
115
115
  call = await script.next_call()
116
+ call.ack()
116
117
  await drv.side("run/steer", add("s1"))
118
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
117
119
  call.ctx.steering.take()
118
120
  call.fail(RuntimeError("boom"))
119
121
  await drv.wait_status("error")
120
122
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
121
- assert queue_ids(drv.replica, "queue") == ["m1"]
123
+ assert queue_ids(drv.replica, "queue") == []
122
124
 
123
125
 
124
126
  async def test_ack_removes_the_entries_for_good():
@@ -208,7 +210,6 @@ async def test_task_cancellation_reverts_entries_and_settles_sends():
208
210
  assert (await drv.res(seq))["type"] == "accepted"
209
211
  await drv.wait_status("stopped")
210
212
  assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
211
- assert drv.run["runLeafMessageId"] is None
212
213
  assert task.cancelled()
213
214
 
214
215
 
@@ -55,6 +55,7 @@ async def test_steer_add_continues_in_error_and_stop(end, continue_type):
55
55
  assert continued.ctx.messages == ()
56
56
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
57
57
  assert continued.ctx.steering.available.is_set()
58
+ continued.ack()
58
59
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
59
60
 
60
61
 
@@ -86,6 +87,7 @@ async def test_steer_edit_continues_in_error_with_replacement():
86
87
  await drv.command("run/steer", {"message": msg("m2", "edited")}, terminal=False)
87
88
  continued = await script.next_call()
88
89
  assert continued.ctx.trigger == "error-continue"
90
+ continued.ack()
89
91
  taken = continued.ctx.steering.take()
90
92
  assert [m["parts"][0]["text"] for m in taken] == ["edited"]
91
93
 
@@ -234,6 +234,7 @@ async def test_continue_with_steer_lane_needs_no_capability():
234
234
  assert res["type"] == "pending"
235
235
  continued = await script.next_call()
236
236
  assert continued.ctx.trigger == "error-continue"
237
+ continued.ack()
237
238
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
238
239
  continued.ack()
239
240
  continued.finish(RunManager.Complete())
@@ -1,162 +0,0 @@
1
- from run_helpers import Script, add, msg, run_host, run_of
2
-
3
- from harness_sdk import RunManager
4
-
5
-
6
- def thread_with_turn(script: Script) -> None:
7
- script.thread.update(
8
- {
9
- "u1": {
10
- "parentId": None,
11
- "role": "user",
12
- "isLeaf": False,
13
- "onActiveBranch": True,
14
- },
15
- "a1": {
16
- "parentId": "u1",
17
- "role": "assistant",
18
- "isLeaf": True,
19
- "onActiveBranch": True,
20
- },
21
- }
22
- )
23
-
24
-
25
- async def test_ready_state_has_no_run_entry():
26
- script = Script()
27
- thread_with_turn(script)
28
- async with run_host(script) as (drv, host):
29
- assert drv.replica["status"] == "ready"
30
- assert drv.replica["runs"] == []
31
-
32
-
33
- async def test_enqueue_dispatch_sets_the_leaf():
34
- script = Script()
35
- async with run_host(script) as (drv, host):
36
- drv.post("run/enqueue", add("m1"))
37
- await script.next_call()
38
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m1")
39
-
40
-
41
- async def test_steer_dispatch_sets_the_leaf_to_the_last_lane_item():
42
- script = Script()
43
- async with run_host(script) as (drv, host):
44
- drv.post("run/enqueue", add("m1"))
45
- call = await script.next_call()
46
- call.ack()
47
- await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
48
- await drv.command("run/steer", add("s2", anchor="s1"), terminal=False)
49
- call.finish(RunManager.Complete())
50
- steered = await script.next_call()
51
- assert steered.ctx.messages[-1]["id"] == "s2"
52
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s2")
53
-
54
-
55
- async def test_steering_take_sets_the_leaf():
56
- script = Script()
57
- async with run_host(script) as (drv, host):
58
- drv.post("run/enqueue", add("m1"))
59
- call = await script.next_call()
60
- call.ack()
61
- await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
62
- await drv.wait(lambda s: len(run_of(s).get("steerQueue", [])) == 1)
63
- call.ctx.steering.take()
64
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s1")
65
- call.ack()
66
- call.finish(RunManager.Complete())
67
-
68
-
69
- async def test_settle_pulls_the_thread_leaf():
70
- for outcome in [
71
- RunManager.Error(dispatch_queue=False),
72
- RunManager.Stop(dispatch_queue=False),
73
- ]:
74
- script = Script()
75
- async with run_host(script) as (drv, host):
76
- drv.post("run/enqueue", add("m1"))
77
- call = await script.next_call()
78
- call.ack()
79
- script.leaf = "a1"
80
- call.finish(outcome)
81
- await drv.wait_status("error", "stopped")
82
- assert drv.run["runLeafMessageId"] == "a1"
83
-
84
-
85
- async def test_input_required_settle_pulls_the_thread_leaf():
86
- script = Script()
87
- async with run_host(script) as (drv, host):
88
- drv.post("run/enqueue", add("m1"))
89
- call = await script.next_call()
90
- call.ack()
91
- script.leaf = "a1"
92
- call.finish(
93
- RunManager.InputRequired(
94
- requests=({"id": "r1", "type": "tool-call", "toolCallId": "t1"},)
95
- )
96
- )
97
- await drv.wait_status("input-required")
98
- assert drv.run["runLeafMessageId"] == "a1"
99
-
100
-
101
- async def test_unacked_end_reverts_instead_of_pulling():
102
- script = Script()
103
- async with run_host(script) as (drv, host):
104
- drv.post("run/enqueue", add("m1"))
105
- call = await script.next_call()
106
- script.leaf = "a1"
107
- call.fail(RuntimeError("boom"))
108
- await drv.wait_status("error")
109
- assert drv.run["runLeafMessageId"] is None
110
- assert [item["id"] for item in drv.run["queue"]] == ["m1"]
111
-
112
-
113
- async def test_complete_clears_the_leaf_on_ready():
114
- script = Script()
115
- async with run_host(script) as (drv, host):
116
- drv.post("run/enqueue", add("m1"))
117
- call = await script.next_call()
118
- call.ack()
119
- script.leaf = "a1"
120
- await drv.wait_status("running")
121
- call.finish(RunManager.Complete())
122
- await drv.wait_status("ready")
123
- assert drv.replica["runs"] == []
124
-
125
-
126
- async def test_complete_with_a_queued_item_keeps_the_leaf_flowing():
127
- script = Script()
128
- async with run_host(script) as (drv, host):
129
- drv.post("run/enqueue", add("m1"))
130
- call = await script.next_call()
131
- call.ack()
132
- await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
133
- script.leaf = "a1"
134
- call.finish(RunManager.Complete())
135
- drain = await script.next_call()
136
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m2")
137
- drain.ack()
138
- drain.finish(RunManager.Complete())
139
- await drv.wait_status("ready")
140
- assert drv.replica["runs"] == []
141
-
142
-
143
- async def test_edit_dispatch_sets_the_leaf_to_the_replacement():
144
- script = Script()
145
- thread_with_turn(script)
146
- async with run_host(script, capabilities=("rewind",)) as (drv, host):
147
- drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
148
- await script.next_call()
149
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u2")
150
-
151
-
152
- async def test_reload_dispatch_sets_the_leaf_to_the_rollback_target():
153
- script = Script()
154
- thread_with_turn(script)
155
- async with run_host(script, capabilities=("rewind",)) as (drv, host):
156
- drv.post("run/reload", {"sourceId": "a1"})
157
- call = await script.next_call()
158
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u1")
159
- call.ack()
160
- script.leaf = "a2"
161
- call.finish(RunManager.Stop(dispatch_queue=False))
162
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a2")