harness-sdk-python 0.27.0__tar.gz → 0.28.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 (42) hide show
  1. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/deepagents.py +25 -0
  4. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/voice.py +67 -31
  5. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_deepagents_messages.py +51 -0
  6. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_voice.py +103 -11
  7. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/voice_helpers.py +32 -1
  8. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/.gitignore +0 -0
  9. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/README.md +0 -0
  10. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/checkpoints.py +0 -0
  14. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/run_manager.py +0 -0
  17. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/run_helpers.py +0 -0
  18. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_admit_steer.py +0 -0
  19. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_applied_visibility.py +0 -0
  20. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_batches.py +0 -0
  21. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_checkpoints.py +0 -0
  23. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/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.27.0
3
+ Version: 0.28.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.27.0"
3
+ version = "0.28.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -66,6 +66,7 @@ class DeepagentsMessages:
66
66
  )
67
67
  self._checkpointer = checkpointer
68
68
  self._captured: dict[str, Any] | None = None
69
+ self._ephemeral: dict[str, dict[str, Any]] = {}
69
70
 
70
71
  async def boot(
71
72
  self, state: Any, *, index: CheckpointIndex, thread_id: str
@@ -241,6 +242,26 @@ class DeepagentsMessages:
241
242
  )
242
243
  return snapshot
243
244
 
245
+ 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
+ if message["role"] not in ("user", "assistant"):
248
+ raise ValueError(f"unsupported message role: {message['role']!r}")
249
+ if message["id"] not in self._ephemeral and any(
250
+ m["id"] == message["id"] for m in self._thread.transcript
251
+ ):
252
+ raise ValueError(f"message {message['id']!r} is already durable")
253
+ self._ephemeral[message["id"]] = {
254
+ "id": message["id"],
255
+ "type": "human" if message["role"] == "user" else "ai",
256
+ "content": message["text"],
257
+ }
258
+ self._restamp()
259
+
260
+ def drop_ephemeral_message(self, message_id: str) -> None:
261
+ """Remove an in-flight entry from the ``state["messages"]`` projection."""
262
+ del self._ephemeral[message_id]
263
+ self._restamp()
264
+
244
265
  def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
245
266
  entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
246
267
  if not any(m["id"] == entry["id"] for m in plain(self._state["messages"])):
@@ -257,4 +278,8 @@ class DeepagentsMessages:
257
278
  for message in self._thread.transcript:
258
279
  branch, siblings = self._thread.sibling_info(message["id"])
259
280
  stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
281
+ durable = {message["id"] for message in stamped}
282
+ stamped.extend(
283
+ entry for id_, entry in self._ephemeral.items() if id_ not in durable
284
+ )
260
285
  self._replace(stamped)
@@ -9,12 +9,15 @@ and the turn commit both stay host-owned.
9
9
 
10
10
  ``voice/start {offer}`` seeds the provider from the thread transcript (at
11
11
  most 128 messages, tail taken) and projects ``state["voice"] = {clientId,
12
- status, answer, turns}`` — the SDP answer rides the applied state. The
13
- session is bound to the issuing client's connection: ``voice/end`` from the
14
- owner, a provider ``closed`` event, or a connection drop ends it. Provider
15
- turn events accumulate in the in-memory turn buffer projected as
16
- ``state["voice"]["turns"]``; at session end the completed turns commit
17
- through ``Host.commit_voice_turns`` and in-progress turns drop.
12
+ status, answer}`` — the SDP answer rides the applied state. The session is
13
+ bound to the issuing client's connection: ``voice/end`` from the owner, a
14
+ provider ``closed`` event, or a connection drop ends it. Provider turn
15
+ events render live in ``state["messages"]`` through
16
+ ``Host.upsert_ephemeral_message`` (message id = turn id); each completed
17
+ turn commits through ``Host.commit_voice_turns`` (serialized, in
18
+ done-order) and the durable entry replaces the ephemeral one under the
19
+ 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.
18
21
 
19
22
  Rejections: ``voice-active`` (a session is already live), ``no-voice``
