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.
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/deepagents.py +25 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/voice.py +67 -31
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_deepagents_messages.py +51 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_voice.py +103 -11
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/voice_helpers.py +32 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/.gitignore +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/README.md +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.28.0}/tests/test_stopping_window.py +0 -0
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
``
|
|
17
|
-
through ``Host.commit_voice_turns``
|
|
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
|
|
62
|
-
buffer
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
214
|
-
self.
|
|
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)
|
|
220
|
-
|
|
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.
|
|
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 (
|
|
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,
|
|
2
|
-
|
|
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
|
|
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: (
|
|
83
|
-
== [{"id": "t1", "
|
|
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: (
|
|
88
|
-
== [{"id": "t1", "
|
|
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
|
|
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(
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|