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.
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/deepagents.py +25 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/voice.py +67 -34
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_deepagents_messages.py +51 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_voice.py +104 -16
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/voice_helpers.py +32 -1
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/.gitignore +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/README.md +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.27.0 → harness_sdk_python-0.29.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
|
|
13
|
-
session is bound to the issuing client's connection: ``voice/end`` from the
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
``
|
|
17
|
-
through ``Host.commit_voice_turns``
|
|
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
|
|
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
|
-
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
214
|
-
self.
|
|
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)
|
|
220
|
-
|
|
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.
|
|
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 (
|
|
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,
|
|
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):
|
|
@@ -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
|
|
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: (
|
|
83
|
-
== [{"id": "t1", "
|
|
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: (
|
|
88
|
-
== [{"id": "t1", "
|
|
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
|
|
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(
|
|
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):
|
|
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
|