20
23
  (no voice manager declared, or ``voice/end`` with no session),
@@ -58,22 +61,21 @@ def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
58
61
 
59
62
 
60
63
  class VoiceManager(Tray):
61
- """One live voice session: the provider handle, the in-memory turn
62
- buffer, and its wire projection; ended sessions commit and self-delete."""
64
+ """One live voice session: the provider handle and the in-memory turn
65
+ buffer rendered live in ``state["messages"]``; completed turns commit
66
+ per turn and ended sessions flush and self-delete."""
63
67
 
64
68
  connection_poll = 0.5
65
69
 
66
70
  clientId = observable(None)
67
71
  status = observable("connecting")
68
72
  answer = observable(None)
69
- turns = observable([])
70
73
 
71
74
  state = observable(
72
75
  {
73
76
  "clientId": clientId,
74
77
  "status": status,
75
78
  "answer": answer,
76
- "turns": turns,
77
79
  }
78
80
  )
79
81
 
@@ -81,6 +83,7 @@ class VoiceManager(Tray):
81
83
  super().__init__(**kwargs)
82
84
  self._provider: "VoiceProvider | None" = None
83
85
  self._turns: list[dict[str, Any]] = []
86
+ self._flushing = asyncio.Lock()
84
87
  self._ended = False
85
88
  self._watcher: "asyncio.Task[Any] | None" = None
86
89
 
@@ -121,7 +124,7 @@ class VoiceManager(Tray):
121
124
  def closed(self, reason: str) -> None: ...
122
125
 
123
126
  class Host(Protocol):
124
- """The host seam: the thread transcript in, committed turns out."""
127
+ """The host seam: the thread transcript in, live and committed turns out."""
125
128
 
126
129
  async def get_voice_seed(self) -> "list[VoiceManager.SeedMessage]":
127
130
  """The transcript as ``{role, text}`` seed messages, tool/system noise skipped."""
@@ -131,6 +134,14 @@ class VoiceManager(Tray):
131
134
  """Write completed ``{id, role, text}`` turns to the thread's durable transcript."""
132
135
  ...
133
136
 
137
+ def upsert_ephemeral_message(self, message: dict[str, Any]) -> None:
138
+ """Render an in-flight ``{id, role, text}`` turn in ``state["messages"]``; same id updates in place."""
139
+ ...
140
+
141
+ def drop_ephemeral_message(self, message_id: str) -> None:
142
+ """Remove an in-flight turn from the ``state["messages"]`` projection."""
143
+ ...
144
+
134
145
  # ─── Session lifecycle ──────────────────────────────────
135
146
 
