harness-sdk-python 0.26.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.26.0 → harness_sdk_python-0.28.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/__init__.py +14 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/deepagents.py +75 -0
- harness_sdk_python-0.28.0/src/harness_sdk/voice.py +418 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_deepagents_messages.py +51 -0
- harness_sdk_python-0.28.0/tests/test_voice.py +284 -0
- harness_sdk_python-0.28.0/tests/voice_helpers.py +176 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/.gitignore +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/README.md +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.26.0 → harness_sdk_python-0.28.0}/tests/test_stopping_window.py +0 -0
|
@@ -14,6 +14,14 @@ from .checkpoints import (
|
|
|
14
14
|
from .deepagents import DeepagentsMessages
|
|
15
15
|
from .linear_thread import linear_thread
|
|
16
16
|
from .run_manager import RunManager, RunManagerDescriptor, run_manager
|
|
17
|
+
from .voice import (
|
|
18
|
+
VoiceManager,
|
|
19
|
+
VoiceManagerDescriptor,
|
|
20
|
+
VoiceProvider,
|
|
21
|
+
end_voice,
|
|
22
|
+
start_voice,
|
|
23
|
+
voice_manager,
|
|
24
|
+
)
|
|
17
25
|
|
|
18
26
|
__all__ = [
|
|
19
27
|
"CheckpointIndex",
|
|
@@ -26,10 +34,16 @@ __all__ = [
|
|
|
26
34
|
"RunManager",
|
|
27
35
|
"RunManagerDescriptor",
|
|
28
36
|
"Snapshot",
|
|
37
|
+
"VoiceManager",
|
|
38
|
+
"VoiceManagerDescriptor",
|
|
39
|
+
"VoiceProvider",
|
|
29
40
|
"checkpoint_thread",
|
|
41
|
+
"end_voice",
|
|
30
42
|
"langgraph_checkpoints",
|
|
31
43
|
"langgraph_snapshot",
|
|
32
44
|
"langgraph_switch_values",
|
|
33
45
|
"linear_thread",
|
|
34
46
|
"run_manager",
|
|
47
|
+
"start_voice",
|
|
48
|
+
"voice_manager",
|
|
35
49
|
]
|
|
@@ -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
|
|
@@ -150,6 +151,56 @@ class DeepagentsMessages:
|
|
|
150
151
|
"""Append harness user messages to the wire state as human entries, deduplicated by id; returns the langgraph turn entries."""
|
|
151
152
|
return [self._append_user(message) for message in messages]
|
|
152
153
|
|
|
154
|
+
async def append_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
155
|
+
"""Append ``{id, role, text}`` entries as a new checkpoint on the current tip and restamp; the voice commit path."""
|
|
156
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
157
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
158
|
+
from langgraph.checkpoint.base.id import uuid6
|
|
159
|
+
|
|
160
|
+
if not messages:
|
|
161
|
+
raise ValueError("messages must be non-empty")
|
|
162
|
+
for message in messages:
|
|
163
|
+
if message["role"] not in ("user", "assistant"):
|
|
164
|
+
raise ValueError(f"unsupported message role: {message['role']!r}")
|
|
165
|
+
entries = [
|
|
166
|
+
(HumanMessage if m["role"] == "user" else AIMessage)(
|
|
167
|
+
id=m["id"], content=m["text"]
|
|
168
|
+
)
|
|
169
|
+
for m in messages
|
|
170
|
+
]
|
|
171
|
+
parent = {"configurable": {"thread_id": self._thread_id, "checkpoint_ns": ""}}
|
|
172
|
+
saved = await self._checkpointer.aget_tuple(parent)
|
|
173
|
+
if saved is None:
|
|
174
|
+
checkpoint = empty_checkpoint()
|
|
175
|
+
step = -1
|
|
176
|
+
else:
|
|
177
|
+
checkpoint = {**saved.checkpoint}
|
|
178
|
+
step = (saved.metadata or {}).get("step", -1)
|
|
179
|
+
parent["configurable"]["checkpoint_id"] = saved.checkpoint["id"]
|
|
180
|
+
version = self._checkpointer.get_next_version(
|
|
181
|
+
checkpoint["channel_versions"].get("messages"), None
|
|
182
|
+
)
|
|
183
|
+
checkpoint = {
|
|
184
|
+
**checkpoint,
|
|
185
|
+
"id": str(uuid6(clock_seq=-2)),
|
|
186
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
187
|
+
"channel_values": {
|
|
188
|
+
**checkpoint["channel_values"],
|
|
189
|
+
"messages": [
|
|
190
|
+
*checkpoint["channel_values"].get("messages", []),
|
|
191
|
+
*entries,
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
"channel_versions": {
|
|
195
|
+
**checkpoint["channel_versions"],
|
|
196
|
+
"messages": version,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
metadata = {"source": "update", "step": step + 1, "parents": {}}
|
|
200
|
+
await self._checkpointer.aput(parent, checkpoint, metadata, {"messages": version})
|
|
201
|
+
await self._thread.ingest_latest()
|
|
202
|
+
self._restamp()
|
|
203
|
+
|
|
153
204
|
async def switch_branch(self, branch_id: int) -> None:
|
|
154
205
|
"""Switch to ``branch_id``: its tip becomes the checkpointer's latest checkpoint and the wire state rebaselines."""
|
|
155
206
|
transcript, lineage = await self._thread.switch_state(branch_id)
|
|
@@ -191,6 +242,26 @@ class DeepagentsMessages:
|
|
|
191
242
|
)
|
|
192
243
|
return snapshot
|
|
193
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
|
+
|
|
194
265
|
def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
195
266
|
entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
|
|
196
267
|
if not any(m["id"] == entry["id"] for m in plain(self._state["messages"])):
|
|
@@ -207,4 +278,8 @@ class DeepagentsMessages:
|
|
|
207
278
|
for message in self._thread.transcript:
|
|
208
279
|
branch, siblings = self._thread.sibling_info(message["id"])
|
|
209
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
|
+
)
|
|
210
285
|
self._replace(stamped)
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""VoiceManager: the harness-sdk voice subsystem, one tray per live session.
|
|
2
|
+
|
|
3
|
+
``@voice_manager()`` on a Statewire host method declares the subsystem: the
|
|
4
|
+
decorated method is the provider factory (``def voice(self, ctx) ->
|
|
5
|
+
VoiceProvider``), ``self.<name>`` is the command surface, and each session
|
|
6
|
+
lives in its own ephemeral tray, minted by ``voice/start`` and deleted when
|
|
7
|
+
the session ends. The host implements ``VoiceManager.Host``: the seed read
|
|
8
|
+
and the turn commit both stay host-owned.
|
|
9
|
+
|
|
10
|
+
``voice/start {offer}`` seeds the provider from the thread transcript (at
|
|
11
|
+
most 128 messages, tail taken) and projects ``state["voice"] = {clientId,
|
|
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.
|
|
21
|
+
|
|
22
|
+
Rejections: ``voice-active`` (a session is already live), ``no-voice``
|
|
23
|
+
(no voice manager declared, or ``voice/end`` with no session),
|
|
24
|
+
``not-voice-owner`` (``voice/end`` from another client),
|
|
25
|
+
``voice-unavailable`` (the factory or ``provider.start`` raised).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import functools
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any, Callable, Literal, Protocol, TypedDict
|
|
34
|
+
from uuid import uuid4
|
|
35
|
+
|
|
36
|
+
from pinned.trays import Tray, TrayDescriptor, TrayMapNamespace
|
|
37
|
+
from statepatch import observable
|
|
38
|
+
from statewire import StatewireReject
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_SEED_LIMIT = 128
|
|
43
|
+
_SEED_ROLES = ("user", "assistant", "system")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reject(reason: str, message: str) -> StatewireReject:
|
|
47
|
+
return StatewireReject(message, payload={"reason": reason})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
|
|
51
|
+
if not isinstance(seed, list):
|
|
52
|
+
raise TypeError(f"get_voice_seed must return a list, got {seed!r}")
|
|
53
|
+
for message in seed:
|
|
54
|
+
if (
|
|
55
|
+
not isinstance(message, dict)
|
|
56
|
+
or message.get("role") not in _SEED_ROLES
|
|
57
|
+
or not isinstance(message.get("text"), str)
|
|
58
|
+
):
|
|
59
|
+
raise TypeError(f"seed messages must be {{role, text}}, got {message!r}")
|
|
60
|
+
return seed[-_SEED_LIMIT:]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VoiceManager(Tray):
|
|
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."""
|
|
67
|
+
|
|
68
|
+
connection_poll = 0.5
|
|
69
|
+
|
|
70
|
+
clientId = observable(None)
|
|
71
|
+
status = observable("connecting")
|
|
72
|
+
answer = observable(None)
|
|
73
|
+
|
|
74
|
+
state = observable(
|
|
75
|
+
{
|
|
76
|
+
"clientId": clientId,
|
|
77
|
+
"status": status,
|
|
78
|
+
"answer": answer,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self._provider: "VoiceProvider | None" = None
|
|
85
|
+
self._turns: list[dict[str, Any]] = []
|
|
86
|
+
self._flushing = asyncio.Lock()
|
|
87
|
+
self._ended = False
|
|
88
|
+
self._watcher: "asyncio.Task[Any] | None" = None
|
|
89
|
+
|
|
90
|
+
def items(self):
|
|
91
|
+
yield from ()
|
|
92
|
+
|
|
93
|
+
class SeedMessage(TypedDict):
|
|
94
|
+
"""One transcript message handed to the provider as session history."""
|
|
95
|
+
|
|
96
|
+
role: Literal["user", "assistant", "system"]
|
|
97
|
+
text: str
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ToolRequest:
|
|
101
|
+
call_id: str
|
|
102
|
+
name: str
|
|
103
|
+
arguments: str
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class SessionContext:
|
|
107
|
+
"""The provider factory's per-session context."""
|
|
108
|
+
|
|
109
|
+
client_id: str
|
|
110
|
+
|
|
111
|
+
class Events(Protocol):
|
|
112
|
+
"""The sink a provider drives; the subsystem hands it to ``start``."""
|
|
113
|
+
|
|
114
|
+
def turn_started(self, turn_id: str, role: Literal["user", "assistant"]) -> None: ...
|
|
115
|
+
|
|
116
|
+
def turn_delta(self, turn_id: str, text: str) -> None: ...
|
|
117
|
+
|
|
118
|
+
def turn_done(self, turn_id: str, text: str) -> None: ...
|
|
119
|
+
|
|
120
|
+
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None: ...
|
|
121
|
+
|
|
122
|
+
def rollover_approaching(self, rollover_id: str, deadline: float) -> None: ...
|
|
123
|
+
|
|
124
|
+
def closed(self, reason: str) -> None: ...
|
|
125
|
+
|
|
126
|
+
class Host(Protocol):
|
|
127
|
+
"""The host seam: the thread transcript in, live and committed turns out."""
|
|
128
|
+
|
|
129
|
+
async def get_voice_seed(self) -> "list[VoiceManager.SeedMessage]":
|
|
130
|
+
"""The transcript as ``{role, text}`` seed messages, tool/system noise skipped."""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
async def commit_voice_turns(self, turns: list[dict[str, Any]]) -> None:
|
|
134
|
+
"""Write completed ``{id, role, text}`` turns to the thread's durable transcript."""
|
|
135
|
+
...
|
|
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
|
+
|
|
145
|
+
# ─── Session lifecycle ──────────────────────────────────
|
|
146
|
+
|
|
147
|
+
async def _begin(
|
|
148
|
+
self, offer: str, seed: "list[VoiceManager.SeedMessage]", caller: Any,
|
|
149
|
+
applied: Callable[[], None],
|
|
150
|
+
) -> Any:
|
|
151
|
+
self.clientId.value = caller.client_id
|
|
152
|
+
try:
|
|
153
|
+
provider = await self.handler(
|
|
154
|
+
VoiceManager.SessionContext(client_id=caller.client_id)
|
|
155
|
+
)
|
|
156
|
+
answer = await provider.start(offer, seed=seed, events=self)
|
|
157
|
+
if not isinstance(answer, str) or answer == "":
|
|
158
|
+
raise TypeError(f"provider.start must return an SDP answer, got {answer!r}")
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
self._ended = True
|
|
161
|
+
self.delete()
|
|
162
|
+
raise _reject("voice-unavailable", f"voice session failed to start: {exc}")
|
|
163
|
+
self._provider = provider
|
|
164
|
+
self.answer.value = answer
|
|
165
|
+
self.status.value = "live"
|
|
166
|
+
applied()
|
|
167
|
+
self._watcher = self._spawn(self._watch(caller))
|
|
168
|
+
return {"answer": answer}
|
|
169
|
+
|
|
170
|
+
async def _watch(self, caller: Any) -> None:
|
|
171
|
+
while caller.is_connected:
|
|
172
|
+
await asyncio.sleep(self.connection_poll)
|
|
173
|
+
await self._background_end("connection-drop")
|
|
174
|
+
|
|
175
|
+
async def _background_end(self, reason: str) -> None:
|
|
176
|
+
try:
|
|
177
|
+
await self._end(reason)
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.exception("voice session %r failed to end cleanly", self.id)
|
|
180
|
+
|
|
181
|
+
async def _end(self, reason: str) -> None:
|
|
182
|
+
if self._ended:
|
|
183
|
+
return
|
|
184
|
+
self._ended = True
|
|
185
|
+
watcher, self._watcher = self._watcher, None
|
|
186
|
+
if watcher is not None and watcher is not asyncio.current_task():
|
|
187
|
+
watcher.cancel()
|
|
188
|
+
provider, self._provider = self._provider, None
|
|
189
|
+
if provider is not None:
|
|
190
|
+
try:
|
|
191
|
+
await provider.close()
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.exception("voice provider close failed for session %r", self.id)
|
|
194
|
+
await self._flush()
|
|
195
|
+
turns, self._turns = self._turns, []
|
|
196
|
+
for turn in turns:
|
|
197
|
+
self._host.drop_ephemeral_message(turn["id"])
|
|
198
|
+
self.status.value = "closed"
|
|
199
|
+
self.delete()
|
|
200
|
+
|
|
201
|
+
# ─── Events sink ────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def _turn(self, turn_id: str) -> dict[str, Any]:
|
|
204
|
+
for turn in self._turns:
|
|
205
|
+
if turn["id"] == turn_id:
|
|
206
|
+
return turn
|
|
207
|
+
raise ValueError(f"turn {turn_id!r} was never started")
|
|
208
|
+
|
|
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)
|
|
235
|
+
|
|
236
|
+
def turn_started(self, turn_id: str, role: str) -> None:
|
|
237
|
+
if self._ended:
|
|
238
|
+
return
|
|
239
|
+
if any(turn["id"] == turn_id for turn in self._turns):
|
|
240
|
+
raise ValueError(f"turn {turn_id!r} already started")
|
|
241
|
+
turn = {"id": turn_id, "role": role, "text": "", "done": False}
|
|
242
|
+
self._turns.append(turn)
|
|
243
|
+
self._render(turn)
|
|
244
|
+
|
|
245
|
+
def turn_delta(self, turn_id: str, text: str) -> None:
|
|
246
|
+
if self._ended:
|
|
247
|
+
return
|
|
248
|
+
turn = self._turn(turn_id)
|
|
249
|
+
turn["text"] += text
|
|
250
|
+
self._render(turn)
|
|
251
|
+
|
|
252
|
+
def turn_done(self, turn_id: str, text: str) -> None:
|
|
253
|
+
if self._ended:
|
|
254
|
+
return
|
|
255
|
+
turn = self._turn(turn_id)
|
|
256
|
+
turn["text"] = text
|
|
257
|
+
turn["done"] = True
|
|
258
|
+
self._render(turn)
|
|
259
|
+
self._spawn(self._flush())
|
|
260
|
+
|
|
261
|
+
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
|
|
262
|
+
logger.info("voice session %r tool request ignored: %r", self.id, request)
|
|
263
|
+
|
|
264
|
+
def rollover_approaching(self, rollover_id: str, deadline: float) -> None:
|
|
265
|
+
logger.info(
|
|
266
|
+
"voice session %r rollover %r ignored (deadline %s)",
|
|
267
|
+
self.id, rollover_id, deadline,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def closed(self, reason: str) -> None:
|
|
271
|
+
if self._ended:
|
|
272
|
+
return
|
|
273
|
+
self._spawn(self._background_end(reason))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class VoiceProvider(Protocol):
|
|
277
|
+
"""The vendor seam: one live voice session's transport and control plane."""
|
|
278
|
+
|
|
279
|
+
async def start(
|
|
280
|
+
self, offer: str, *,
|
|
281
|
+
seed: "list[VoiceManager.SeedMessage]",
|
|
282
|
+
events: "VoiceManager.Events",
|
|
283
|
+
) -> str:
|
|
284
|
+
"""Open the session from an SDP offer and return the SDP answer; events flow into the sink."""
|
|
285
|
+
...
|
|
286
|
+
|
|
287
|
+
async def append_context(
|
|
288
|
+
self, text: str, *, channel: Literal["speakable", "commentary", "developer"]
|
|
289
|
+
) -> None: ...
|
|
290
|
+
|
|
291
|
+
async def return_tool_result(self, call_id: str, output: str) -> None: ...
|
|
292
|
+
|
|
293
|
+
async def select_rollover(
|
|
294
|
+
self, rollover_id: str, seed: "list[VoiceManager.SeedMessage]"
|
|
295
|
+
) -> None: ...
|
|
296
|
+
|
|
297
|
+
async def close(self) -> None: ...
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class VoiceManagerNamespace(TrayMapNamespace):
|
|
301
|
+
"""The subsystem's command surface: at most one live session, its tray
|
|
302
|
+
mounted as ``state["voice"]``."""
|
|
303
|
+
|
|
304
|
+
def __init__(self, api: Any, spec: TrayDescriptor) -> None:
|
|
305
|
+
super().__init__(api, spec)
|
|
306
|
+
for name in (
|
|
307
|
+
"get_voice_seed",
|
|
308
|
+
"commit_voice_turns",
|
|
309
|
+
"upsert_ephemeral_message",
|
|
310
|
+
"drop_ephemeral_message",
|
|
311
|
+
):
|
|
312
|
+
if not callable(getattr(api, name, None)):
|
|
313
|
+
raise TypeError(
|
|
314
|
+
f"{type(api).__name__} must implement {name} "
|
|
315
|
+
"(VoiceManager.Host) to host @voice_manager"
|
|
316
|
+
)
|
|
317
|
+
self._session: VoiceManager | None = None
|
|
318
|
+
self._starting = False
|
|
319
|
+
api.state["voice"] = None
|
|
320
|
+
|
|
321
|
+
def live(self) -> VoiceManager | None:
|
|
322
|
+
"""The live session's tray; None at rest."""
|
|
323
|
+
session = self._session
|
|
324
|
+
return session if session is not None and not session._ended else None
|
|
325
|
+
|
|
326
|
+
def _mount(self, tray: VoiceManager) -> None:
|
|
327
|
+
if tray.storage._restored:
|
|
328
|
+
return # a crash's leftover row: the pump finishes its delete unmounted
|
|
329
|
+
self._session = tray
|
|
330
|
+
self._api.state["voice"] = tray.state
|
|
331
|
+
|
|
332
|
+
def _unmount(self, tray: VoiceManager) -> None:
|
|
333
|
+
if self._session is tray:
|
|
334
|
+
self._session = None
|
|
335
|
+
self._api.state["voice"] = None
|
|
336
|
+
|
|
337
|
+
async def start(self, params: Any, *, ctx: Any) -> Any:
|
|
338
|
+
if not isinstance(params, dict):
|
|
339
|
+
raise _reject("invalid-message", "params must be an object")
|
|
340
|
+
offer = params.get("offer")
|
|
341
|
+
if not isinstance(offer, str) or offer == "":
|
|
342
|
+
raise _reject("invalid-message", "offer must be a non-empty SDP string")
|
|
343
|
+
if self._starting or self.live() is not None:
|
|
344
|
+
raise _reject("voice-active", "a voice session is already live")
|
|
345
|
+
self._starting = True
|
|
346
|
+
try:
|
|
347
|
+
seed = _capped_seed(await self._api.get_voice_seed())
|
|
348
|
+
tray = await self.get_or_create(uuid4().hex)
|
|
349
|
+
assert isinstance(tray, VoiceManager)
|
|
350
|
+
return await tray._begin(offer, seed, ctx.caller, ctx.applied)
|
|
351
|
+
finally:
|
|
352
|
+
self._starting = False
|
|
353
|
+
|
|
354
|
+
async def end(self, *, ctx: Any) -> None:
|
|
355
|
+
session = self.live()
|
|
356
|
+
if session is None:
|
|
357
|
+
raise _reject("no-voice", "no voice session is live")
|
|
358
|
+
if session.clientId.value != ctx.caller.client_id:
|
|
359
|
+
raise _reject("not-voice-owner", "voice/end must come from the session owner")
|
|
360
|
+
await session._end("client")
|
|
361
|
+
ctx.applied()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class VoiceManagerDescriptor(TrayDescriptor):
|
|
365
|
+
namespace_cls = VoiceManagerNamespace
|
|
366
|
+
|
|
367
|
+
def __init__(self, handler: Any) -> None:
|
|
368
|
+
super().__init__(VoiceManager, handler, always_open=False)
|
|
369
|
+
|
|
370
|
+
async def mount(api: Any, id: str, tray: VoiceManager):
|
|
371
|
+
ns: VoiceManagerNamespace = getattr(api, self.name)
|
|
372
|
+
ns._mount(tray)
|
|
373
|
+
yield
|
|
374
|
+
ns._unmount(tray)
|
|
375
|
+
|
|
376
|
+
self._on_activate.append(mount)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def voice_manager() -> Callable[[Callable[..., Any]], VoiceManagerDescriptor]:
|
|
380
|
+
"""Declare the voice subsystem on a Statewire host: the decorated method
|
|
381
|
+
is the per-session provider factory and ``self.<name>`` is the command
|
|
382
|
+
surface."""
|
|
383
|
+
|
|
384
|
+
def decorate(fn: Callable[..., Any]) -> VoiceManagerDescriptor:
|
|
385
|
+
if inspect.iscoroutinefunction(fn):
|
|
386
|
+
return VoiceManagerDescriptor(fn)
|
|
387
|
+
|
|
388
|
+
@functools.wraps(fn)
|
|
389
|
+
async def factory(self: Any, ctx: "VoiceManager.SessionContext") -> Any:
|
|
390
|
+
return fn(self, ctx)
|
|
391
|
+
|
|
392
|
+
return VoiceManagerDescriptor(factory)
|
|
393
|
+
|
|
394
|
+
return decorate
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _voice_of(host: Any) -> VoiceManagerNamespace | None:
|
|
398
|
+
for cls in type(host).__mro__:
|
|
399
|
+
for name, member in vars(cls).items():
|
|
400
|
+
if isinstance(member, VoiceManagerDescriptor):
|
|
401
|
+
return getattr(host, name)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def start_voice(host: Any, params: Any, *, ctx: Any) -> Any:
|
|
406
|
+
"""The ``voice/start`` command body: routes to the host's declared voice manager."""
|
|
407
|
+
namespace = _voice_of(host)
|
|
408
|
+
if namespace is None:
|
|
409
|
+
raise _reject("no-voice", "this host declares no voice manager")
|
|
410
|
+
return await namespace.start(params, ctx=ctx)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def end_voice(host: Any, *, ctx: Any) -> None:
|
|
414
|
+
"""The ``voice/end`` command body: routes to the host's declared voice manager."""
|
|
415
|
+
namespace = _voice_of(host)
|
|
416
|
+
if namespace is None:
|
|
417
|
+
raise _reject("no-voice", "this host declares no voice manager")
|
|
418
|
+
return await namespace.end(ctx=ctx)
|
|
@@ -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
|
|
|
@@ -0,0 +1,284 @@
|
|
|
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
|
|
5
|
+
|
|
6
|
+
from run_helpers import _statement, assert_rejected
|
|
7
|
+
from statewire_helpers import post_frame, stream_of
|
|
8
|
+
from voice_helpers import (
|
|
9
|
+
ANSWER,
|
|
10
|
+
OFFER,
|
|
11
|
+
Bench,
|
|
12
|
+
FakeVoiceProvider,
|
|
13
|
+
seed_message,
|
|
14
|
+
side_command,
|
|
15
|
+
voice_host,
|
|
16
|
+
voice_of,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from harness_sdk import VoiceManager
|
|
20
|
+
|
|
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
|
+
|
|
33
|
+
async def test_start_seeds_provider_and_projects_session():
|
|
34
|
+
bench = Bench(transcript=[seed_message(0), seed_message(1)])
|
|
35
|
+
async with voice_host(bench) as (drv, _app):
|
|
36
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
37
|
+
assert rsp["type"] == "accepted", rsp
|
|
38
|
+
assert rsp["payload"] == {"answer": ANSWER}
|
|
39
|
+
assert bench.provider.calls == [("start", OFFER, bench.transcript)]
|
|
40
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
|
|
41
|
+
voice = voice_of(drv.replica)
|
|
42
|
+
assert voice == {
|
|
43
|
+
"clientId": drv.client_id,
|
|
44
|
+
"status": "live",
|
|
45
|
+
"answer": ANSWER,
|
|
46
|
+
}
|
|
47
|
+
assert bench.contexts[0].client_id == drv.client_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_start_while_active_rejects_voice_active():
|
|
51
|
+
bench = Bench()
|
|
52
|
+
async with voice_host(bench) as (drv, _app):
|
|
53
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
54
|
+
assert_rejected(
|
|
55
|
+
await drv.command("voice/start", {"offer": OFFER}), "voice-active"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_undeclared_host_rejects_no_voice():
|
|
60
|
+
bench = Bench()
|
|
61
|
+
async with voice_host(bench, declare=False) as (drv, _app):
|
|
62
|
+
assert_rejected(await drv.command("voice/start", {"offer": OFFER}), "no-voice")
|
|
63
|
+
assert_rejected(await drv.command("voice/end"), "no-voice")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_end_with_no_session_rejects_no_voice():
|
|
67
|
+
bench = Bench()
|
|
68
|
+
async with voice_host(bench) as (drv, _app):
|
|
69
|
+
assert_rejected(await drv.command("voice/end"), "no-voice")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_provider_failure_rejects_voice_unavailable():
|
|
73
|
+
bench = Bench(provider=FakeVoiceProvider(fail=RuntimeError("no api key")))
|
|
74
|
+
async with voice_host(bench) as (drv, _app):
|
|
75
|
+
assert_rejected(
|
|
76
|
+
await drv.command("voice/start", {"offer": OFFER}), "voice-unavailable"
|
|
77
|
+
)
|
|
78
|
+
await drv.wait(lambda s: voice_of(s) is None)
|
|
79
|
+
# the failed session left nothing behind: a fresh start succeeds
|
|
80
|
+
bench.provider.fail = None
|
|
81
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
82
|
+
assert rsp["type"] == "accepted", rsp
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_turn_streams_into_messages_ephemerally():
|
|
86
|
+
bench = Bench()
|
|
87
|
+
async with voice_host(bench) as (drv, _app):
|
|
88
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
89
|
+
events = bench.events()
|
|
90
|
+
events.turn_started("t1", "user")
|
|
91
|
+
events.turn_delta("t1", "hello ")
|
|
92
|
+
events.turn_delta("t1", "there")
|
|
93
|
+
await drv.wait(
|
|
94
|
+
lambda s: messages_of(s)
|
|
95
|
+
== [{"id": "t1", "type": "human", "content": "hello there"}]
|
|
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")
|
|
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 == []
|
|
146
|
+
await drv.wait(
|
|
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"}]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
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():
|
|
180
|
+
bench = Bench()
|
|
181
|
+
async with voice_host(bench) as (drv, _app):
|
|
182
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
183
|
+
events = bench.events()
|
|
184
|
+
events.turn_started("t1", "user")
|
|
185
|
+
events.turn_done("t1", "question")
|
|
186
|
+
await until(lambda: len(bench.commits) == 1)
|
|
187
|
+
events.turn_started("t2", "assistant")
|
|
188
|
+
events.turn_delta("t2", "half an ans")
|
|
189
|
+
rsp = await drv.command("voice/end")
|
|
190
|
+
assert rsp["type"] == "accepted", rsp
|
|
191
|
+
assert bench.provider.closed
|
|
192
|
+
assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
|
|
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
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def test_end_with_no_done_turns_commits_nothing():
|
|
201
|
+
bench = Bench()
|
|
202
|
+
async with voice_host(bench) as (drv, _app):
|
|
203
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
204
|
+
bench.events().turn_started("t1", "assistant")
|
|
205
|
+
await drv.command("voice/end")
|
|
206
|
+
assert bench.commits == []
|
|
207
|
+
await drv.wait(lambda s: voice_of(s) is None and messages_of(s) == [])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_end_from_other_client_rejects_not_voice_owner():
|
|
211
|
+
bench = Bench()
|
|
212
|
+
async with voice_host(bench) as (drv, app):
|
|
213
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
214
|
+
rsp = await side_command(app, drv._client, "voice/end")
|
|
215
|
+
assert_rejected(rsp, "not-voice-owner")
|
|
216
|
+
# the session survived the stranger's end
|
|
217
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def test_provider_closed_event_ends_and_commits():
|
|
221
|
+
bench = Bench()
|
|
222
|
+
async with voice_host(bench) as (drv, _app):
|
|
223
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
224
|
+
events = bench.events()
|
|
225
|
+
events.turn_started("t1", "assistant")
|
|
226
|
+
events.turn_done("t1", "goodbye")
|
|
227
|
+
events.closed("server")
|
|
228
|
+
await drv.wait(lambda s: voice_of(s) is None)
|
|
229
|
+
assert bench.commits == [[{"id": "t1", "role": "assistant", "text": "goodbye"}]]
|
|
230
|
+
assert_rejected(await drv.command("voice/end"), "no-voice")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def test_connection_drop_ends_session(monkeypatch):
|
|
234
|
+
monkeypatch.setattr(VoiceManager, "connection_poll", 0.02)
|
|
235
|
+
bench = Bench()
|
|
236
|
+
async with voice_host(bench) as (drv, app):
|
|
237
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
238
|
+
await stream.next_event()
|
|
239
|
+
response = await post_frame(
|
|
240
|
+
drv._client,
|
|
241
|
+
{"cmd": [_statement("voice/start", {"offer": OFFER}, 1)]},
|
|
242
|
+
client_id="c2",
|
|
243
|
+
)
|
|
244
|
+
assert response.status_code == 200, response.text
|
|
245
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("clientId") == "c2")
|
|
246
|
+
events = bench.events()
|
|
247
|
+
events.turn_started("t1", "user")
|
|
248
|
+
events.turn_done("t1", "dropped mid-call")
|
|
249
|
+
await drv.wait(lambda s: voice_of(s) is None)
|
|
250
|
+
assert bench.provider.closed
|
|
251
|
+
assert bench.commits == [
|
|
252
|
+
[{"id": "t1", "role": "user", "text": "dropped mid-call"}]
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def test_seed_takes_the_128_tail():
|
|
257
|
+
bench = Bench(transcript=[seed_message(i) for i in range(130)])
|
|
258
|
+
async with voice_host(bench) as (drv, _app):
|
|
259
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
260
|
+
_, _, seed = bench.provider.calls[0]
|
|
261
|
+
assert len(seed) == 128
|
|
262
|
+
assert seed == bench.transcript[-128:]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def test_restart_after_end_gets_a_fresh_session():
|
|
266
|
+
bench = Bench()
|
|
267
|
+
async with voice_host(bench) as (drv, _app):
|
|
268
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
269
|
+
await drv.command("voice/end")
|
|
270
|
+
await drv.wait(lambda s: voice_of(s) is None)
|
|
271
|
+
bench.provider = FakeVoiceProvider()
|
|
272
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
273
|
+
assert rsp["type"] == "accepted", rsp
|
|
274
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def test_invalid_offer_rejects():
|
|
278
|
+
bench = Bench()
|
|
279
|
+
async with voice_host(bench) as (drv, _app):
|
|
280
|
+
assert_rejected(await drv.command("voice/start", {}), "invalid-message")
|
|
281
|
+
assert_rejected(
|
|
282
|
+
await drv.command("voice/start", {"offer": ""}), "invalid-message"
|
|
283
|
+
)
|
|
284
|
+
assert bench.provider.calls == []
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pinned import PinnedWorker
|
|
9
|
+
from pinned.testing import MemoryStateStore
|
|
10
|
+
from run_helpers import RunDriver, _statement
|
|
11
|
+
from statewire import Statewire, command
|
|
12
|
+
from statewire_helpers import apply_ops, post_frame, statewire_client, stream_of
|
|
13
|
+
|
|
14
|
+
from harness_sdk import VoiceManager, end_voice, start_voice, voice_manager
|
|
15
|
+
|
|
16
|
+
OFFER = "v=0 fake-offer"
|
|
17
|
+
ANSWER = "v=0 fake-answer"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeVoiceProvider:
|
|
21
|
+
"""Records calls and hands the test the events sink to drive."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *, answer: str = ANSWER, fail: Exception | None = None) -> None:
|
|
24
|
+
self.answer = answer
|
|
25
|
+
self.fail = fail
|
|
26
|
+
self.calls: list[tuple[Any, ...]] = []
|
|
27
|
+
self.events: VoiceManager.Events | None = None
|
|
28
|
+
self.closed = False
|
|
29
|
+
|
|
30
|
+
async def start(self, offer, *, seed, events):
|
|
31
|
+
self.calls.append(("start", offer, list(seed)))
|
|
32
|
+
if self.fail is not None:
|
|
33
|
+
raise self.fail
|
|
34
|
+
self.events = events
|
|
35
|
+
return self.answer
|
|
36
|
+
|
|
37
|
+
async def append_context(self, text, *, channel):
|
|
38
|
+
self.calls.append(("append_context", text, channel))
|
|
39
|
+
|
|
40
|
+
async def return_tool_result(self, call_id, output):
|
|
41
|
+
self.calls.append(("return_tool_result", call_id, output))
|
|
42
|
+
|
|
43
|
+
async def select_rollover(self, rollover_id, seed):
|
|
44
|
+
self.calls.append(("select_rollover", rollover_id, list(seed)))
|
|
45
|
+
|
|
46
|
+
async def close(self):
|
|
47
|
+
self.closed = True
|
|
48
|
+
|
|
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
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Bench:
|
|
60
|
+
"""The test's host seams: the seed transcript in, live and committed turns out."""
|
|
61
|
+
|
|
62
|
+
provider: FakeVoiceProvider = field(default_factory=FakeVoiceProvider)
|
|
63
|
+
transcript: list[dict[str, Any]] = field(default_factory=list)
|
|
64
|
+
commits: list[list[dict[str, Any]]] = field(default_factory=list)
|
|
65
|
+
commit_failures: list[Exception] = field(default_factory=list)
|
|
66
|
+
contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def events(self) -> VoiceManager.Events:
|
|
69
|
+
assert self.provider.events is not None, "provider.start never ran"
|
|
70
|
+
return self.provider.events
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
74
|
+
class Host(Statewire):
|
|
75
|
+
async def lifespan(self):
|
|
76
|
+
self.state = {"messages": []}
|
|
77
|
+
self._durable: list[dict[str, Any]] = []
|
|
78
|
+
self._ephemeral: dict[str, dict[str, Any]] = {}
|
|
79
|
+
yield
|
|
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
|
+
|
|
89
|
+
async def get_voice_seed(self):
|
|
90
|
+
return [dict(message) for message in bench.transcript]
|
|
91
|
+
|
|
92
|
+
async def commit_voice_turns(self, turns):
|
|
93
|
+
if bench.commit_failures:
|
|
94
|
+
raise bench.commit_failures.pop(0)
|
|
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()
|
|
106
|
+
|
|
107
|
+
@command("voice/start")
|
|
108
|
+
async def voice_start(self, params, *, ctx):
|
|
109
|
+
return await start_voice(self, params, ctx=ctx)
|
|
110
|
+
|
|
111
|
+
@command("voice/end")
|
|
112
|
+
async def voice_end(self, params=None, *, ctx):
|
|
113
|
+
return await end_voice(self, ctx=ctx)
|
|
114
|
+
|
|
115
|
+
if not declare:
|
|
116
|
+
return Host
|
|
117
|
+
|
|
118
|
+
class Declared(Host):
|
|
119
|
+
@voice_manager()
|
|
120
|
+
def voice(self, ctx):
|
|
121
|
+
bench.contexts.append(ctx)
|
|
122
|
+
return bench.provider
|
|
123
|
+
|
|
124
|
+
return Declared
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@asynccontextmanager
|
|
128
|
+
async def voice_host(bench: Bench, *, declare: bool = True):
|
|
129
|
+
host_cls = make_voice_host(bench, declare=declare)
|
|
130
|
+
worker = PinnedWorker(state_store=MemoryStateStore())
|
|
131
|
+
async with statewire_client(host_cls, worker=worker) as (app, client):
|
|
132
|
+
client_id = f"c-{uuid4().hex[:8]}"
|
|
133
|
+
async with stream_of(app, client_id=client_id) as stream:
|
|
134
|
+
snapshot = await stream.next_event()
|
|
135
|
+
driver = RunDriver(client, stream, app, client_id=client_id)
|
|
136
|
+
driver.envelopes.append(snapshot)
|
|
137
|
+
apply_ops(driver.replica, copy.deepcopy(snapshot))
|
|
138
|
+
try:
|
|
139
|
+
yield driver, app
|
|
140
|
+
finally:
|
|
141
|
+
for task in driver._posts:
|
|
142
|
+
task.cancel()
|
|
143
|
+
for result in await asyncio.gather(
|
|
144
|
+
*driver._posts, return_exceptions=True
|
|
145
|
+
):
|
|
146
|
+
if isinstance(result, AssertionError):
|
|
147
|
+
raise result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def side_command(
|
|
151
|
+
app, client, method: str, params: Any = None, *, client_id: str = "c2"
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""One command from a second client on its own attach; returns the settled verdict."""
|
|
154
|
+
async with stream_of(app, client_id=client_id) as stream:
|
|
155
|
+
await stream.next_event()
|
|
156
|
+
response = await post_frame(
|
|
157
|
+
client,
|
|
158
|
+
{"cmd": [_statement(method, params, 1)]},
|
|
159
|
+
client_id=client_id,
|
|
160
|
+
)
|
|
161
|
+
assert response.status_code == 200, response.text
|
|
162
|
+
while True:
|
|
163
|
+
envelope = await stream.next_event()
|
|
164
|
+
assert envelope is not None, "stream ended unexpectedly"
|
|
165
|
+
for rsp in envelope.get("cmd", []):
|
|
166
|
+
if rsp.get("type") not in (None, "applied"):
|
|
167
|
+
return rsp
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def voice_of(replica: dict[str, Any]) -> dict[str, Any] | None:
|
|
171
|
+
return replica.get("voice")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def seed_message(index: int) -> dict[str, Any]:
|
|
175
|
+
role = "user" if index % 2 == 0 else "assistant"
|
|
176
|
+
return {"role": role, "text": f"m{index}"}
|
|
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
|