harness-sdk-python 0.39.0__tar.gz → 0.39.2__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 (72) hide show
  1. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/PKG-INFO +6 -3
  2. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/README.md +4 -1
  3. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/examples/runs_app.py +8 -2
  4. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/pyproject.toml +2 -2
  5. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/deepagents_host.py +2 -2
  6. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/protocol.py +1 -1
  7. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/run_manager.py +24 -3
  8. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/run_helpers.py +37 -4
  9. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_branch_anchor.py +91 -8
  10. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_deepagents_thread.py +27 -0
  11. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_threads.py +3 -3
  12. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/voice_helpers.py +2 -2
  13. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/.gitignore +0 -0
  14. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/examples/__init__.py +0 -0
  15. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/__init__.py +0 -0
  16. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/checkpoints.py +0 -0
  17. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/deepagents.py +0 -0
  18. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/fenced_postgres.py +0 -0
  19. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/interest.py +0 -0
  20. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/langgraph.py +0 -0
  21. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/linear_thread.py +0 -0
  22. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/messages.py +0 -0
  23. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/parts.py +0 -0
  24. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/providers/__init__.py +0 -0
  25. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/providers/gpt_live.py +0 -0
  26. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/threads.py +0 -0
  27. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/voice.py +0 -0
  28. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/voice_thread.py +0 -0
  29. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/src/harness_sdk/wire.py +0 -0
  30. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_admit_steer.py +0 -0
  31. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_applied_visibility.py +0 -0
  32. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_batches.py +0 -0
  33. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_checkpoints.py +0 -0
  34. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_deepagents_approval.py +0 -0
  35. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_deepagents_files.py +0 -0
  36. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_deepagents_outcome.py +0 -0
  37. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_deepagents_voice.py +0 -0
  38. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_dispatching.py +0 -0
  39. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_durability.py +0 -0
  40. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_edit_dispatched.py +0 -0
  41. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_edit_reload.py +0 -0
  42. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_enqueue.py +0 -0
  43. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_fenced_postgres.py +0 -0
  44. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_gpt_live.py +0 -0
  45. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_imports.py +0 -0
  46. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_input_required.py +0 -0
  47. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_interest.py +0 -0
  48. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_langgraph.py +0 -0
  49. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_linear_thread.py +0 -0
  50. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_meta.py +0 -0
  51. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_mint_concurrency.py +0 -0
  52. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_outcomes.py +0 -0
  53. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_parts.py +0 -0
  54. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_placement.py +0 -0
  55. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_prepare_hooks.py +0 -0
  56. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_restore.py +0 -0
  57. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_rewind_during_run.py +0 -0
  58. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_admission.py +0 -0
  59. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_crash_sweep.py +0 -0
  60. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_eviction.py +0 -0
  61. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_id_bounds.py +0 -0
  62. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_ownership.py +0 -0
  63. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_run_sync.py +0 -0
  64. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_settle.py +0 -0
  65. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_steer.py +0 -0
  66. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_stop_continue.py +0 -0
  67. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_stopping_window.py +0 -0
  68. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_undo.py +0 -0
  69. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_voice.py +0 -0
  70. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_voice_delegation.py +0 -0
  71. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_voice_events.py +0 -0
  72. {harness_sdk_python-0.39.0 → harness_sdk_python-0.39.2}/tests/test_wire.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.39.0
3
+ Version: 0.39.2
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.12
8
8
  Requires-Dist: pinned<0.17,>=0.16.0
9
- Requires-Dist: statewire<0.23,>=0.21.1
9
+ Requires-Dist: statewire<0.24,>=0.21.2
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -57,7 +57,10 @@ class RunsHost(Statewire):
57
57
  @command("run/enqueue")
58
58
  async def run_enqueue(self, params, *, ctx, auth_result):
59
59
  return await self.run.enqueue(
60
- params, applied=ctx.applied, auth_result=auth_result
60
+ params,
61
+ applied=ctx.applied,
62
+ auth_result=auth_result,
63
+ version=ctx.version,
61
64
  )
62
65
  ```
63
66
 
@@ -35,7 +35,10 @@ class RunsHost(Statewire):
35
35
  @command("run/enqueue")
36
36
  async def run_enqueue(self, params, *, ctx, auth_result):
37
37
  return await self.run.enqueue(
38
- params, applied=ctx.applied, auth_result=auth_result
38
+ params,
39
+ applied=ctx.applied,
40
+ auth_result=auth_result,
41
+ version=ctx.version,
39
42
  )
40
43
  ```