136
147
  async def _begin(
@@ -180,19 +191,12 @@ class VoiceManager(Tray):
180
191
  await provider.close()
181
192
  except Exception:
182
193
  logger.exception("voice provider close failed for session %r", self.id)
183
- done = [
184
- {"id": t["id"], "role": t["role"], "text": t["text"]}
185
- for t in self._turns
186
- if t["done"]
187
- ]
188
- self._turns = []
189
- self.turns.value = []
194
+ await self._flush()
195
+ turns, self._turns = self._turns, []
196
+ for turn in turns:
197
+ self._host.drop_ephemeral_message(turn["id"])
190
198
  self.status.value = "closed"
191
- try:
192
- if done:
193
- await self.handler.__self__.commit_voice_turns(done)
194
- finally:
195
- self.delete()
199
+ self.delete()
196
200
 
197
201
  # ─── Events sink ────────────────────────────────────────
198
202
 
@@ -202,22 +206,48 @@ class VoiceManager(Tray):
202
206
  return turn
203
207
  raise ValueError(f"turn {turn_id!r} was never started")
204
208
 
205
- def _project_turns(self) -> None:
206
- self.turns.value = [dict(turn) for turn in self._turns]
209
+ @property
210
+ def _host(self) -> Any:
211
+ return self.handler.__self__
212
+
213
+ def _render(self, turn: dict[str, Any]) -> None:
214
+ self._host.upsert_ephemeral_message(
215
+ {"id": turn["id"], "role": turn["role"], "text": turn["text"]}
216
+ )
217
+
218
+ async def _flush(self) -> None:
219
+ # the lock serializes commits in done-order across turn-done and end
220
+ async with self._flushing:
221
+ done = [t for t in self._turns if t["done"]]
222
+ if not done:
223
+ return
224
+ try:
225
+ await self._host.commit_voice_turns(
226
+ [{"id": t["id"], "role": t["role"], "text": t["text"]} for t in done]
227
+ )
228
+ except Exception:
229
+ logger.exception("voice session %r turn commit failed", self.id)
230
+ return
231
+ committed = {t["id"] for t in done}
232
+ self._turns = [t for t in self._turns if t["id"] not in committed]
233
+ for turn_id in committed:
234
+ self._host.drop_ephemeral_message(turn_id)
207
235
 
208
236
  def turn_started(self, turn_id: str, role: str) -> None:
209
237
  if self._ended:
210
238
  return
211
239
  if any(turn["id"] == turn_id for turn in self._turns):
212
240
  raise ValueError(f"turn {turn_id!r} already started")
213
- self._turns.append({"id": turn_id, "role": role, "text": "", "done": False})
214
- self._project_turns()
241
+ turn = {"id": turn_id, "role": role, "text": "", "done": False}
242
+ self._turns.append(turn)
243
+ self._render(turn)
215
244
 
216
245
  def turn_delta(self, turn_id: str, text: str) -> None:
217
246
  if self._ended:
218
247
  return
219
- self._turn(turn_id)["text"] += text
220
- self._project_turns()
248
+ turn = self._turn(turn_id)
249
+ turn["text"] += text
250
+ self._render(turn)
221
251
 
222
252
  def turn_done(self, turn_id: str, text: str) -> None:
223
253
  if self._ended:
@@ -225,7 +255,8 @@ class VoiceManager(Tray):
225
255
  turn = self._turn(turn_id)
226
256
  turn["text"] = text
227
257
  turn["done"] = True
228
- self._project_turns()
258
+ self._render(turn)
259
+ self._spawn(self._flush())
229
260
 
230
261
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
231
262
  logger.info("voice session %r tool request ignored: %r", self.id, request)
@@ -272,7 +303,12 @@ class VoiceManagerNamespace(TrayMapNamespace):
272
303
 
273
304
  def __init__(self, api: Any, spec: TrayDescriptor) -> None:
274
305
  super().__init__(api, spec)
275
- for name in ("get_voice_seed", "commit_voice_turns"):
306
+ for name in (
307
+ "get_voice_seed",
308
+ "commit_voice_turns",
309
+ "upsert_ephemeral_message",
310
+ "drop_ephemeral_message",
311
+ ):
276
312
  if not callable(getattr(api, name, None)):
277
313
  raise TypeError(
278
314
  f"{type(api).__name__} must implement {name} "
@@ -164,6 +164,57 @@ async def test_ingest_turn_refetch_discards_the_capture():
164
164
  assert ids(state) == ["u1", "a0", "u2", "a1"]
165
165
 
166
166
 
167
+ # ─── Ephemeral messages ─────────────────────────────────────
168
+
169
+
170
+ async def test_ephemeral_message_renders_and_updates_in_place():
171
+ _, _, _, messages, state = await seeded()
172
+ messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hel"})
173
+ assert state["messages"][-1] == {"id": "v1", "type": "human", "content": "hel"}
174
+ messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hello"})
175
+ assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
176
+ assert state["messages"][-1]["content"] == "hello"
177
+
178
+
179
+ async def test_ephemeral_message_survives_a_turn_ingest():
180
+ _, _, graph, messages, state = await seeded()
181
+ messages.upsert_ephemeral_message({"id": "v1", "role": "assistant", "text": "hi"})
182
+ await turn(graph, messages, Ctx("message-send", (user("u3", "three"),)))
183
+ assert ids(state) == ["u1", "a0", "u2", "a1", "u3", "a2", "v1"]
184
+ assert state["messages"][-1] == {"id": "v1", "type": "ai", "content": "hi"}
185
+
186
+
187
+ async def test_append_messages_replaces_the_ephemeral_entry_durably():
188
+ _, _, _, messages, state = await seeded()
189
+ messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "spoken"})
190
+ await messages.append_messages([{"id": "v1", "role": "user", "text": "spoken"}])
191
+ assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
192
+ assert "branch" in state["messages"][-1]
193
+ messages.drop_ephemeral_message("v1")
194
+ assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
195
+
196
+
197
+ async def test_drop_ephemeral_message_removes_the_entry():
198
+ _, _, _, messages, state = await seeded()
199
+ messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "oops"})
200
+ messages.drop_ephemeral_message("v1")
201
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
202
+ with pytest.raises(KeyError):
203
+ messages.drop_ephemeral_message("v1")
204
+
205
+
206
+ async def test_upsert_ephemeral_rejects_a_durable_id():
207
+ _, _, _, messages, _ = await seeded()
208
+ with pytest.raises(ValueError, match="already durable"):
209
+ messages.upsert_ephemeral_message({"id": "u1", "role": "user", "text": "x"})
210
+
211
+
212
+ async def test_upsert_ephemeral_rejects_an_unsupported_role():
213
+ _, _, _, messages, _ = await seeded()
214
+ with pytest.raises(ValueError, match="unsupported message role"):
215
+ messages.upsert_ephemeral_message({"id": "v1", "role": "system", "text": "x"})
216
+
217
+
167
218
  # ─── Rewind ─────────────────────────────────────────────────
