harness-sdk-python 0.10.1__tar.gz → 0.11.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.10.1 → harness_sdk_python-0.11.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/examples/runs_app.py +3 -5
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/run_manager.py +65 -25
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_ack_visibility.py +4 -4
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_branch_anchor.py +35 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_dispatching.py +5 -5
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_meta.py +0 -17
- harness_sdk_python-0.11.0/tests/test_restore.py +136 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_settle.py +23 -27
- harness_sdk_python-0.10.1/tests/test_facade.py +0 -100
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/.gitignore +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/README.md +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_stop_continue.py +0 -0
|
@@ -6,7 +6,7 @@ Run from python/harness-sdk-python:
|
|
|
6
6
|
|
|
7
7
|
State is ``{"messages": [...]}`` in langchain format; rewind truncates the
|
|
8
8
|
list. Threads live at /threads/{id}: ``GET .../stream`` (SSE), ``.../ws``,
|
|
9
|
-
``POST .../commands
|
|
9
|
+
``POST .../commands``.
|
|
10
10
|
|
|
11
11
|
Set PINBOARD_URL, PINBOARD_TOKEN, and ADVERTISE_URL to register with a
|
|
12
12
|
pinboard control plane instead of local placement.
|
|
@@ -20,7 +20,7 @@ from typing import Any
|
|
|
20
20
|
from fastapi import FastAPI
|
|
21
21
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
22
22
|
from pinned import PinnedHost
|
|
23
|
-
from statewire import
|
|
23
|
+
from statewire import Statewire, command, plain
|
|
24
24
|
from statewire.langgraph import append_langgraph_event
|
|
25
25
|
|
|
26
26
|
from harness_sdk import RunManager, linear_thread
|
|
@@ -72,9 +72,7 @@ def user_text(message: dict[str, Any]) -> str:
|
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
class RunsHost(
|
|
76
|
-
assistant_transport_protocol = "assistant-transport"
|
|
77
|
-
|
|
75
|
+
class RunsHost(Statewire):
|
|
78
76
|
async def lifespan(self):
|
|
79
77
|
self.agent = build_agent()
|
|
80
78
|
self.state = {"messages": []}
|
|
@@ -15,10 +15,9 @@ in-flight run has ended.
|
|
|
15
15
|
|
|
16
16
|
``entry["dispatching"]`` is the dispatch record (or None): the payload the
|
|
17
17
|
run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
|
|
18
|
-
refill it — at most one unacked batch ever exists.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
the front of the queue, rewind replacements dropped.
|
|
18
|
+
refill it — at most one unacked batch ever exists. Settle consumes the
|
|
19
|
+
delivered batch, acked or not; steer entries still undelivered in the lane
|
|
20
|
+
stay queued for the next run.
|
|
22
21
|
"""
|
|
23
22
|
|
|
24
23
|
import asyncio
|
|
@@ -216,18 +215,60 @@ class RunManager:
|
|
|
216
215
|
return self._idle
|
|
217
216
|
|
|
218
217
|
def _init_state(self) -> None:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
218
|
+
runs = plain(self._state.get("runs"))
|
|
219
|
+
if runs is None or runs == []:
|
|
220
|
+
self._state["status"] = "ready"
|
|
221
|
+
self._state["runs"] = []
|
|
222
|
+
for key in (
|
|
223
|
+
"error",
|
|
224
|
+
"queue",
|
|
225
|
+
"steerQueue",
|
|
226
|
+
"runId",
|
|
227
|
+
"dispatch",
|
|
228
|
+
"inputRequests",
|
|
229
|
+
):
|
|
230
|
+
self._state.pop(key, None)
|
|
231
|
+
return
|
|
232
|
+
self._hydrate(runs)
|
|
233
|
+
|
|
234
|
+
def _hydrate(self, runs: Any) -> None:
|
|
235
|
+
if not isinstance(runs, list) or len(runs) != 1:
|
|
236
|
+
raise ValueError("state['runs'] must hold exactly one entry to restore")
|
|
237
|
+
entry = runs[0]
|
|
238
|
+
if not isinstance(entry, dict):
|
|
239
|
+
raise ValueError("the restored run entry must be an object")
|
|
240
|
+
if entry.get("status") != "input-required":
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"only an input-required entry restores, "
|
|
243
|
+
f"got status {entry.get('status')!r}"
|
|
244
|
+
)
|
|
245
|
+
run_id = entry.get("runId")
|
|
246
|
+
if not isinstance(run_id, str) or run_id == "":
|
|
247
|
+
raise ValueError("the restored runId must be a non-empty string")
|
|
248
|
+
for lane in ("queue", "steerQueue"):
|
|
249
|
+
if not isinstance(entry.get(lane), list):
|
|
250
|
+
raise ValueError(f"the restored {lane} must be a list")
|
|
251
|
+
for key in ("dispatching", "error"):
|
|
252
|
+
if entry.get(key, _ABSENT) is not None:
|
|
253
|
+
raise ValueError(f"the restored entry must carry {key}: None")
|
|
254
|
+
requests = entry.get("inputRequests")
|
|
255
|
+
if not isinstance(requests, list) or not requests:
|
|
256
|
+
raise ValueError("the restored inputRequests must be a non-empty list")
|
|
257
|
+
for request in requests:
|
|
258
|
+
if not isinstance(request, dict):
|
|
259
|
+
raise ValueError("restored input requests must be objects")
|
|
260
|
+
if request.get("response") is not None:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"restored input requests must carry response: None"
|
|
263
|
+
)
|
|
264
|
+
outcome = RunManager.InputRequired(
|
|
265
|
+
tuple(
|
|
266
|
+
{k: v for k, v in request.items() if k != "response"}
|
|
267
|
+
for request in requests
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
self._input_requests = [dict(r) for r in outcome.requests]
|
|
271
|
+
self._state["status"] = "input-required"
|
|
231
272
|
|
|
232
273
|
# ─── State access ───────────────────────────────────────
|
|
233
274
|
|
|
@@ -308,7 +349,7 @@ class RunManager:
|
|
|
308
349
|
rewind = self._staged_rewinds.pop(0)
|
|
309
350
|
self._clear_input()
|
|
310
351
|
if self._dispatching:
|
|
311
|
-
self.
|
|
352
|
+
self._unstage_dispatching()
|
|
312
353
|
entry = self._ensure_entry()
|
|
313
354
|
if entry["runId"] is None:
|
|
314
355
|
entry["runId"] = rewind.run_id
|
|
@@ -492,7 +533,6 @@ class RunManager:
|
|
|
492
533
|
except asyncio.CancelledError:
|
|
493
534
|
self._settle(ctx)
|
|
494
535
|
self._set_status("stopped")
|
|
495
|
-
self._revert_dispatching()
|
|
496
536
|
self._idle.set()
|
|
497
537
|
raise # no drain, no freeze
|
|
498
538
|
except Exception as exc:
|
|
@@ -502,11 +542,9 @@ class RunManager:
|
|
|
502
542
|
self._freeze(exc.message, exc.payload)
|
|
503
543
|
else:
|
|
504
544
|
self._freeze(message)
|
|
505
|
-
self._revert_dispatching()
|
|
506
545
|
self._drain()
|
|
507
546
|
return
|
|
508
547
|
self._settle(ctx)
|
|
509
|
-
self._revert_dispatching()
|
|
510
548
|
self._outcome = outcome
|
|
511
549
|
self._drain()
|
|
512
550
|
|
|
@@ -515,6 +553,12 @@ class RunManager:
|
|
|
515
553
|
self._ctx = None
|
|
516
554
|
self._task = None
|
|
517
555
|
self._dispatch_record = None
|
|
556
|
+
self._dispatching = []
|
|
557
|
+
entry = self._ensure_entry()
|
|
558
|
+
record = plain(entry["dispatching"])
|
|
559
|
+
if record is not None and record["trigger"] != "steer" and record["messages"]:
|
|
560
|
+
self._dispatched_ids = ()
|
|
561
|
+
entry["dispatching"] = None
|
|
518
562
|
|
|
519
563
|
def _ack(self) -> None:
|
|
520
564
|
entry = self._entry()
|
|
@@ -524,18 +568,14 @@ class RunManager:
|
|
|
524
568
|
self._dispatching = []
|
|
525
569
|
entry["dispatching"] = None
|
|
526
570
|
|
|
527
|
-
def
|
|
571
|
+
def _unstage_dispatching(self) -> None:
|
|
528
572
|
taken, self._dispatching = self._dispatching, []
|
|
529
573
|
entry = self._ensure_entry()
|
|
530
574
|
entry["dispatching"] = None
|
|
531
|
-
if not taken:
|
|
532
|
-
return
|
|
533
575
|
for lane in ("steerQueue", "queue"):
|
|
534
576
|
front = [item for taken_lane, item in taken if taken_lane == lane]
|
|
535
577
|
if front:
|
|
536
578
|
entry[lane] = front + self._lane_items(lane)
|
|
537
|
-
ids = {item["id"] for _, item in taken}
|
|
538
|
-
self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
|
|
539
579
|
|
|
540
580
|
def _dispatchable(self, lane: str) -> bool:
|
|
541
581
|
if self._task is not None or self._staged_rewinds:
|
|
@@ -60,7 +60,7 @@ async def test_parked_send_acks_with_its_queue_insert():
|
|
|
60
60
|
call.finish(RunManager.Complete())
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
async def
|
|
63
|
+
async def test_unacked_run_end_consumes_the_direct_dispatch():
|
|
64
64
|
script = Script()
|
|
65
65
|
async with run_host(script) as (drv, host):
|
|
66
66
|
seq = drv.post("run/enqueue", add("m1"))
|
|
@@ -69,10 +69,10 @@ async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
|
|
|
69
69
|
assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
|
|
70
70
|
call.fail(RuntimeError("boom"))
|
|
71
71
|
await drv.wait_status("error")
|
|
72
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
72
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
async def
|
|
75
|
+
async def test_unacked_run_end_consumes_the_direct_steer_send():
|
|
76
76
|
script = Script()
|
|
77
77
|
async with run_host(script) as (drv, host):
|
|
78
78
|
seq = drv.post("run/steer", add("s1"))
|
|
@@ -81,7 +81,7 @@ async def test_unacked_run_end_reverts_the_direct_steer_send_to_the_steer_lane()
|
|
|
81
81
|
assert dispatching_ids(covered_state(drv, seq)) == ["s1"]
|
|
82
82
|
call.fail(RuntimeError("boom"))
|
|
83
83
|
await drv.wait_status("error")
|
|
84
|
-
assert queue_ids(drv.replica, "steerQueue") == [
|
|
84
|
+
assert queue_ids(drv.replica, "steerQueue") == []
|
|
85
85
|
assert queue_ids(drv.replica, "queue") == []
|
|
86
86
|
|
|
87
87
|
|
|
@@ -128,6 +128,41 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
|
|
|
128
128
|
assert res["type"] == "pending"
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
async def test_unacked_edit_crash_invalidates_the_replacement_anchor():
|
|
132
|
+
script = Script()
|
|
133
|
+
script.thread["u1"] = {
|
|
134
|
+
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
|
|
135
|
+
}
|
|
136
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
137
|
+
drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
138
|
+
(await script.next_call()).fail(RuntimeError("boom"))
|
|
139
|
+
await drv.wait_status("error")
|
|
140
|
+
assert_rejected(
|
|
141
|
+
await drv.command("run/enqueue", add("q1", anchor="u2"), terminal=False),
|
|
142
|
+
"unknown-id",
|
|
143
|
+
)
|
|
144
|
+
drv.post("run/enqueue", add("q1", anchor="u1"))
|
|
145
|
+
call = await script.next_call()
|
|
146
|
+
assert [m["id"] for m in call.ctx.messages] == ["q1"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_unacked_recovery_run_keeps_acked_dispatch_anchors():
|
|
150
|
+
script = Script()
|
|
151
|
+
async with run_host(script, capabilities=("incomplete-continuation",)) as (drv, host):
|
|
152
|
+
drv.post("run/enqueue", add("m1"))
|
|
153
|
+
call = await script.next_call()
|
|
154
|
+
call.ack()
|
|
155
|
+
call.fail(RuntimeError("boom"))
|
|
156
|
+
await drv.wait_status("error")
|
|
157
|
+
await drv.command("run/continue", terminal=False)
|
|
158
|
+
continued = await script.next_call()
|
|
159
|
+
assert continued.ctx.trigger == "error-continue"
|
|
160
|
+
continued.fail(RuntimeError("boom"))
|
|
161
|
+
await drv.wait_status("error")
|
|
162
|
+
res = await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
|
|
163
|
+
assert res["type"] == "pending"
|
|
164
|
+
|
|
165
|
+
|
|
131
166
|
async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
|
|
132
167
|
script = Script()
|
|
133
168
|
async with run_host(script) as (drv, host):
|
|
@@ -3,8 +3,8 @@ is the current unacked batch — a record ``{trigger, messages, rollbackTo?,
|
|
|
3
3
|
inputOutcomes?}`` or None. A message item lives in exactly one of the lanes,
|
|
4
4
|
the record's ``messages``, or the thread, and every transition is
|
|
5
5
|
single-envelope atomic: dispatch fills the record, ``ctx.ack()`` clears it as
|
|
6
|
-
the canonical message appears, a steer take refills it, and
|
|
7
|
-
|
|
6
|
+
the canonical message appears, a steer take refills it, and settle consumes
|
|
7
|
+
whatever is still unacked."""
|
|
8
8
|
|
|
9
9
|
import copy
|
|
10
10
|
|
|
@@ -78,7 +78,7 @@ async def test_ack_clears_dispatching_with_the_message_append():
|
|
|
78
78
|
call.finish(RunManager.Complete())
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
async def
|
|
81
|
+
async def test_unacked_run_end_consumes_the_delivered_item():
|
|
82
82
|
script = Script()
|
|
83
83
|
async with run_host(script) as (drv, host):
|
|
84
84
|
drv.post("run/enqueue", add("m1"))
|
|
@@ -88,9 +88,9 @@ async def test_unacked_run_end_returns_the_item_to_the_lane_front():
|
|
|
88
88
|
await drv.side("run/stop", {"runId": await drv.run_id()})
|
|
89
89
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
90
90
|
await drv.wait_status("stopped")
|
|
91
|
-
assert queue_ids(drv.replica, "queue") == ["
|
|
91
|
+
assert queue_ids(drv.replica, "queue") == ["m2"]
|
|
92
92
|
assert dispatching_ids(drv.replica) == []
|
|
93
|
-
assert residence(drv, "
|
|
93
|
+
assert residence(drv, "m2") == ["queue"]
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
async def test_steering_take_moves_the_lane_into_dispatching():
|
|
@@ -128,23 +128,6 @@ async def test_meta_dies_with_a_dequeued_entry():
|
|
|
128
128
|
dispatched.finish(RunManager.Complete())
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
async def test_unacked_run_end_reprojects_entries_with_meta():
|
|
132
|
-
script = Script()
|
|
133
|
-
async with run_host(script) as (drv, _):
|
|
134
|
-
drv.batch(
|
|
135
|
-
[
|
|
136
|
-
("run/enqueue", add("m1", meta={"clientId": "c1"})),
|
|
137
|
-
("run/steer", add("s1", meta={"clientId": "c2"})),
|
|
138
|
-
]
|
|
139
|
-
)
|
|
140
|
-
call = await script.next_call()
|
|
141
|
-
assert [m["id"] for m in call.ctx.messages] == ["m1", "s1"]
|
|
142
|
-
call.fail(RuntimeError("boom"))
|
|
143
|
-
await drv.wait_status("error")
|
|
144
|
-
assert drv.run["queue"][0]["meta"] == {"clientId": "c1"}
|
|
145
|
-
assert drv.run["steerQueue"][0]["meta"] == {"clientId": "c2"}
|
|
146
|
-
|
|
147
|
-
|
|
148
131
|
async def test_edit_meta_rides_the_replacement_message():
|
|
149
132
|
script = Script()
|
|
150
133
|
thread_with_turn(script)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Contract: restore across eviction. Construction hydrates a pre-existing
|
|
2
|
+
input-required run entry from state — the host writes the parked entry before
|
|
3
|
+
constructing RunManager — and the resume paths (answer all, steer) work as if
|
|
4
|
+
the park never left memory. Empty runs state seeds ready; any other shape
|
|
5
|
+
raises."""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from run_helpers import Script, add, msg, run_host
|
|
11
|
+
|
|
12
|
+
from harness_sdk import RunManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tool_call(id: str, tool_call_id: str = "tc1") -> dict:
|
|
16
|
+
return {"type": "tool-call", "id": id, "toolCallId": tool_call_id}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parked_entry(**overrides) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"runId": "run-1",
|
|
22
|
+
"status": "input-required",
|
|
23
|
+
"queue": [],
|
|
24
|
+
"steerQueue": [],
|
|
25
|
+
"dispatching": None,
|
|
26
|
+
"error": None,
|
|
27
|
+
"inputRequests": [{**tool_call("r1"), "response": None}],
|
|
28
|
+
**overrides,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def construct(state: dict) -> RunManager:
|
|
33
|
+
script = Script()
|
|
34
|
+
return RunManager(
|
|
35
|
+
state=state,
|
|
36
|
+
run=script.run,
|
|
37
|
+
thread=script,
|
|
38
|
+
create_task=asyncio.ensure_future,
|
|
39
|
+
schedule=lambda fn: fn(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_empty_runs_state_seeds_ready():
|
|
44
|
+
script = Script()
|
|
45
|
+
stale = {"status": "input-required", "runs": []}
|
|
46
|
+
async with run_host(script, initial_runs=stale) as (drv, host):
|
|
47
|
+
assert drv.replica == {"status": "ready", "runs": []}
|
|
48
|
+
script.no_call()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_hydrated_entry_rides_the_attach_snapshot():
|
|
52
|
+
script = Script()
|
|
53
|
+
async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
|
|
54
|
+
drv,
|
|
55
|
+
host,
|
|
56
|
+
):
|
|
57
|
+
assert drv.replica["status"] == "input-required"
|
|
58
|
+
assert drv.run == parked_entry()
|
|
59
|
+
script.no_call()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_answering_all_hydrated_requests_resumes():
|
|
63
|
+
script = Script()
|
|
64
|
+
entry = parked_entry(queue=[msg("m2")])
|
|
65
|
+
async with run_host(script, initial_runs={"runs": [entry]}) as (drv, host):
|
|
66
|
+
res = await drv.command(
|
|
67
|
+
"run/input", {"requestId": "r1", "response": {"output": "ok"}}
|
|
68
|
+
)
|
|
69
|
+
assert res["type"] == "accepted"
|
|
70
|
+
resumed = await script.next_call()
|
|
71
|
+
assert resumed.ctx.trigger == "input-resume"
|
|
72
|
+
assert resumed.ctx.messages == ()
|
|
73
|
+
assert resumed.ctx.input_outcomes == (
|
|
74
|
+
(tool_call("r1"), {"output": "ok"}, None),
|
|
75
|
+
)
|
|
76
|
+
assert drv.run["runId"] == "run-1"
|
|
77
|
+
resumed.ack()
|
|
78
|
+
resumed.finish(RunManager.Complete())
|
|
79
|
+
drain = await script.next_call()
|
|
80
|
+
assert [m["id"] for m in drain.ctx.messages] == ["m2"]
|
|
81
|
+
drain.ack()
|
|
82
|
+
drain.finish(RunManager.Complete())
|
|
83
|
+
await drv.wait_status("ready")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def test_steer_while_hydrated_abandons_input_and_dispatches():
|
|
87
|
+
script = Script()
|
|
88
|
+
async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
|
|
89
|
+
drv,
|
|
90
|
+
host,
|
|
91
|
+
):
|
|
92
|
+
drv.post("run/steer", add("s1"))
|
|
93
|
+
run = await script.next_call()
|
|
94
|
+
assert run.ctx.trigger == "message-send"
|
|
95
|
+
assert [m["id"] for m in run.ctx.messages] == ["s1"]
|
|
96
|
+
assert run.ctx.input_outcomes == ((tool_call("r1"), None, None),)
|
|
97
|
+
await drv.wait_status("running")
|
|
98
|
+
assert "inputRequests" not in drv.run
|
|
99
|
+
run.ack()
|
|
100
|
+
run.finish(RunManager.Complete())
|
|
101
|
+
await drv.wait_status("ready")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_malformed_restore_states_raise():
|
|
105
|
+
for state, match in [
|
|
106
|
+
({"runs": "nope"}, "exactly one entry"),
|
|
107
|
+
({"runs": [parked_entry(), parked_entry()]}, "exactly one entry"),
|
|
108
|
+
({"runs": ["nope"]}, "must be an object"),
|
|
109
|
+
({"runs": [parked_entry(status="running")]}, "input-required"),
|
|
110
|
+
({"runs": [parked_entry(runId="")]}, "runId"),
|
|
111
|
+
({"runs": [parked_entry(runId=None)]}, "runId"),
|
|
112
|
+
({"runs": [parked_entry(queue=None)]}, "queue"),
|
|
113
|
+
({"runs": [parked_entry(steerQueue=None)]}, "steerQueue"),
|
|
114
|
+
({"runs": [parked_entry(dispatching={"trigger": "steer"})]}, "dispatching"),
|
|
115
|
+
({"runs": [parked_entry(error={"message": "x"})]}, "error"),
|
|
116
|
+
({"runs": [parked_entry(inputRequests=[])]}, "non-empty list"),
|
|
117
|
+
({"runs": [parked_entry(inputRequests=["nope"])]}, "objects"),
|
|
118
|
+
(
|
|
119
|
+
{
|
|
120
|
+
"runs": [
|
|
121
|
+
parked_entry(
|
|
122
|
+
inputRequests=[
|
|
123
|
+
{**tool_call("r1"), "response": {"output": "x"}}
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
"response",
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
{"runs": [parked_entry(inputRequests=[{"type": "tool-call"}])]},
|
|
132
|
+
"non-empty string",
|
|
133
|
+
),
|
|
134
|
+
]:
|
|
135
|
+
with pytest.raises(ValueError, match=match):
|
|
136
|
+
construct(state)
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
"""Contract: command settlement. Every initiator settles accepted with its
|
|
2
2
|
state-visible application — sends with their insert (a lane entry or a direct
|
|
3
3
|
``dispatching`` entry), edit/reload/continue at their dispatch. The run's
|
|
4
|
-
outcome never travels through settlement:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
``state.error``."""
|
|
4
|
+
outcome never travels through settlement: settle consumes every delivered
|
|
5
|
+
item, acked or not; steer entries still undelivered in the lane stay queued,
|
|
6
|
+
and any failure lands on ``state.error``."""
|
|
8
7
|
|
|
9
8
|
import asyncio
|
|
10
9
|
|
|
11
|
-
from run_helpers import Script, add, edit_params, msg, queue_ids, run_host
|
|
10
|
+
from run_helpers import Script, add, edit_params, msg, queue_ids, run_host
|
|
12
11
|
|
|
13
12
|
from statewire import StatewireReject
|
|
14
13
|
|
|
@@ -51,7 +50,7 @@ async def test_run_reject_lands_the_payload_on_state_error():
|
|
|
51
50
|
)
|
|
52
51
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
53
52
|
await drv.wait_status("error")
|
|
54
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
53
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
55
54
|
assert drv.run["error"] == {
|
|
56
55
|
"message": "payment required",
|
|
57
56
|
"reason": "payment-required",
|
|
@@ -62,16 +61,15 @@ async def test_run_reject_lands_the_payload_on_state_error():
|
|
|
62
61
|
async def test_unacked_run_end_settles_the_send_accepted():
|
|
63
62
|
script = Script()
|
|
64
63
|
async with run_host(script) as (drv, host):
|
|
65
|
-
for outcome in [
|
|
66
|
-
lambda call: call.fail(RuntimeError("boom")),
|
|
67
|
-
lambda call: call.finish(RunManager.Error(dispatch_queue=False)),
|
|
64
|
+
for name, outcome in [
|
|
65
|
+
("m1", lambda call: call.fail(RuntimeError("boom"))),
|
|
66
|
+
("m2", lambda call: call.finish(RunManager.Error(dispatch_queue=False))),
|
|
68
67
|
]:
|
|
69
|
-
seq = drv.post("run/enqueue", add(
|
|
68
|
+
seq = drv.post("run/enqueue", add(name))
|
|
70
69
|
outcome(await script.next_call())
|
|
71
70
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
72
71
|
await drv.wait_status("error")
|
|
73
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
74
|
-
await drv.command("run/dequeue", {"messageId": "m1"})
|
|
72
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
75
73
|
|
|
76
74
|
|
|
77
75
|
async def test_rewind_settles_before_the_reruns_end():
|
|
@@ -90,23 +88,23 @@ async def test_rewind_settles_before_the_reruns_end():
|
|
|
90
88
|
await drv.wait_status("stopped")
|
|
91
89
|
|
|
92
90
|
|
|
93
|
-
async def
|
|
91
|
+
async def test_unacked_run_end_consumes_the_delivered_batch():
|
|
94
92
|
script = Script()
|
|
95
93
|
async with run_host(script) as (drv, host):
|
|
96
94
|
drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
|
|
97
95
|
call = await script.next_call()
|
|
98
96
|
call.fail(RuntimeError("boom"))
|
|
99
97
|
await drv.wait_status("error")
|
|
100
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
98
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
101
99
|
assert drv.run["error"] == {"message": "boom"}
|
|
102
|
-
await drv.command("run/steer",
|
|
100
|
+
await drv.command("run/steer", add("s1"), terminal=False)
|
|
103
101
|
continued = await script.next_call()
|
|
104
102
|
assert continued.ctx.trigger == "error-continue"
|
|
105
103
|
continued.ack()
|
|
106
|
-
assert [m["id"] for m in continued.ctx.steering.take()] == ["
|
|
104
|
+
assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
|
|
107
105
|
|
|
108
106
|
|
|
109
|
-
async def
|
|
107
|
+
async def test_unacked_run_end_consumes_taken_steered_items():
|
|
110
108
|
script = Script()
|
|
111
109
|
async with run_host(script) as (drv, host):
|
|
112
110
|
drv.post("run/enqueue", add("m1"))
|
|
@@ -117,7 +115,7 @@ async def test_unacked_run_end_reverts_taken_steered_items():
|
|
|
117
115
|
call.ctx.steering.take()
|
|
118
116
|
call.fail(RuntimeError("boom"))
|
|
119
117
|
await drv.wait_status("error")
|
|
120
|
-
assert queue_ids(drv.replica, "steerQueue") == [
|
|
118
|
+
assert queue_ids(drv.replica, "steerQueue") == []
|
|
121
119
|
assert queue_ids(drv.replica, "queue") == []
|
|
122
120
|
|
|
123
121
|
|
|
@@ -132,17 +130,15 @@ async def test_ack_removes_the_entries_for_good():
|
|
|
132
130
|
assert queue_ids(drv.replica, "queue") == []
|
|
133
131
|
|
|
134
132
|
|
|
135
|
-
async def
|
|
133
|
+
async def test_complete_without_ack_consumes_the_dispatch():
|
|
136
134
|
script = Script()
|
|
137
135
|
async with run_host(script) as (drv, host):
|
|
138
136
|
seq = drv.post("run/enqueue", add("m1"))
|
|
139
137
|
(await script.next_call()).finish(RunManager.Complete())
|
|
140
|
-
redo = await script.next_call()
|
|
141
|
-
assert [m["id"] for m in redo.ctx.messages] == ["m1"]
|
|
142
138
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
143
|
-
redo.ack()
|
|
144
|
-
redo.finish(RunManager.Complete())
|
|
145
139
|
await drv.wait_status("ready")
|
|
140
|
+
assert drv.replica["runs"] == []
|
|
141
|
+
script.no_call()
|
|
146
142
|
|
|
147
143
|
|
|
148
144
|
async def test_dequeue_removes_a_parked_entry():
|
|
@@ -169,9 +165,9 @@ async def test_stop_lands_while_a_steer_is_parked():
|
|
|
169
165
|
await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
|
|
170
166
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
171
167
|
await drv.wait_status("stopped")
|
|
172
|
-
# The
|
|
168
|
+
# The undelivered steer stays parked; the delivered dispatch is consumed.
|
|
173
169
|
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
174
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
170
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
175
171
|
|
|
176
172
|
|
|
177
173
|
async def test_rewind_edit_settles_at_its_dispatch():
|
|
@@ -199,7 +195,7 @@ async def test_rewind_rerun_failure_lands_on_state_error():
|
|
|
199
195
|
assert drv.run["error"] == {"message": "payment required", "code": 402}
|
|
200
196
|
|
|
201
197
|
|
|
202
|
-
async def
|
|
198
|
+
async def test_task_cancellation_consumes_entries_and_settles_sends():
|
|
203
199
|
script = Script()
|
|
204
200
|
async with run_host(script) as (drv, host):
|
|
205
201
|
seq = drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
|
|
@@ -208,7 +204,7 @@ async def test_task_cancellation_reverts_entries_and_settles_sends():
|
|
|
208
204
|
task.cancel()
|
|
209
205
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
210
206
|
await drv.wait_status("stopped")
|
|
211
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
207
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
212
208
|
assert task.cancelled()
|
|
213
209
|
|
|
214
210
|
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""Contract: run lifetime composed with the assistant-transport chat
|
|
2
|
-
endpoint. The ``/chat`` hold releases once the send settles — the opening ``set`` carries the sent
|
|
3
|
-
message as the ``dispatching`` record — then the response stays open through
|
|
4
|
-
the run and EOFs once the run task ends; a run rejection is a single error
|
|
5
|
-
frame then EOF.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import asyncio
|
|
9
|
-
|
|
10
|
-
from run_helpers import Script
|
|
11
|
-
from statewire_helpers import statewire_client
|
|
12
|
-
from test_assistant_transport import _PostStream, data_stream_frames, state_ops
|
|
13
|
-
|
|
14
|
-
from statewire import AssistantTransport, StatewireReject, command
|
|
15
|
-
|
|
16
|
-
from harness_sdk import RunManager
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def make_facade_host(script: Script):
|
|
20
|
-
class Host(AssistantTransport):
|
|
21
|
-
async def lifespan(self):
|
|
22
|
-
self.state = {}
|
|
23
|
-
self.runs = RunManager(
|
|
24
|
-
state=self.state,
|
|
25
|
-
run=script.run,
|
|
26
|
-
thread=script,
|
|
27
|
-
create_task=self.create_task,
|
|
28
|
-
schedule=self.schedule,
|
|
29
|
-
)
|
|
30
|
-
yield
|
|
31
|
-
|
|
32
|
-
@command("run/steer")
|
|
33
|
-
async def run_steer(self, params, *, ctx):
|
|
34
|
-
return await self.runs.steer(params, ack=ctx.ack)
|
|
35
|
-
|
|
36
|
-
@command("run/stop")
|
|
37
|
-
async def run_stop(self, params=None, *, ctx):
|
|
38
|
-
return await self.runs.stop(params, ack=ctx.ack)
|
|
39
|
-
|
|
40
|
-
return Host
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def chat_body():
|
|
44
|
-
return {
|
|
45
|
-
"commands": [
|
|
46
|
-
{
|
|
47
|
-
"type": "run/steer",
|
|
48
|
-
"runId": "run-1",
|
|
49
|
-
"runAnchorMessageId": None,
|
|
50
|
-
"message": {
|
|
51
|
-
"id": "m1",
|
|
52
|
-
"role": "user",
|
|
53
|
-
"parts": [{"type": "text", "text": "hi"}],
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
],
|
|
57
|
-
"state": {},
|
|
58
|
-
"threadId": "t1",
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
async def test_chat_holds_until_the_entity_run_settles_then_eofs():
|
|
63
|
-
script = Script()
|
|
64
|
-
async with statewire_client(make_facade_host(script)) as (app, client):
|
|
65
|
-
async with _PostStream(
|
|
66
|
-
app, "/threads/t1/assistant-transport/api/chat", chat_body()
|
|
67
|
-
) as stream:
|
|
68
|
-
assert stream.status == 200
|
|
69
|
-
call = await script.next_call()
|
|
70
|
-
assert call.ctx.trigger == "message-send"
|
|
71
|
-
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
72
|
-
instance.state["messages"] = [dict(call.ctx.messages[0])]
|
|
73
|
-
call.ack()
|
|
74
|
-
await asyncio.sleep(0.05)
|
|
75
|
-
assert stream._task is not None and not stream._task.done()
|
|
76
|
-
call.finish(RunManager.Complete())
|
|
77
|
-
tail = await stream.until_closed()
|
|
78
|
-
frames = data_stream_frames(tail.decode())
|
|
79
|
-
assert frames and all(prefix == "aui-state" for prefix, _ in frames)
|
|
80
|
-
ops = state_ops(frames)
|
|
81
|
-
assert ops[0]["type"] == "set" and ops[0]["path"] == []
|
|
82
|
-
record = ops[0]["value"]["runs"][0]["dispatching"]
|
|
83
|
-
assert [m["id"] for m in record["messages"]] == [call.ctx.messages[0]["id"]]
|
|
84
|
-
assert any(
|
|
85
|
-
op["type"] == "set" and op["path"] == ["messages"] for op in ops[1:]
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
async def test_chat_run_reject_is_one_error_frame_then_eof():
|
|
90
|
-
script = Script()
|
|
91
|
-
async with statewire_client(make_facade_host(script)) as (app, client):
|
|
92
|
-
async with _PostStream(
|
|
93
|
-
app, "/threads/t1/assistant-transport/api/chat", chat_body()
|
|
94
|
-
) as stream:
|
|
95
|
-
call = await script.next_call()
|
|
96
|
-
call.fail(StatewireReject("payment required", payload={"code": 402}))
|
|
97
|
-
tail = await stream.until_closed()
|
|
98
|
-
frames = data_stream_frames(tail.decode())
|
|
99
|
-
errors = [frame for frame in frames if frame[0] == "3"]
|
|
100
|
-
assert errors == [("3", "payment required")]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|