harness-sdk-python 0.26.0__tar.gz → 0.27.0__tar.gz

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