168
219
 
169
220
 
@@ -1,5 +1,7 @@
1
- """VoiceManager contract: session lifecycle, wire projection, and the commit
2
- of completed turns at session end."""
1
+ """VoiceManager contract: session lifecycle, the live turn projection in
2
+ ``state["messages"]``, and the per-turn commit at turn-done."""
3
+
4
+ import asyncio
3
5
 
4
6
  from run_helpers import _statement, assert_rejected
5
7
  from statewire_helpers import post_frame, stream_of
@@ -17,6 +19,17 @@ from voice_helpers import (
17
19
  from harness_sdk import VoiceManager
18
20
 
19
21
 
22
+ async def until(predicate, timeout: float = 5) -> None:
23
+ deadline = asyncio.get_running_loop().time() + timeout
24
+ while not predicate():
25
+ assert asyncio.get_running_loop().time() < deadline, "condition never held"
26
+ await asyncio.sleep(0.01)
27
+
28
+
29
+ def messages_of(replica):
30
+ return replica.get("messages")
31
+
32
+
20
33
  async def test_start_seeds_provider_and_projects_session():
21
34
  bench = Bench(transcript=[seed_message(0), seed_message(1)])
22
35
  async with voice_host(bench) as (drv, _app):
@@ -30,7 +43,6 @@ async def test_start_seeds_provider_and_projects_session():
30
43
  "clientId": drv.client_id,
31
44
  "status": "live",
32
45
  "answer": ANSWER,
33
- "turns": [],
34
46
  }
35
47
  assert bench.contexts[0].client_id == drv.client_id
36
48
 
@@ -70,7 +82,7 @@ async def test_provider_failure_rejects_voice_unavailable():
70
82
  assert rsp["type"] == "accepted", rsp
71
83
 
72
84
 
73
- async def test_turn_events_project_into_state():
85
+ async def test_turn_streams_into_messages_ephemerally():
74
86
  bench = Bench()
75
87
  async with voice_host(bench) as (drv, _app):
76
88
  await drv.command("voice/start", {"offer": OFFER})
@@ -79,30 +91,110 @@ async def test_turn_events_project_into_state():
79
91
  events.turn_delta("t1", "hello ")
80
92
  events.turn_delta("t1", "there")