41
44
 
@@ -158,13 +158,19 @@ class RunsHost(Statewire):
158
158
  @command("run/enqueue")
159
159
  async def run_enqueue(self, params, *, ctx, auth_result):
160
160
  return await self.run.enqueue(
161
- params, applied=ctx.applied, auth_result=auth_result
161
+ params,
162
+ applied=ctx.applied,
163
+ auth_result=auth_result,
164
+ version=ctx.version,
162
165
  )
163
166
 
164
167
  @command("run/steer")
165
168
  async def run_steer(self, params, *, ctx, auth_result):
166
169
  return await self.run.steer(
167
- params, applied=ctx.applied, auth_result=auth_result
170
+ params,
171
+ applied=ctx.applied,
172
+ auth_result=auth_result,
173
+ version=ctx.version,
168
174
  )
169
175
 
170
176
  @command("run/dequeue")
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.39.0"
3
+ version = "0.39.2"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.21.1,<0.23", "pinned>=0.16.0,<0.17"]
8
+ dependencies = ["statewire>=0.21.2,<0.24", "pinned>=0.16.0,<0.17"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -189,11 +189,11 @@ class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
189
189
 
190
190
  @command("run/enqueue")
191
191
  async def run_enqueue(self, params, *, ctx, auth_result):
192
- return await self.run.enqueue(params, applied=ctx.applied, auth_result=auth_result)
192
+ return await self.run.enqueue(params, applied=ctx.applied, auth_result=auth_result, version=ctx.version)
193
193
 
194
194
  @command("run/steer")
195
195
  async def run_steer(self, params, *, ctx, auth_result):
196
- return await self.run.steer(params, applied=ctx.applied, auth_result=auth_result)
196
+ return await self.run.steer(params, applied=ctx.applied, auth_result=auth_result, version=ctx.version)
197
197
 
198
198
  @command("run/dequeue")
199
199
  async def run_dequeue(self, params, *, auth_result):
@@ -3,7 +3,7 @@ from statewire.protocol import Subprotocol
3
3
 
4
4
  HARNESS_PROTOCOL = Statewire.Protocol(
5
5
  "harness-sdk",
6
- version="2026-09-30",
6
+ version="2026-11-02",
7
7
  min_version="2026-09-30",
8
8
  subprotocols=(
9
9
  Subprotocol("interest", record="ephemeral"),
@@ -88,6 +88,7 @@ MAX_MESSAGE_BYTES = 1 << 20
88
88
  MAX_RUN_IDS = 4096
89
89
 
90
90
  _ABSENT: Any = object()
91
+ _LEAF_SEND_VERSION = "2026-11-02"
91
92
  _PARKED: Any = object()
92
93
 
93
94
  _CAPABILITIES = frozenset(
@@ -310,6 +311,7 @@ class _Send:
310
311
  thread_empty: bool = False
311
312
  minted: bool = False
312
313
  principal: Any = None
314
+ version: str | None = None
313
315
  future: "asyncio.Future[Any]" = field(default_factory=_future)
314
316
 
315
317
 
@@ -1252,7 +1254,12 @@ class RunManager(Tray):
1252
1254
  if len(self._batch) + len(steer) >= self.max_queued:
1253
1255
  raise _reject("queue-full", f"queue is full ({self.max_queued} messages)")
1254
1256
  staged = self._stamped(e.message, e.meta)
1255
- if not self._batch and not steer and e.anchor is not _ABSENT:
1257
+ if (
1258
+ not self._batch
1259
+ and not steer
1260
+ and e.anchor is not _ABSENT
1261
+ and not self._stale_anchor(e)
1262
+ ):
1256
1263
  staged["~parent"] = e.anchor
1257
1264
  self._batch.extend(("steerQueue", item) for item in steer)
1258
1265
  self._batch.append((e.lane, staged))
@@ -1380,6 +1387,15 @@ class RunManager(Tray):
1380
1387
  if not e.anchor_meta["onActiveBranch"]:
1381
1388
  raise _reject("wrong-anchor", f"anchor {e.anchor} is off the active branch")
1382
1389
 
1390
+ def _stale_anchor(self, e: _Send) -> bool:
1391
+ return (
1392
+ e.version is not None
1393
+ and e.version >= _LEAF_SEND_VERSION
1394
+ and e.anchor_meta is not None
1395
+ and not e.anchor_meta["isLeaf"]
1396
+ and e.anchor not in self.dispatchedIds.value
1397
+ )
1398
+
1383
1399
  # ─── Queue mutations ────────────────────────────────────
1384
1400
 
1385
1401
  def _stamped(self, message: dict[str, Any], meta: Any) -> dict[str, Any]:
@@ -2106,6 +2122,7 @@ class RunManagerNamespace(TrayMapNamespace):
2106
2122
  meta: Any,
2107
2123
  applied: Callable[[], None],
2108
2124
  auth_result: Any,
2125
+ version: str | None,
2109
2126
  ) -> Any:
2110
2127
  cls: type[RunManager] = self._spec.tray_cls # type: ignore[assignment]
2111
2128
  if not isinstance(params, dict):
@@ -2151,6 +2168,7 @@ class RunManagerNamespace(TrayMapNamespace):
2151
2168
  anchor_meta=anchor_meta,
2152
2169
  thread_empty=thread_empty,
2153
2170
  principal=auth_result,
2171
+ version=version,
2154
2172
  )
2155
2173
  )
2156
2174
  message = _validated_message(cls, params["message"])
@@ -2171,6 +2189,7 @@ class RunManagerNamespace(TrayMapNamespace):
2171
2189
  thread_empty=thread_empty,
2172
2190
  minted=minted,
2173
2191
  principal=auth_result,
2192
+ version=version,
2174
2193
  )
