harness-sdk-python 0.30.1__tar.gz → 0.31.1__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 (44) hide show
  1. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/__init__.py +2 -0
  4. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/voice.py +106 -33
  5. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice.py +54 -10
  6. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice_frontend_tools.py +89 -2
  7. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/voice_helpers.py +9 -1
  8. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/.gitignore +0 -0
  9. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/README.md +0 -0
  10. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/checkpoints.py +0 -0
  13. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/deepagents.py +0 -0
  14. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/run_manager.py +0 -0
  17. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/run_helpers.py +0 -0
  18. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_admit_steer.py +0 -0
  19. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_applied_visibility.py +0 -0
  20. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_batches.py +0 -0
  21. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_checkpoints.py +0 -0
  23. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_deepagents_messages.py +0 -0
  24. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_dispatching.py +0 -0
  25. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_durability.py +0 -0
  26. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_edit_dispatched.py +0 -0
  27. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_edit_reload.py +0 -0
  28. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_enqueue.py +0 -0
  29. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_fenced_postgres.py +0 -0
  30. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_input_required.py +0 -0
  31. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_linear_thread.py +0 -0
  32. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_meta.py +0 -0
  33. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_mint_concurrency.py +0 -0
  34. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_outcomes.py +0 -0
  35. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_placement.py +0 -0
  36. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_prepare_hooks.py +0 -0
  37. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_restore.py +0 -0
  38. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_rewind_during_run.py +0 -0
  39. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_run_crash_sweep.py +0 -0
  40. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_settle.py +0 -0
  41. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_steer.py +0 -0
  42. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_stop_continue.py +0 -0
  43. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_stopping_window.py +0 -0
  44. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice_delegation.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.30.1
3
+ Version: 0.31.1
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
7
7
  Requires-Python: <4.0,>=3.12
8
8
  Requires-Dist: pinned<0.15,>=0.14.0