81
93
  await drv.wait(
82
- lambda s: (voice_of(s) or {}).get("turns")
83
- == [{"id": "t1", "role": "user", "text": "hello there", "done": False}]
94
+ lambda s: messages_of(s)
95
+ == [{"id": "t1", "type": "human", "content": "hello there"}]
84
96
  )
97
+ assert bench.commits == []
98
+
99
+
100
+ async def test_turn_done_commits_promptly_under_the_same_id():
101
+ bench = Bench()
102
+ async with voice_host(bench) as (drv, _app):
103
+ await drv.command("voice/start", {"offer": OFFER})
104
+ events = bench.events()
105
+ events.turn_started("t1", "user")
85
106
  events.turn_done("t1", "hello there!")
107
+ await until(
108
+ lambda: bench.commits
109
+ == [[{"id": "t1", "role": "user", "text": "hello there!"}]]
110
+ )
111
+ await drv.wait(
112
+ lambda s: messages_of(s)
113
+ == [{"id": "t1", "type": "human", "content": "hello there!"}]
114
+ )
115
+ # the durable entry replaced the ephemeral one: end recommits nothing
116
+ await drv.command("voice/end")
117
+ assert len(bench.commits) == 1
118
+
119
+
120
+ async def test_turns_commit_in_done_order():
121
+ bench = Bench()
122
+ async with voice_host(bench) as (drv, _app):
123
+ await drv.command("voice/start", {"offer": OFFER})
124
+ events = bench.events()
125
+ events.turn_started("t1", "user")
126
+ events.turn_done("t1", "question")
127
+ await until(lambda: len(bench.commits) == 1)
128
+ events.turn_started("t2", "assistant")
129
+ events.turn_done("t2", "answer")
130
+ await until(lambda: len(bench.commits) == 2)
131
+ assert bench.commits == [
132
+ [{"id": "t1", "role": "user", "text": "question"}],
133
+ [{"id": "t2", "role": "assistant", "text": "answer"}],
134
+ ]
135
+
136
+
137
+ async def test_commit_failure_keeps_the_turn_until_the_next_flush():
138
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
139
+ async with voice_host(bench) as (drv, _app):
140
+ await drv.command("voice/start", {"offer": OFFER})
141
+ events = bench.events()
142
+ events.turn_started("t1", "user")
143
+ events.turn_done("t1", "question")
144
+ await until(lambda: bench.commit_failures == [])
145
+ assert bench.commits == []
86
146
  await drv.wait(
87
- lambda s: (voice_of(s) or {}).get("turns")
88
- == [{"id": "t1", "role": "user", "text": "hello there!", "done": True}]
147
+ lambda s: messages_of(s)
148
+ == [{"id": "t1", "type": "human", "content": "question"}]
149
+ )
150
+ # session end retries the kept turn
151
+ await drv.command("voice/end")
152
+ assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
153
+ await drv.wait(
154
+ lambda s: voice_of(s) is None
155
+ and messages_of(s)
156
+ == [{"id": "t1", "type": "human", "content": "question"}]
89
157
  )
90
158
 
91
159
 
92
- async def test_end_commits_done_turns_and_drops_in_progress():
160
+ async def test_commit_failure_retries_in_order_with_the_next_turn():
161
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
162
+ async with voice_host(bench) as (drv, _app):
163
+ await drv.command("voice/start", {"offer": OFFER})
164
+ events = bench.events()
165
+ events.turn_started("t1", "user")
166
+ events.turn_done("t1", "question")
167
+ await until(lambda: bench.commit_failures == [])
168
+ events.turn_started("t2", "assistant")
169
+ events.turn_done("t2", "answer")
170
+ await until(lambda: bench.commits != [])
171
+ assert bench.commits == [
172
+ [
173
+ {"id": "t1", "role": "user", "text": "question"},
174
+ {"id": "t2", "role": "assistant", "text": "answer"},
175
+ ]
176
+ ]
177
+
178
+
179
+ async def test_end_drops_in_progress_turns():
93
180
  bench = Bench()
94
181
  async with voice_host(bench) as (drv, _app):
