harness-sdk-python 0.27.0__tar.gz → 0.29.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.29.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/deepagents.py +25 -0
  4. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/voice.py +67 -34
  5. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_deepagents_messages.py +51 -0
  6. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_voice.py +104 -16
  7. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/voice_helpers.py +32 -1
  8. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/.gitignore +0 -0
  9. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/README.md +0 -0
  10. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/checkpoints.py +0 -0
  14. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/run_manager.py +0 -0
  17. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/run_helpers.py +0 -0
  18. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_admit_steer.py +0 -0
  19. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_applied_visibility.py +0 -0
  20. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_batches.py +0 -0
  21. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_checkpoints.py +0 -0
  23. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.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.29.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.29.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}`` — the SDP answer rides only the command's result payload
13
+ ``{answer}``. The session is 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,19 @@ 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
- answer = observable(None)
69
- turns = observable([])
70
72
 
71
73
  state = observable(
72
74
  {
73
75
  "clientId": clientId,
74
76
  "status": status,
75
- "answer": answer,
76
- "turns": turns,
77
77
  }
78
78
  )
79
79
 
@@ -81,6 +81,7 @@ class VoiceManager(Tray):
81
81
  super().__init__(**kwargs)
82
82
  self._provider: "VoiceProvider | None" = None
83
83
  self._turns: list[dict[str, Any]] = []
84
+ self._flushing = asyncio.Lock()
84
85
  self._ended = False
85
86
  self._watcher: "asyncio.Task[Any] | None" = None
86
87
 
@@ -121,7 +122,7 @@ class VoiceManager(Tray):
121
122
  def closed(self, reason: str) -> None: ...
122
123
 
123
124
  class Host(Protocol):
124
- """The host seam: the thread transcript in, committed turns out."""
125
+ """The host seam: the thread transcript in, live and committed turns out."""
125
126
 
126
127
  async def get_voice_seed(self) -> "list[VoiceManager.SeedMessage]":
127
128
  """The transcript as ``{role, text}`` seed messages, tool/system noise skipped."""
@@ -131,6 +132,14 @@ class VoiceManager(Tray):
131
132
  """Write completed ``{id, role, text}`` turns to the thread's durable transcript."""
132
133
  ...
133
134
 
135
+ def upsert_ephemeral_message(self, message: dict[str, Any]) -> None:
136
+ """Render an in-flight ``{id, role, text}`` turn in ``state["messages"]``; same id updates in place."""
137
+ ...
138
+
139
+ def drop_ephemeral_message(self, message_id: str) -> None:
140
+ """Remove an in-flight turn from the ``state["messages"]`` projection."""
141
+ ...
142
+
134
143
  # ─── Session lifecycle ──────────────────────────────────
135
144
 