9
- Requires-Dist: statewire<0.13,>=0.12.0
9
+ Requires-Dist: statewire<0.13,>=0.12.3
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.30.1"
3
+ version = "0.31.1"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.12.0,<0.13", "pinned>=0.14.0,<0.15"]
8
+ dependencies = ["statewire>=0.12.3,<0.13", "pinned>=0.14.0,<0.15"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -21,6 +21,7 @@ from .voice import (
21
21
  end_voice,
22
22
  start_voice,
23
23
  temp_frontend_tool_result,
24
+ voice_client_departed,
24
25
  voice_manager,
25
26
  )
26
27
 
@@ -47,5 +48,6 @@ __all__ = [
47
48
  "run_manager",
48
49
  "start_voice",
49
50
  "temp_frontend_tool_result",
51
+ "voice_client_departed",
50
52
  "voice_manager",
51
53
  ]
@@ -16,7 +16,11 @@ projects into ``toolCalls`` as ``{callId, name, arguments}``; the owning
16
16
  client answers it with ``voice/temp-frontend-tool-result {call_id,
17
17
  output}``, which forwards through ``provider.return_tool_result`` and
18
18
  clears the entry. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
19
- provider ``closed`` event, or a connection drop ends it. Provider turn
19
+ provider ``closed`` event, or the owner staying detached past the
20
+ ``departure_grace`` window (the host's ``client_departed`` hook starts the
21
+ timer, the re-check runs at expiry) ends it; ``voice/start`` while the live
22
+ session's owner is detached ends the orphan and starts the new session.
23
+ Provider turn
20
24
  events render live in ``state["messages"]`` through
21
25
  ``Host.upsert_ephemeral_message`` (message id = turn id), tagged
22
26
  ``ephemeral: true`` on the wire; each completed
@@ -34,9 +38,14 @@ turns since the previous task. Interim ``speak`` text and the returned
34
38
  result flow back as speakable context appends; a named call's result
35
39
  returns via ``provider.return_tool_result``; a failed handler surfaces to
36
40
  the model as commentary (task) or an error output (call). The running unit
37
- projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest.
38
-
39
- Rejections: ``voice-active`` (a session is already live), ``no-voice``
41
+ projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest. The
42
+ handler's ``call_frontend_tool`` invokes a client-declared tool through the
43
+ same ``toolCalls`` projection: the client's ``voice/temp-frontend-tool-result``
44
+ resolves the await instead of forwarding to the provider, and session end
45
+ fails outstanding awaits.
46
+
47
+ Rejections: ``voice-active`` (a session is already live and its owner
48
+ attached), ``no-voice``
40
49
  (no voice manager declared, or ``voice/end`` with no session),
41
50
  ``not-voice-owner`` (``voice/end`` from another client),
42
51
  ``voice-unavailable`` (the factory or ``provider.start`` raised).
@@ -107,7 +116,7 @@ class VoiceManager(Tray):
107
116
  buffer rendered live in ``state["messages"]``; completed turns commit
108
117
  per turn and ended sessions flush and self-delete."""
109
118
 
110
- connection_poll = 0.5
119
+ departure_grace = 10.0
111
120
 
112
121
  clientId = observable(None)
113
122
  status = observable("connecting")
@@ -129,12 +138,13 @@ class VoiceManager(Tray):
129
138
  self._turns: list[dict[str, Any]] = []
130
139
  self._flushing = asyncio.Lock()
131
140
  self._ended = False
132
- self._watcher: "asyncio.Task[Any] | None" = None
141
+ self._grace: "asyncio.Task[Any] | None" = None
133
142
  self._handoff_pending: list[dict[str, str]] = []
134
143
  self._seen_requests: set[str] = set()
135
144
  self._delegating = asyncio.Lock()
136
145
  self._tasks_run = 0
137
- self._client_tools: set[str] = set()
146
+ self._client_tools: "list[VoiceManager.ToolDef]" = []
147
+ self._executor_waits: dict[str, asyncio.Future[str]] = {}
138
148
 
139
149
  def items(self):
140
150
  yield from ()
@@ -227,8 +237,10 @@ class VoiceManager(Tray):
227
237
  task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
228
238
  *,
229
239
  speak: Callable[[str], Awaitable[None]],
240
+ call_frontend_tool: Callable[[str, str], Awaitable[str]],
241
+ tools: "list[VoiceManager.ToolDef]",
230
242
  ) -> str:
231
- """Fulfill one delegated work unit; ``speak`` streams interim spoken updates and the return value is the result."""
243
+ """Fulfill one delegated work unit; ``speak`` streams interim spoken updates, ``call_frontend_tool(name, arguments)`` runs a client-declared tool (``tools``) on the owning client, and the return value is the result."""
232
244
  ...
233
245
 
234
246
  # ─── Session lifecycle ──────────────────────────────────
@@ -239,7 +251,7 @@ class VoiceManager(Tray):
239
251
  applied: Callable[[], None],
240
252
  ) -> Any:
241
253
  self.clientId.value = caller.client_id
242
- self._client_tools = {tool["name"] for tool in tools}
254
+ self._client_tools = tools
243
255
  try:
244
256
  provider = await self.handler(
245
257
  VoiceManager.SessionContext(client_id=caller.client_id)
@@ -254,12 +266,18 @@ class VoiceManager(Tray):
254
266
  self._provider = provider
255
267
  self.status.value = "live"
256
268
  applied()
257
- self._watcher = self._spawn(self._watch(caller))
258
269
  return {"answer": answer}
259
270
 
260
- async def _watch(self, caller: Any) -> None:
261
- while caller.is_connected:
262
- await asyncio.sleep(self.connection_poll)
271
+ def client_departed(self) -> None:
272
+ if self._ended or self._grace is not None:
273
+ return
274
+ self._grace = self._spawn(self._departure_expired())
275
+
276
+ async def _departure_expired(self) -> None:
277
+ await asyncio.sleep(self.departure_grace)
278
+ self._grace = None
279
+ if self._host.has_subscriber(self.clientId.value):
280
+ return
263
281
  await self._background_end("connection-drop")
264
282
 
265
283
  async def _background_end(self, reason: str) -> None:
@@ -272,9 +290,9 @@ class VoiceManager(Tray):
272
290
  if self._ended:
273
291
  return
274
292
  self._ended = True
275
- watcher, self._watcher = self._watcher, None
276
- if watcher is not None and watcher is not asyncio.current_task():
277
- watcher.cancel()
293
+ grace, self._grace = self._grace, None
294
+ if grace is not None and grace is not asyncio.current_task():
295
+ grace.cancel()
278
296
  provider, self._provider = self._provider, None
279
297
  if provider is not None:
280
298
  try:
@@ -289,6 +307,11 @@ class VoiceManager(Tray):
289
307
  turns, self._turns = self._turns, []
290
308
  for turn in turns:
291
309
  self._host.drop_ephemeral_message(turn["id"])
310
+ waits, self._executor_waits = self._executor_waits, {}
311
+ for wait in waits.values():
312
+ if not wait.done():
313
+ wait.set_exception(RuntimeError("the voice session ended"))
314
+ wait.exception()
292
315
  self.status.value = "closed"
293
316
  self.delete()
294
317
 
@@ -387,7 +410,7 @@ class VoiceManager(Tray):
387
410
  self._tasks_run += 1
388
411
  self.activeTask.value = {"taskId": task_id, "text": text}
389
412
  try:
390
- answer = await self._host.handle_voice_task(task, speak=self._speak)
413
+ answer = await self._delegate(task)
391
414
  if not isinstance(answer, str) or answer == "":
392
415
  raise TypeError(
393
416
  f"handle_voice_task must return the result text, got {answer!r}"
@@ -403,39 +426,72 @@ class VoiceManager(Tray):
403
426
  self.activeTask.value = None
404
427
  await self._append(answer, channel="speakable")
405
428
 
429
+ async def _delegate(
430
+ self, task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest"
431
+ ) -> str:
432
+ return await self._host.handle_voice_task(
433
+ task,
434
+ speak=self._speak,
435
+ call_frontend_tool=self._call_frontend_tool,
436
+ tools=self._client_tools,
437
+ )
438
+
439
+ def _project_call(self, call_id: str, name: str, arguments: str) -> None:
440
+ self.toolCalls.value = [
441
+ *self.toolCalls.value,
442
+ {"callId": call_id, "name": name, "arguments": arguments},
443
+ ]
444
+
445
+ def _clear_call(self, call_id: str) -> None:
446
+ if self._ended:
447
+ return
448
+ self.toolCalls.value = [
449
+ call for call in self.toolCalls.value if call["callId"] != call_id
450
+ ]
451
+
406
452
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
407
453
  if self._ended or not self._claim(request.call_id):
408
454
  return
409
- if request.name in self._client_tools:
410
- self.toolCalls.value = [
411
- *self.toolCalls.value,
412
- {
413
- "callId": request.call_id,
414
- "name": request.name,
415
- "arguments": request.arguments,
416
- },
417
- ]
455
+ if any(tool["name"] == request.name for tool in self._client_tools):
456
+ self._project_call(request.call_id, request.name, request.arguments)
418
457
  return
419
458
  self._spawn(self._run_tool_call(request))
420
459
 
460
+ async def _call_frontend_tool(self, name: str, arguments: str) -> str:
461
+ if all(tool["name"] != name for tool in self._client_tools):
462
+ raise ValueError(f"{name!r} is not a client-declared frontend tool")
463
+ if self._ended:
464
+ raise RuntimeError("the voice session ended")
465
+ call_id = uuid4().hex
466
+ wait: asyncio.Future[str] = asyncio.get_running_loop().create_future()
467
+ self._executor_waits[call_id] = wait
468
+ self._project_call(call_id, name, arguments)
469
+ try:
470
+ return await wait
471
+ finally:
472
+ self._executor_waits.pop(call_id, None)
473
+ self._clear_call(call_id)
474
+
421
475
  async def _answer_client_call(self, call_id: str, output: str) -> None:
422
- pending = self.toolCalls.value
423
- if not any(call["callId"] == call_id for call in pending):
476
+ if not any(call["callId"] == call_id for call in self.toolCalls.value):
424
477
  raise _reject(
425
478
  "unknown-tool-call", f"no pending frontend tool call {call_id!r}"
426
479
  )
480
+ wait = self._executor_waits.get(call_id)
481
+ if wait is not None:
482
+ if not wait.done():
483
+ wait.set_result(output)
484
+ return
427
485
  provider = self._provider
428
486
  if provider is None:
429
487
  raise _reject("no-voice", "the voice session is not live")
430
488
  await provider.return_tool_result(call_id, output)
431
- self.toolCalls.value = [
432
- call for call in pending if call["callId"] != call_id
433
- ]
489
+ self._clear_call(call_id)
434
490
 
435
491
  async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
436
492
  self.activeTask.value = {"taskId": request.call_id, "text": request.name}
437
493
  try:
438
- output = await self._host.handle_voice_task(request, speak=self._speak)
494
+ output = await self._delegate(request)
439
495
  if not isinstance(output, str):
440
496
  raise TypeError(
441
497
  f"handle_voice_task must return the result text, got {output!r}"
@@ -515,6 +571,11 @@ class VoiceManagerNamespace(TrayMapNamespace):
515
571
  session = self._session
516
572
  return session if session is not None and not session._ended else None
517
573
 
574
+ def client_departed(self, client_id: str) -> None:
575
+ session = self.live()
576
+ if session is not None and session.clientId.value == client_id:
577
+ session.client_departed()
578
+
518
579
  def _mount(self, tray: VoiceManager) -> None:
519
580
  if tray.storage._restored:
520
581
  return # a crash's leftover row: the pump finishes its delete unmounted
@@ -533,8 +594,13 @@ class VoiceManagerNamespace(TrayMapNamespace):
533
594
  if not isinstance(offer, str) or offer == "":
534
595
  raise _reject("invalid-message", "offer must be a non-empty SDP string")
535
596
  tools = _validated_tools(params.get("tools"))
536
- if self._starting or self.live() is not None:
597
+ if self._starting:
537
598
  raise _reject("voice-active", "a voice session is already live")
599
+ session = self.live()
600
+ if session is not None:
601
+ if self._api.has_subscriber(session.clientId.value):
602
+ raise _reject("voice-active", "a voice session is already live")
603
+ await session._end("connection-drop")
538
604
  self._starting = True
539
605
  try:
540
606
  seed = _capped_seed(await self._api.get_voice_seed())
@@ -630,6 +696,13 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
630
696
  return await namespace.end(ctx=ctx)
631
697
 
632
698
 
699
+ def voice_client_departed(host: Any, client_id: str) -> None:
700
+ """The Statewire ``client_departed`` hook body: routes to the host's declared voice manager."""
701
+ namespace = _voice_of(host)
702
+ if namespace is not None:
703
+ namespace.client_departed(client_id)
704
+
705
+
633
706
  async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
634
707
  """The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
635
708
  namespace = _voice_of(host)
@@ -276,19 +276,23 @@ async def test_provider_closed_event_ends_and_commits():
276
276
  assert_rejected(await drv.command("voice/end"), "no-voice")
277
277
 
278
278
 
279
- async def test_connection_drop_ends_session(monkeypatch):
280
- monkeypatch.setattr(VoiceManager, "connection_poll", 0.02)
279
+ async def start_from_second_client(drv, stream, *, client_id="c2"):
280
+ await stream.next_event()
281
+ response = await post_frame(
282
+ drv._client,
283
+ {"cmd": [_statement("voice/start", {"offer": OFFER}, 1)]},
284
+ client_id=client_id,
285
+ )
286
+ assert response.status_code == 200, response.text
287
+ await drv.wait(lambda s: (voice_of(s) or {}).get("clientId") == client_id)
288
+
289
+
290
+ async def test_connection_drop_ends_session_after_grace(monkeypatch):
291
+ monkeypatch.setattr(VoiceManager, "departure_grace", 0.05)
281
292
  bench = Bench()
282
293
  async with voice_host(bench) as (drv, app):
283
294
  async with stream_of(app, client_id="c2") as stream:
284
- await stream.next_event()
285
- response = await post_frame(
286
- drv._client,
287
- {"cmd": [_statement("voice/start", {"offer": OFFER}, 1)]},
288
- client_id="c2",
289
- )
290
- assert response.status_code == 200, response.text
291
- await drv.wait(lambda s: (voice_of(s) or {}).get("clientId") == "c2")
295
+ await start_from_second_client(drv, stream)
292
296
  events = bench.events()
293
297
  events.turn_started("t1", "user")
294
298
  events.turn_done("t1", "dropped mid-call")
@@ -299,6 +303,46 @@ async def test_connection_drop_ends_session(monkeypatch):
299
303
  ]
300
304
 
301
305
 
306
+ async def test_reattach_within_grace_keeps_the_session(monkeypatch):
307
+ monkeypatch.setattr(VoiceManager, "departure_grace", 0.2)
308
+ bench = Bench()
309
+ async with voice_host(bench) as (drv, app):
310
+ async with stream_of(app, client_id="c2") as stream:
311
+ await start_from_second_client(drv, stream)
312
+ async with stream_of(app, client_id="c2") as stream:
313
+ await stream.next_event()
314
+ await asyncio.sleep(0.4)
315
+ await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
316
+ assert not bench.provider.closed
317
+
318
+
319
+ async def test_start_over_a_detached_owner_replaces_the_session():
320
+ bench = Bench()
321
+ async with voice_host(bench) as (drv, app):
322
+ async with stream_of(app, client_id="c2") as stream:
323
+ await start_from_second_client(drv, stream)
324
+ orphaned = bench.provider
325
+ bench.provider = FakeVoiceProvider()
326
+ rsp = await drv.command("voice/start", {"offer": OFFER})
327
+ assert rsp["type"] == "accepted", rsp
328
+ assert orphaned.closed
329
+ await drv.wait(
330
+ lambda s: (voice_of(s) or {}).get("clientId") == drv.client_id
331
+ and (voice_of(s) or {}).get("status") == "live"
332
+ )
333
+
334
+
335
+ async def test_start_over_an_attached_owner_still_rejects_voice_active():
336
+ bench = Bench()
337
+ async with voice_host(bench) as (drv, app):
338
+ async with stream_of(app, client_id="c2") as stream:
339
+ await start_from_second_client(drv, stream)
340
+ assert_rejected(
341
+ await drv.command("voice/start", {"offer": OFFER}), "voice-active"
342
+ )
343
+ assert not bench.provider.closed
344
+
345
+
302
346
  async def test_seed_takes_the_128_tail():
303
347
  bench = Bench(transcript=[seed_message(i) for i in range(130)])
304
348
  async with voice_host(bench) as (drv, _app):
@@ -1,6 +1,9 @@
1
1
  """Frontend function tools contract: ``voice/start {tools}`` reaches the
2
- provider, matching calls project into ``state["voice"]["toolCalls"]``, and
3
- ``voice/temp-frontend-tool-result`` answers them."""
2
+ provider, matching calls project into ``state["voice"]["toolCalls"]``,
3
+ ``voice/temp-frontend-tool-result`` answers them, and the delegated-task
4
+ handler's ``call_frontend_tool`` rides the same projection."""
5
+
6
+ import asyncio
4
7
 
5
8
  from run_helpers import assert_rejected
6
9
  from voice_helpers import (
@@ -13,6 +16,13 @@ from voice_helpers import (
13
16
 
14
17
  from harness_sdk import VoiceManager
15
18
 
19
+
20
+ async def until(predicate, timeout: float = 5) -> None:
21
+ deadline = asyncio.get_running_loop().time() + timeout
22
+ while not predicate():
23
+ assert asyncio.get_running_loop().time() < deadline, "condition never held"
24
+ await asyncio.sleep(0.01)
25
+
16
26
  TOOL = {
17
27
  "name": "get_local_time",
18
28
  "description": "Read the user's local date and time.",
@@ -93,6 +103,83 @@ async def test_unknown_call_id_rejects_unknown_tool_call():
93
103
  )
94
104
 
95
105
 
106
+ async def test_executor_call_projects_and_the_result_resolves_the_await():
107
+ bench = Bench()
108
+ outputs: list[str] = []
109
+
110
+ async def handler(task, speak):
111
+ outputs.append(await bench.call_frontend_tool("get_local_time", "{}"))
112
+ return "wrapped it up"
113
+
114
+ bench.task_handler = handler
115
+ async with voice_host(bench) as (drv, _app):
116
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
117
+ bench.events().task_requested("d1", "what time is it locally?")
118
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
119
+ [call] = drv.replica["voice"]["toolCalls"]
120
+ assert call["name"] == "get_local_time"
121
+ assert call["arguments"] == "{}"
122
+ assert bench.tool_defs == [TOOL]
123
+ rsp = await drv.command(
124
+ "voice/temp-frontend-tool-result",
125
+ {"call_id": call["callId"], "output": "12:30"},
126
+ )
127
+ assert rsp["type"] == "accepted", rsp
128
+ await until(
129
+ lambda: ("append_context", "wrapped it up", "speakable")
130
+ in bench.provider.calls
131
+ )
132
+ assert outputs == ["12:30"]
133
+ assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
134
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") == [])
135
+
136
+
137
+ async def test_executor_call_with_undeclared_tool_raises_in_the_handler():
138
+ bench = Bench()
139
+
140
+ async def handler(task, speak):
141
+ return await bench.call_frontend_tool("open_pod_bay_doors", "{}")
142
+
143
+ bench.task_handler = handler
144
+ async with voice_host(bench) as (drv, _app):
145
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
146
+ bench.events().task_requested("d1", "open up")
147
+ await until(
148
+ lambda: any(
149
+ c[0] == "append_context" and c[2] == "commentary"
150
+ for c in bench.provider.calls
151
+ )
152
+ )
153
+ [(_, text, _channel)] = [
154
+ c for c in bench.provider.calls if c[0] == "append_context"
155
+ ]
156
+ assert "'open_pod_bay_doors' is not a client-declared frontend tool" in text
157
+ assert drv.replica["voice"]["toolCalls"] == []
158
+
159
+
160
+ async def test_session_end_fails_a_pending_executor_call():
161
+ bench = Bench()
162
+ failures: list[Exception] = []
163
+
164
+ async def handler(task, speak):
165
+ try:
166
+ return await bench.call_frontend_tool("get_local_time", "{}")
167
+ except Exception as exc:
168
+ failures.append(exc)
169
+ raise
170
+
171
+ bench.task_handler = handler
172
+ async with voice_host(bench) as (drv, _app):
173
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
174
+ bench.events().task_requested("d1", "what time is it?")
175
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
176
+ rsp = await drv.command("voice/end")
177
+ assert rsp["type"] == "accepted", rsp
178
+ await until(lambda: failures != [])
179
+ assert isinstance(failures[0], RuntimeError)
180
+ assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
181
+
182
+
96
183
  async def test_tool_result_from_other_client_rejects_not_voice_owner():
97
184
  bench = Bench()
98
185
  async with voice_host(bench) as (drv, app):
@@ -16,6 +16,7 @@ from harness_sdk import (
16
16
  end_voice,
17
17
  start_voice,
18
18
  temp_frontend_tool_result,
19
+ voice_client_departed,
19
20
  voice_manager,
20
21
  )
21
22
 
@@ -72,6 +73,8 @@ class Bench:
72
73
  contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
73
74
  tasks: list[Any] = field(default_factory=list)
74
75
  task_handler: Any = None
76
+ call_frontend_tool: Any = None
77
+ tool_defs: list[Any] = field(default_factory=list)
75
78
 
76
79
  def events(self) -> VoiceManager.Events:
77
80
  assert self.provider.events is not None, "provider.start never ran"
@@ -116,8 +119,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
116
119
  del self._ephemeral[message_id]
117
120
  self._project_messages()
118
121
 
119
- async def handle_voice_task(self, task, *, speak):
122
+ async def handle_voice_task(self, task, *, speak, call_frontend_tool, tools):
120
123
  bench.tasks.append(task)
124
+ bench.call_frontend_tool = call_frontend_tool
125
+ bench.tool_defs = tools
121
126
  if bench.task_handler is not None:
122
127
  return await bench.task_handler(task, speak)
123
128
  request_id = getattr(task, "task_id", None) or task.call_id
@@ -135,6 +140,9 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
135
140
  async def voice_tool_result(self, params, *, ctx):
136
141
  return await temp_frontend_tool_result(self, params, ctx=ctx)
137
142
 
143
+ def client_departed(self, client_id):
144
+ voice_client_departed(self, client_id)
145
+
138
146
  if not declare:
139
147
  return Host
140
148