95
182
  await drv.command("voice/start", {"offer": OFFER})
96
183
  events = bench.events()
97
184
  events.turn_started("t1", "user")
98
185
  events.turn_done("t1", "question")
186
+ await until(lambda: len(bench.commits) == 1)
99
187
  events.turn_started("t2", "assistant")
100
188
  events.turn_delta("t2", "half an ans")
101
189
  rsp = await drv.command("voice/end")
102
190
  assert rsp["type"] == "accepted", rsp
103
191
  assert bench.provider.closed
104
192
  assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
105
- await drv.wait(lambda s: voice_of(s) is None)
193
+ await drv.wait(
194
+ lambda s: voice_of(s) is None
195
+ and messages_of(s)
196
+ == [{"id": "t1", "type": "human", "content": "question"}]
197
+ )
106
198
 
107
199
 
108
200
  async def test_end_with_no_done_turns_commits_nothing():
@@ -112,7 +204,7 @@ async def test_end_with_no_done_turns_commits_nothing():
112
204
  bench.events().turn_started("t1", "assistant")
113
205
  await drv.command("voice/end")
114
206
  assert bench.commits == []
115
- await drv.wait(lambda s: voice_of(s) is None)
207
+ await drv.wait(lambda s: voice_of(s) is None and messages_of(s) == [])
116
208
 
117
209
 
118
210
  async def test_end_from_other_client_rejects_not_voice_owner():
@@ -47,13 +47,22 @@ class FakeVoiceProvider:
47
47
  self.closed = True
48
48
 
49
49
 
50
+ def wire_entry(turn: dict[str, Any]) -> dict[str, Any]:
51
+ return {
52
+ "id": turn["id"],
53
+ "type": "human" if turn["role"] == "user" else "ai",
54
+ "content": turn["text"],
55
+ }
56
+
57
+
50
58
  @dataclass
51
59
  class Bench:
52
- """The test's host seams: the seed transcript in, committed turns out."""
60
+ """The test's host seams: the seed transcript in, live and committed turns out."""
53
61
 
54
62
  provider: FakeVoiceProvider = field(default_factory=FakeVoiceProvider)
55
63
  transcript: list[dict[str, Any]] = field(default_factory=list)
56
64
  commits: list[list[dict[str, Any]]] = field(default_factory=list)
65
+ commit_failures: list[Exception] = field(default_factory=list)
57
66
  contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
58
67
 
59
68
  def events(self) -> VoiceManager.Events:
@@ -65,13 +74,35 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
65
74
  class Host(Statewire):
66
75
  async def lifespan(self):
67
76
  self.state = {"messages": []}
77
+ self._durable: list[dict[str, Any]] = []
78
+ self._ephemeral: dict[str, dict[str, Any]] = {}
68
79
  yield
69
80
 
81
+ # mirrors the DeepagentsMessages projection: durable transcript, ephemeral tail
82
+ def _project_messages(self):
83
+ durable_ids = {m["id"] for m in self._durable}
84
+ self.state["messages"] = [
85
+ *self._durable,
86
+ *(e for i, e in self._ephemeral.items() if i not in durable_ids),
87
+ ]
88
+
70
89
  async def get_voice_seed(self):
71
90
  return [dict(message) for message in bench.transcript]
72
91
 
73
92
  async def commit_voice_turns(self, turns):
93
+ if bench.commit_failures:
94
+ raise bench.commit_failures.pop(0)
74
95
  bench.commits.append(turns)
96
+ self._durable.extend(wire_entry(turn) for turn in turns)
97
+ self._project_messages()
98
+
99
+ def upsert_ephemeral_message(self, message):
100
+ self._ephemeral[message["id"]] = wire_entry(message)
101
+ self._project_messages()
102
+
103
+ def drop_ephemeral_message(self, message_id):
104
+ del self._ephemeral[message_id]
105
+ self._project_messages()
75
106
 
76
107
  @command("voice/start")
77
108
  async def voice_start(self, params, *, ctx):