2175
2194
  )
2176
2195
 
@@ -2181,8 +2200,9 @@ class RunManagerNamespace(TrayMapNamespace):
2181
2200
  meta: Any = None,
2182
2201
  applied: Callable[[], None],
2183
2202
  auth_result: Any,
2203
+ version: str | None = None,
2184
2204
  ) -> Any:
2185
- return await self._send("queue", params, meta, applied, auth_result)
2205
+ return await self._send("queue", params, meta, applied, auth_result, version)
2186
2206
 
2187
2207
  async def steer(
2188
2208
  self,
@@ -2191,8 +2211,9 @@ class RunManagerNamespace(TrayMapNamespace):
2191
2211
  meta: Any = None,
2192
2212
  applied: Callable[[], None],
2193
2213
  auth_result: Any,
2214
+ version: str | None = None,
2194
2215
  ) -> Any:
2195
- return await self._send("steerQueue", params, meta, applied, auth_result)
2216
+ return await self._send("steerQueue", params, meta, applied, auth_result, version)
2196
2217
 
2197
2218
  async def dequeue(self, params: Any, *, auth_result: Any) -> None:
2198
2219
  message_id = params.get("messageId") if isinstance(params, dict) else None
@@ -8,9 +8,11 @@ from uuid import uuid4
8
8
  from pinned import PinnedWorker
9
9
  from pinned.testing import MemoryStateStore
10
10
  from statewire import Statewire, command
11
+ from statewire.protocol import VERSION, Offer, Protocol, VersionRange
12
+ from statewire.protocol import headers as protocol_headers
11
13
  from statewire_helpers import apply_ops, attach, post_frame, statewire_client, stream_of
12
14
 
13
- from harness_sdk import RunManager, run_manager
15
+ from harness_sdk import HARNESS_PROTOCOL, RunManager, run_manager
14
16
 
15
17
 
16
18
  @dataclass
@@ -53,6 +55,11 @@ class Script:
53
55
  assert self.calls.empty(), "unexpected start() invocation"
54
56
 
55
57
 
58
+ def negotiating(version: str) -> dict[str, str]:
59
+ """Attach headers offering only ``version`` of the test host's protocol."""
60
+ return protocol_headers(VersionRange(VERSION), (Offer("default", VersionRange(version)),))
61
+
62
+
56
63
  def _meta(params: Any) -> Any:
57
64
  """Test hosts stamp meta from a wire `meta` param; real hosts stamp their own."""
58
65
  return params.pop("meta", None) if isinstance(params, dict) else None
@@ -65,8 +72,16 @@ def make_host(
65
72
  prepare_message=None,
66
73
  prepare_input=None,
67
74
  authorize=None,
75
+ version=HARNESS_PROTOCOL.version,
68
76
  ):
69
77
  class Host(Statewire):
78
+ protocols = (
79
+ Protocol(
80
+ "default",
81
+ version=HARNESS_PROTOCOL.version,
82
+ min_version=HARNESS_PROTOCOL.min_version,
83
+ ),
84
+ )
70
85
  live: "Host | None" = None