136
145
  async def _begin(
@@ -150,7 +159,6 @@ class VoiceManager(Tray):
150
159
  self.delete()
151
160
  raise _reject("voice-unavailable", f"voice session failed to start: {exc}")
152
161
  self._provider = provider
153
- self.answer.value = answer
154
162
  self.status.value = "live"
155
163
  applied()
156
164
  self._watcher = self._spawn(self._watch(caller))
@@ -180,19 +188,12 @@ class VoiceManager(Tray):
180
188
  await provider.close()
181
189
  except Exception:
182
190
  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 = []
191
+ await self._flush()
192
+ turns, self._turns = self._turns, []
193
+ for turn in turns:
194
+ self._host.drop_ephemeral_message(turn["id"])
190
195
  self.status.value = "closed"
191
- try:
192
- if done:
193
- await self.handler.__self__.commit_voice_turns(done)
194
- finally:
195
- self.delete()
196
+ self.delete()
196
197
 
197
198
  # ─── Events sink ────────────────────────────────────────
198
199
 
@@ -202,22 +203,48 @@ class VoiceManager(Tray):
202
203
  return turn
203
204
  raise ValueError(f"turn {turn_id!r} was never started")
204
205
 
205
- def _project_turns(self) -> None:
206
- self.turns.value = [dict(turn) for turn in self._turns]
206
+ @property
207
+ def _host(self) -> Any:
208
+ return self.handler.__self__
209
+
210
+ def _render(self, turn: dict[str, Any]) -> None:
211
+ self._host.upsert_ephemeral_message(
212
+ {"id": turn["id"], "role": turn["role"], "text": turn["text"]}
213
+ )
214
+
215
+ async def _flush(self) -> None:
216
+ # the lock serializes commits in done-order across turn-done and end
217
+ async with self._flushing:
218
+ done = [t for t in self._turns if t["done"]]
219
+ if not done:
220
+ return
221
+ try:
222
+ await self._host.commit_voice_turns(
223
+ [{"id": t["id"], "role": t["role"], "text": t["text"]} for t in done]
224
+ )
225
+ except Exception:
226
+ logger.exception("voice session %r turn commit failed", self.id)
227
+ return
228
+ committed = {t["id"] for t in done}
229
+ self._turns = [t for t in self._turns if t["id"] not in committed]
230
+ for turn_id in committed:
231
+ self._host.drop_ephemeral_message(turn_id)
207
232
 
208
233
  def turn_started(self, turn_id: str, role: str) -> None:
209
234
  if self._ended:
210
235
  return
211
236
  if any(turn["id"] == turn_id for turn in self._turns):
212
237
  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()
238
+ turn = {"id": turn_id, "role": role, "text": "", "done": False}
239
+ self._turns.append(turn)
240
+ self._render(turn)
215
241
 
216
242
  def turn_delta(self, turn_id: str, text: str) -> None:
217
243
  if self._ended:
218
244
  return
219
- self._turn(turn_id)["text"] += text
220
- self._project_turns()
245
+ turn = self._turn(turn_id)
246
+ turn["text"] += text
247
+ self._render(turn)
221
248
 
222
249
  def turn_done(self, turn_id: str, text: str) -> None:
223
250
  if self._ended:
@@ -225,7 +252,8 @@ class VoiceManager(Tray):
225
252
  turn = self._turn(turn_id)
226
253
  turn["text"] = text
227
254
  turn["done"] = True
228
- self._project_turns()
255
+ self._render(turn)
256
+ self._spawn(self._flush())
229
257
 
230
258
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
231
259
  logger.info("voice session %r tool request ignored: %r", self.id, request)
@@ -272,7 +300,12 @@ class VoiceManagerNamespace(TrayMapNamespace):
272
300
 
273
301
  def __init__(self, api: Any, spec: TrayDescriptor) -> None:
274
302
  super().__init__(api, spec)
275
- for name in ("get_voice_seed", "commit_voice_turns"):
303
+ for name in (
304
+ "get_voice_seed",
305
+ "commit_voice_turns",
306
+ "upsert_ephemeral_message",
307
+ "drop_ephemeral_message",
308
+ ):
276
309
  if not callable(getattr(api, name, None)):
277
310
  raise TypeError(
278
311
  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):
@@ -26,12 +39,7 @@ async def test_start_seeds_provider_and_projects_session():
26
39
  assert bench.provider.calls == [("start", OFFER, bench.transcript)]
27
40
  await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
28
41
  voice = voice_of(drv.replica)
29
- assert voice == {
30
- "clientId": drv.client_id,
31
- "status": "live",
32
- "answer": ANSWER,
33
- "turns": [],
34
- }
42
+ assert voice == {"clientId": drv.client_id, "status": "live"}
35
43
  assert bench.contexts[0].client_id == drv.client_id
36
44
 
37
45
 
@@ -70,7 +78,7 @@ async def test_provider_failure_rejects_voice_unavailable():
70
78
  assert rsp["type"] == "accepted", rsp
71
79
 
72
80
 
73
- async def test_turn_events_project_into_state():
81
+ async def test_turn_streams_into_messages_ephemerally():
74
82
  bench = Bench()
75
83
  async with voice_host(bench) as (drv, _app):
76
84
  await drv.command("voice/start", {"offer": OFFER})
@@ -79,30 +87,110 @@ async def test_turn_events_project_into_state():
79
87
  events.turn_delta("t1", "hello ")
80
88
  events.turn_delta("t1", "there")
81
89
  await drv.wait(
82
- lambda s: (voice_of(s) or {}).get("turns")
83
- == [{"id": "t1", "role": "user", "text": "hello there", "done": False}]
90
+ lambda s: messages_of(s)
91
+ == [{"id": "t1", "type": "human", "content": "hello there"}]
84
92
  )
93
+ assert bench.commits == []
94
+
95
+
96
+ async def test_turn_done_commits_promptly_under_the_same_id():
97
+ bench = Bench()
98
+ async with voice_host(bench) as (drv, _app):
99
+ await drv.command("voice/start", {"offer": OFFER})
100
+ events = bench.events()
101
+ events.turn_started("t1", "user")
85
102
  events.turn_done("t1", "hello there!")
103
+ await until(
104
+ lambda: bench.commits
105
+ == [[{"id": "t1", "role": "user", "text": "hello there!"}]]
106
+ )
107
+ await drv.wait(
108
+ lambda s: messages_of(s)
109
+ == [{"id": "t1", "type": "human", "content": "hello there!"}]
110
+ )
111
+ # the durable entry replaced the ephemeral one: end recommits nothing
112
+ await drv.command("voice/end")
113
+ assert len(bench.commits) == 1
114
+
115
+
116
+ async def test_turns_commit_in_done_order():
117
+ bench = Bench()
118
+ async with voice_host(bench) as (drv, _app):
119
+ await drv.command("voice/start", {"offer": OFFER})
120
+ events = bench.events()
121
+ events.turn_started("t1", "user")
122
+ events.turn_done("t1", "question")
123
+ await until(lambda: len(bench.commits) == 1)
124
+ events.turn_started("t2", "assistant")
125
+ events.turn_done("t2", "answer")
126
+ await until(lambda: len(bench.commits) == 2)
127
+ assert bench.commits == [
128
+ [{"id": "t1", "role": "user", "text": "question"}],
129
+ [{"id": "t2", "role": "assistant", "text": "answer"}],
130
+ ]
131
+
132
+
133
+ async def test_commit_failure_keeps_the_turn_until_the_next_flush():
134
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
135
+ async with voice_host(bench) as (drv, _app):
136
+ await drv.command("voice/start", {"offer": OFFER})
137
+ events = bench.events()
138
+ events.turn_started("t1", "user")
139
+ events.turn_done("t1", "question")
140
+ await until(lambda: bench.commit_failures == [])
141
+ assert bench.commits == []
86
142
  await drv.wait(
87
- lambda s: (voice_of(s) or {}).get("turns")
88
- == [{"id": "t1", "role": "user", "text": "hello there!", "done": True}]
143
+ lambda s: messages_of(s)
144
+ == [{"id": "t1", "type": "human", "content": "question"}]
145
+ )
146
+ # session end retries the kept turn
147
+ await drv.command("voice/end")
148
+ assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
149
+ await drv.wait(
150
+ lambda s: voice_of(s) is None
151
+ and messages_of(s)
152
+ == [{"id": "t1", "type": "human", "content": "question"}]
89
153
  )
90
154
 
91
155
 
92
- async def test_end_commits_done_turns_and_drops_in_progress():
156
+ async def test_commit_failure_retries_in_order_with_the_next_turn():
157
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
158
+ async with voice_host(bench) as (drv, _app):
159
+ await drv.command("voice/start", {"offer": OFFER})
160
+ events = bench.events()
161
+ events.turn_started("t1", "user")
162
+ events.turn_done("t1", "question")
163
+ await until(lambda: bench.commit_failures == [])
164
+ events.turn_started("t2", "assistant")
165
+ events.turn_done("t2", "answer")
166
+ await until(lambda: bench.commits != [])
167
+ assert bench.commits == [
168
+ [
169
+ {"id": "t1", "role": "user", "text": "question"},
170
+ {"id": "t2", "role": "assistant", "text": "answer"},
171
+ ]
172
+ ]
173
+
174
+
175
+ async def test_end_drops_in_progress_turns():
93
176
  bench = Bench()
94
177
  async with voice_host(bench) as (drv, _app):
95
178
  await drv.command("voice/start", {"offer": OFFER})
96
179
  events = bench.events()
97
180
  events.turn_started("t1", "user")
98
181
  events.turn_done("t1", "question")
182
+ await until(lambda: len(bench.commits) == 1)
99
183
  events.turn_started("t2", "assistant")
100
184
  events.turn_delta("t2", "half an ans")
101
185
  rsp = await drv.command("voice/end")
102
186
  assert rsp["type"] == "accepted", rsp
103
187
  assert bench.provider.closed
104
188
  assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
105
- await drv.wait(lambda s: voice_of(s) is None)
189
+ await drv.wait(
190
+ lambda s: voice_of(s) is None
191
+ and messages_of(s)
192
+ == [{"id": "t1", "type": "human", "content": "question"}]
193
+ )
106
194
 
107
195
 
108
196
  async def test_end_with_no_done_turns_commits_nothing():
@@ -112,7 +200,7 @@ async def test_end_with_no_done_turns_commits_nothing():
112
200
  bench.events().turn_started("t1", "assistant")
113
201
  await drv.command("voice/end")
114
202
  assert bench.commits == []
115
- await drv.wait(lambda s: voice_of(s) is None)
203
+ await drv.wait(lambda s: voice_of(s) is None and messages_of(s) == [])
116
204
 
117
205
 
118
206
  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):