harness-sdk-python 0.29.0__tar.gz → 0.29.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 (42) hide show
  1. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/deepagents.py +6 -3
  4. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/voice.py +8 -2
  5. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_deepagents_messages.py +14 -2
  6. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_voice.py +52 -7
  7. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/voice_helpers.py +5 -1
  8. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/.gitignore +0 -0
  9. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/README.md +0 -0
  10. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/checkpoints.py +0 -0
  14. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/run_manager.py +0 -0
  17. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/run_helpers.py +0 -0
  18. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_admit_steer.py +0 -0
  19. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_applied_visibility.py +0 -0
  20. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_batches.py +0 -0
  21. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_checkpoints.py +0 -0
  23. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_stopping_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.29.0
3
+ Version: 0.29.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.29.0"
3
+ version = "0.29.2"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,7 +1,8 @@
1
1
  """DeepagentsMessages: the ``messages`` slice of a Statewire host, projected
2
2
  from a langgraph checkpointer in the deepagents dialect. The checkpointer is
3
3
  the source of truth and the wire state is its projection, restamped with
4
- ``branch: {id, siblings}`` metadata after every ingest. A rewind restores the
4
+ ``branch: {id, siblings}`` metadata after every ingest; ephemeral overlay
5
+ entries carry ``ephemeral: true`` instead. A rewind restores the
5
6
  projection from the target message's anchor checkpoint; a branch switch clones
6
7
  the target branch's tip as the checkpointer's latest checkpoint, so resumption
7
8
  and reboot both land on the switched world.
@@ -243,7 +244,7 @@ class DeepagentsMessages:
243
244
  return snapshot
244
245
 
245
246
  def upsert_ephemeral_message(self, message: dict[str, Any]) -> None:
246
- """Render an in-flight ``{id, role, text}`` entry in ``state["messages"]``; an ``append_messages`` of the same id replaces it durably."""
247
+ """Render an in-flight ``{id, role, text}`` entry in ``state["messages"]`` tagged ``ephemeral: true``; an ``append_messages`` of the same id replaces it durably."""
247
248
  if message["role"] not in ("user", "assistant"):
248
249
  raise ValueError(f"unsupported message role: {message['role']!r}")
249
250
  if message["id"] not in self._ephemeral and any(
@@ -280,6 +281,8 @@ class DeepagentsMessages:
280
281
  stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
281
282
  durable = {message["id"] for message in stamped}
282
283
  stamped.extend(
283
- entry for id_, entry in self._ephemeral.items() if id_ not in durable
284
+ {**entry, "ephemeral": True}
285
+ for id_, entry in self._ephemeral.items()
286
+ if id_ not in durable
284
287
  )
285
288
  self._replace(stamped)
@@ -13,11 +13,13 @@ status}`` — the SDP answer rides only the command's result payload
13
13
  ``{answer}``. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
14
14
  provider ``closed`` event, or a connection drop ends it. Provider turn
15
15
  events render live in ``state["messages"]`` through
16
- ``Host.upsert_ephemeral_message`` (message id = turn id); each completed
16
+ ``Host.upsert_ephemeral_message`` (message id = turn id), tagged
17
+ ``ephemeral: true`` on the wire; each completed
17
18
  turn commits through ``Host.commit_voice_turns`` (serialized, in
18
19
  done-order) and the durable entry replaces the ephemeral one under the
19
20
  same id, a failed commit keeps its turns for the next flush; session end
20
- flushes the remaining done turns and drops in-progress turns.
21
+ flushes the remaining done turns, then commits in-progress turns with
22
+ their partial text; in-progress turns with no text yet are dropped.
21
23
 
22
24
  Rejections: ``voice-active`` (a session is already live), ``no-voice``
23
25
  (no voice manager declared, or ``voice/end`` with no session),
@@ -188,6 +190,10 @@ class VoiceManager(Tray):
188
190
  await provider.close()
189
191
  except Exception:
190
192
  logger.exception("voice provider close failed for session %r", self.id)
193
+ self._turns.sort(key=lambda turn: not turn["done"])
194
+ for turn in self._turns:
195
+ if turn["text"]:
196
+ turn["done"] = True
191
197
  await self._flush()
192
198
  turns, self._turns = self._turns, []
193
199
  for turn in turns:
@@ -170,10 +170,16 @@ async def test_ingest_turn_refetch_discards_the_capture():
170
170
  async def test_ephemeral_message_renders_and_updates_in_place():
171
171
  _, _, _, messages, state = await seeded()
172
172
  messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hel"})
173
- assert state["messages"][-1] == {"id": "v1", "type": "human", "content": "hel"}
173
+ assert state["messages"][-1] == {
174
+ "id": "v1",
175
+ "type": "human",
176
+ "content": "hel",
177
+ "ephemeral": True,
178
+ }
174
179
  messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hello"})
175
180
  assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
176
181
  assert state["messages"][-1]["content"] == "hello"
182
+ assert all("ephemeral" not in m for m in state["messages"][:-1])
177
183
 
178
184
 
179
185
  async def test_ephemeral_message_survives_a_turn_ingest():
@@ -181,7 +187,12 @@ async def test_ephemeral_message_survives_a_turn_ingest():
181
187
  messages.upsert_ephemeral_message({"id": "v1", "role": "assistant", "text": "hi"})
182
188
  await turn(graph, messages, Ctx("message-send", (user("u3", "three"),)))
183
189
  assert ids(state) == ["u1", "a0", "u2", "a1", "u3", "a2", "v1"]
184
- assert state["messages"][-1] == {"id": "v1", "type": "ai", "content": "hi"}
190
+ assert state["messages"][-1] == {
191
+ "id": "v1",
192
+ "type": "ai",
193
+ "content": "hi",
194
+ "ephemeral": True,
195
+ }
185
196
 
186
197
 
187
198
  async def test_append_messages_replaces_the_ephemeral_entry_durably():
@@ -190,6 +201,7 @@ async def test_append_messages_replaces_the_ephemeral_entry_durably():
190
201
  await messages.append_messages([{"id": "v1", "role": "user", "text": "spoken"}])
191
202
  assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
192
203
  assert "branch" in state["messages"][-1]
204
+ assert "ephemeral" not in state["messages"][-1]
193
205
  messages.drop_ephemeral_message("v1")
194
206
  assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
195
207
 
@@ -88,7 +88,14 @@ async def test_turn_streams_into_messages_ephemerally():
88
88
  events.turn_delta("t1", "there")
89
89
  await drv.wait(
90
90
  lambda s: messages_of(s)
91
- == [{"id": "t1", "type": "human", "content": "hello there"}]
91
+ == [
92
+ {
93
+ "id": "t1",
94
+ "type": "human",
95
+ "content": "hello there",
96
+ "ephemeral": True,
97
+ }
98
+ ]
92
99
  )
93
100
  assert bench.commits == []
94
101
 
@@ -141,7 +148,14 @@ async def test_commit_failure_keeps_the_turn_until_the_next_flush():
141
148
  assert bench.commits == []
142
149
  await drv.wait(
143
150
  lambda s: messages_of(s)
144
- == [{"id": "t1", "type": "human", "content": "question"}]
151
+ == [
152
+ {
153
+ "id": "t1",
154
+ "type": "human",
155
+ "content": "question",
156
+ "ephemeral": True,
157
+ }
158
+ ]
145
159
  )
146
160
  # session end retries the kept turn
147
161
  await drv.command("voice/end")
@@ -172,7 +186,7 @@ async def test_commit_failure_retries_in_order_with_the_next_turn():
172
186
  ]
173
187
 
174
188
 
175
- async def test_end_drops_in_progress_turns():
189
+ async def test_end_commits_in_progress_turns_with_partial_text():
176
190
  bench = Bench()
177
191
  async with voice_host(bench) as (drv, _app):
178
192
  await drv.command("voice/start", {"offer": OFFER})
@@ -185,15 +199,41 @@ async def test_end_drops_in_progress_turns():
185
199
  rsp = await drv.command("voice/end")
186
200
  assert rsp["type"] == "accepted", rsp
187
201
  assert bench.provider.closed
188
- assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
202
+ assert bench.commits == [
203
+ [{"id": "t1", "role": "user", "text": "question"}],
204
+ [{"id": "t2", "role": "assistant", "text": "half an ans"}],
205
+ ]
206
+ # the durable entries replaced the ephemeral ones under the same ids
189
207
  await drv.wait(
190
208
  lambda s: voice_of(s) is None
191
209
  and messages_of(s)
192
- == [{"id": "t1", "type": "human", "content": "question"}]
210
+ == [
211
+ {"id": "t1", "type": "human", "content": "question"},
212
+ {"id": "t2", "type": "ai", "content": "half an ans"},
213
+ ]
193
214
  )
194
215
 
195
216
 
196
- async def test_end_with_no_done_turns_commits_nothing():
217
+ async def test_end_commits_partial_turns_after_kept_done_turns():
218
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
219
+ async with voice_host(bench) as (drv, _app):
220
+ await drv.command("voice/start", {"offer": OFFER})
221
+ events = bench.events()
222
+ events.turn_started("t1", "user")
223
+ events.turn_done("t1", "question")
224
+ await until(lambda: bench.commit_failures == [])
225
+ events.turn_started("t2", "assistant")
226
+ events.turn_delta("t2", "half an ans")
227
+ await drv.command("voice/end")
228
+ assert bench.commits == [
229
+ [
230
+ {"id": "t1", "role": "user", "text": "question"},
231
+ {"id": "t2", "role": "assistant", "text": "half an ans"},
232
+ ]
233
+ ]
234
+
235
+
236
+ async def test_end_drops_started_turns_with_no_text():
197
237
  bench = Bench()
198
238
  async with voice_host(bench) as (drv, _app):
199
239
  await drv.command("voice/start", {"offer": OFFER})
@@ -220,9 +260,14 @@ async def test_provider_closed_event_ends_and_commits():
220
260
  events = bench.events()
221
261
  events.turn_started("t1", "assistant")
222
262
  events.turn_done("t1", "goodbye")
263
+ events.turn_started("t2", "user")
264
+ events.turn_delta("t2", "wai")
223
265
  events.closed("server")
224
266
  await drv.wait(lambda s: voice_of(s) is None)
225
- assert bench.commits == [[{"id": "t1", "role": "assistant", "text": "goodbye"}]]
267
+ assert bench.commits == [
268
+ [{"id": "t1", "role": "assistant", "text": "goodbye"}],
269
+ [{"id": "t2", "role": "user", "text": "wai"}],
270
+ ]
226
271
  assert_rejected(await drv.command("voice/end"), "no-voice")
227
272
 
228
273
 
@@ -83,7 +83,11 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
83
83
  durable_ids = {m["id"] for m in self._durable}
84
84
  self.state["messages"] = [
85
85
  *self._durable,
86
- *(e for i, e in self._ephemeral.items() if i not in durable_ids),
86
+ *(
87
+ {**e, "ephemeral": True}
88
+ for i, e in self._ephemeral.items()
89
+ if i not in durable_ids
90
+ ),
87
91
  ]
88
92
 
89
93
  async def get_voice_seed(self):