71
86
 
72
87
  async def lifespan(self):
@@ -89,13 +104,21 @@ def make_host(
89
104
  @command("run/enqueue")
90
105
  async def run_enqueue(self, params, *, ctx, auth_result):
91
106
  return await self.runs.enqueue(
92
- params, meta=_meta(params), applied=ctx.applied, auth_result=auth_result
107
+ params,
108
+ meta=_meta(params),
109
+ applied=ctx.applied,
110
+ auth_result=auth_result,
111
+ version=version,
93
112
  )
94
113
 
95
114
  @command("run/steer")
96
115
  async def run_steer(self, params, *, ctx, auth_result):
97
116
  return await self.runs.steer(
98
- params, meta=_meta(params), applied=ctx.applied, auth_result=auth_result
117
+ params,
118
+ meta=_meta(params),
119
+ applied=ctx.applied,
120
+ auth_result=auth_result,
121
+ version=version,
99
122
  )
100
123
 
101
124
  @command("run/dequeue")
@@ -315,7 +338,9 @@ async def run_host(
315
338
  store=None,
316
339
  authorize=None,
317
340
  headers=None,
341
+ version=HARNESS_PROTOCOL.version,
318
342
  ):
343
+ """``version`` is the negotiated protocol date the host passes to sends; None passes none."""
319
344
  host_cls = make_host(
320
345
  script,
321
346
  capabilities=capabilities,
@@ -323,6 +348,7 @@ async def run_host(
323
348
  prepare_message=prepare_message,
324
349
  prepare_input=prepare_input,
325
350
  authorize=authorize,
351
+ version=version,
326
352
  )
327
353
  worker = PinnedWorker(
328
354
  state_store=store if store is not None else MemoryStateStore()
@@ -330,7 +356,14 @@ async def run_host(
330
356
  async with statewire_client(host_cls, worker=worker) as (app, client):
331
357
  # Fresh client id per session, like a production client.
332
358
  client_id = f"c-{uuid4().hex[:8]}"
333
- async with stream_of(app, client_id=client_id, headers=headers) as stream:
359
+ async with stream_of(
360
+ app,
361
+ client_id=client_id,
362
+ headers={
363
+ **negotiating(version or HARNESS_PROTOCOL.min_version),
364
+ **(headers or {}),
365
+ },
366
+ ) as stream:
334
367
  snapshot = await stream.next_event()
335
368
  driver = RunDriver(client, stream, app, client_id=client_id)
336
369
  driver.envelopes.append(snapshot)
@@ -1,3 +1,4 @@
1
+ import pytest
1
2
  from run_helpers import (
2
3
  Script,
3
4
  add,
@@ -14,6 +15,19 @@ from run_helpers import (
14
15
 
15
16
  from harness_sdk import RunManager
16
17
 
18
+ BRANCHING = "2026-09-30"
19
+
20
+
21
+ def two_turns():
22
+ script = Script()
23
+ script.thread.update(
24
+ {
25
+ "u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
26
+ "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
27
+ }
28
+ )
29
+ return script
30
+
17
31
 
18
32
  async def test_add_requires_a_run_id():
19
33
  script = Script()
@@ -208,15 +222,28 @@ async def test_post_reload_sends_anchor_on_the_reload_targets_parent():
208
222
  await drv.wait_status("running")
209
223
 
210
224
 
211
- async def test_a_dispatching_send_carries_its_anchor_as_the_parent():
212
- script = Script()
213
- script.thread.update(
214
- {
215
- "u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
216
- "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
217
- }
218
- )
225
+ async def test_a_dispatching_send_carries_its_leaf_anchor_as_the_parent():
226
+ script = two_turns()
219
227
  async with run_host(script) as (drv, host):
228
+ drv.post("run/enqueue", add("m1", anchor="a1"))
229
+ call = await script.next_call()
230
+ assert call.ctx.messages == ({**msg("m1"), "parentId": "a1"},)
231
+
232
+
233
+ async def test_a_stale_anchor_appends_at_the_leaf():
234
+ script = two_turns()
235
+ async with run_host(script) as (drv, host):
236
+ drv.post("run/steer", add("m1", anchor="u1"))
237
+ call = await script.next_call()
238
+ assert call.ctx.messages == (msg("m1"),)
239
+ await drv.wait(lambda s: run_of(s)["dispatching"] is not None)
240
+ assert drv.run["dispatching"]["messages"] == [msg("m1")]
241
+
242
+
243
+ @pytest.mark.parametrize("version", [BRANCHING, None])
244
+ async def test_a_stale_anchor_branches_under_2026_09_30_or_an_unknown_version(version):
245
+ script = two_turns()
246
+ async with run_host(script, version=version) as (drv, host):
220
247
  drv.post("run/enqueue", add("m1", anchor="u1"))
221
248
  call = await script.next_call()
222
249
  assert call.ctx.messages == ({**msg("m1"), "parentId": "u1"},)
@@ -229,6 +256,62 @@ async def test_a_dispatching_send_carries_its_anchor_as_the_parent():
229
256
  assert queued.ctx.messages == (msg("m2"),)
230
257
 
231
258
 
259
+ async def test_off_branch_and_unknown_anchors_reject_under_2026_09_30():
260
+ script = two_turns()
261
+ script.thread["old"] = {
262
+ "parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
263
+ }
264
+ async with run_host(script, version=BRANCHING) as (drv, host):
265
+ assert_rejected(
266
+ await drv.command("run/enqueue", add("m1", anchor="old"), terminal=False),
267
+ "wrong-anchor",
268
+ )
269
+ assert_rejected(
270
+ await drv.command("run/enqueue", add("m1", anchor="zz"), terminal=False),
271
+ "unknown-id",
272
+ )
273
+ script.no_call()
274
+
275
+
276
+ async def test_a_dispatched_anchor_stays_the_parent_after_an_error():
277
+ script = Script()
278
+ async with run_host(script) as (drv, host):
279
+ drv.post("run/enqueue", add("m1"))
280
+ call = await script.next_call()
281
+ call.applied()
282
+ script.thread.update(
283
+ {
284
+ "m1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
285
+ "a1": {"parentId": "m1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
286
+ }
287
+ )
288
+ call.fail(RuntimeError("boom"))
289
+ await drv.wait_status("error")
290
+ drv.post("run/enqueue", add("m2", anchor="m1"))
291
+ retry = await script.next_call()
292
+ assert retry.ctx.messages == ({**msg("m2"), "parentId": "m1"},)
293
+
294
+
295
+ async def test_edit_and_reload_anchor_at_a_non_leaf_parent():
296
+ script = two_turns()
297
+ script.thread["a0"] = {
298
+ "parentId": None, "role": "assistant", "isLeaf": False, "onActiveBranch": True
299
+ }
300
+ script.thread["u1"] = {**script.thread["u1"], "parentId": "a0"}
301
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
302
+ drv.post("run/edit", edit_params("u1", msg("u2"), anchor="a0"))
303
+ edit = await script.next_call()
304
+ assert edit.ctx.rollback_to == "a0"
305
+ assert edit.ctx.messages == (msg("u2"),)
306
+ edit.applied()
307
+ edit.finish(RunManager.Complete())
308
+ await drv.wait_status("ready")
309
+ drv.post("run/reload", reload_params("a1", anchor="u1"))
310
+ reload = await script.next_call()
311
+ assert reload.ctx.trigger == "message-reload"
312
+ assert reload.ctx.rollback_to == "u1"
313
+
314
+
232
315
  async def test_a_message_parent_id_is_rejected():
233
316
  script = Script()
234
317
  async with run_host(script) as (drv, host):
@@ -15,6 +15,7 @@ from langgraph.checkpoint.memory import InMemorySaver
15
15
  from pinned import PinnedWorker
16
16
  from pinned.testing import MemoryStateStore
17
17
  from run_helpers import _statement, add, edit_params, msg
18
+ from statewire.protocol import VERSION, Offer, VersionRange, headers as protocol_headers
18
19
  from statewire_helpers import apply_ops, post_frame, statewire_client, stream_of
19
20
  from voice_helpers import NEGOTIATION, VoiceDriver
20
21
 
@@ -334,3 +335,29 @@ async def test_host_send_from_a_switched_branch_grows_under_its_leaf():
334
335
  assert all(w[:2] == ["u1", old_leaf] for w in windows)
335
336
  assert window(drv)[2:] == ["u3", drv.replica["threads"]["main"]["headId"]]
336
337
  assert next(m for m in drv.mounted() if m["id"] == "u3")["parentId"] == old_leaf
338
+
339
+
340
+ @pytest.mark.parametrize(("version", "appends"), [("2026-11-02", True), ("2026-09-30", False)])
341
+ async def test_host_stale_on_branch_send_appends_at_the_leaf_from_2026_11_02(version, appends):
342
+ """A send anchored at a committed non-leaf message on the active branch appends at the
343
+ leaf for a 2026-11-02 client and branches for a 2026-09-30 one, via ``ctx.version``."""
344
+
345
+ class Fresh(Host):
346
+ checkpointer = InMemorySaver()
347
+ index = MemoryCheckpointIndex()
348
+
349
+ headers = protocol_headers(VersionRange(VERSION), (Offer("harness-sdk", VersionRange(version)),))
350
+ async with statewire_client(Fresh, worker=PinnedWorker(state_store=MemoryStateStore())) as (app, client):
351
+ async with stream_of(app, client_id="c1", headers=headers) as stream:
352
+ snapshot = await stream.next_event()
353
+ drv = VoiceDriver(client, stream, app, client_id="c1", protocol="harness-sdk")
354
+ apply_ops(drv.replica, copy.deepcopy(snapshot))
355
+ drv.fold(snapshot)
356
+ await drv.command("run/enqueue", add("u1", anchor=None))
357
+ await drv.wait(lambda s: len(window(drv)) == 2 and s["status"] == "ready")
358
+ leaf = window(drv)[1]
359
+ await drv.command("run/enqueue", add("u2", anchor="u1"))
360
+ await drv.wait(lambda s: "u2" in window(drv) and len(window(drv)) >= 3 and s["status"] == "ready")
361
+ parent = next(m for m in drv.mounted() if m["id"] == "u2")["parentId"]
362
+ assert parent == (leaf if appends else "u1")
363
+ assert window(drv)[: (3 if appends else 2)] == (["u1", leaf, "u2"] if appends else ["u1", "u2"])
@@ -9,7 +9,7 @@ from fastapi import FastAPI
9
9
  from pinned import PinnedAPI, PinnedHost, PinnedWorker, route
10
10
  from pinned.testing import MemoryStateStore
11
11
  from run_helpers import RunDriver, Script, add, make_host
12
- from statewire.protocol import Offer, VersionRange, headers as protocol_headers
12
+ from statewire.protocol import VERSION, Offer, VersionRange, headers as protocol_headers
13
13
  from statewire_helpers import CLIENT_ID_HEADER, SSEStream, apply_ops, cmd_on, stream_of
14
14
 
15
15
  from harness_sdk import RunManager
@@ -20,8 +20,8 @@ from harness_sdk.wire import HarnessMessages, first_user_text
20
20
 
21
21
  pytestmark = pytest.mark.anyio
22
22
 
23
- NEGOTIATION = protocol_headers(VersionRange(), (Offer("harness-sdk-threads", VersionRange("2026-09-13")),))
24
- HOST_NEGOTIATION = protocol_headers(VersionRange(), (Offer("harness-sdk", VersionRange()),))
23
+ NEGOTIATION = protocol_headers(VersionRange(VERSION), (Offer("harness-sdk-threads", VersionRange("2026-09-13")),))
24
+ HOST_NEGOTIATION = protocol_headers(VersionRange(VERSION), (Offer("harness-sdk", VersionRange(VERSION)),))
25
25
 
26
26
 
27
27
  class Toucher(PinnedAPI):
@@ -10,7 +10,7 @@ from pinned.testing import MemoryStateStore
10
10
  from run_helpers import RunDriver, _statement
11
11
  from statewire import Statewire, command
12
12
  from statewire.client import apply_ops as client_apply_ops
13
- from statewire.protocol import Offer, VersionRange, headers as protocol_headers
13
+ from statewire.protocol import VERSION, Offer, VersionRange, headers as protocol_headers
14
14
  from statewire_helpers import apply_ops, post_frame, statewire_client, stream_of
15
15
 
16
16
  from harness_sdk import (
@@ -26,7 +26,7 @@ from harness_sdk import (
26
26
  from harness_sdk.messages import MAIN_NS
27
27
  from harness_sdk.protocol import HARNESS_PROTOCOL
28
28
 
29
- NEGOTIATION = protocol_headers(VersionRange(), (Offer("harness-sdk", VersionRange()),))
29
+ NEGOTIATION = protocol_headers(VersionRange(VERSION), (Offer("harness-sdk", VersionRange(VERSION)),))
30
30
 
31
31
  OFFER = "v=0 fake-offer"
32
32
  ANSWER = "v=0 fake-answer"