From cc94d18dec3ec8ba619bd5919aae10819f6fa5c3 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Mon, 14 Sep 2026 13:19:00 +0100 Subject: [PATCH 1/7] fix: clarify ForceEndTurn deployment behavior --- .../deepgram-python-conversational-stt/SKILL.md | 2 +- .fernignore | 2 +- AGENTS.md | 2 +- examples/16-transcription-force-end-turn.py | 7 ++++--- examples/32-voice-agent-force-end-turn.py | 17 +++++++++++++++-- tests/manual/listen/v2/force_end_turn/async.py | 8 +++++--- tests/manual/listen/v2/force_end_turn/main.py | 12 +++++++----- 7 files changed, 34 insertions(+), 16 deletions(-) diff --git a/.agents/skills/deepgram-python-conversational-stt/SKILL.md b/.agents/skills/deepgram-python-conversational-stt/SKILL.md index 4d9bfac6..20b98a21 100644 --- a/.agents/skills/deepgram-python-conversational-stt/SKILL.md +++ b/.agents/skills/deepgram-python-conversational-stt/SKILL.md @@ -91,7 +91,7 @@ with client.listen.v2.connect( **No `language` parameter** on v2 — language is implied by model (`flux-general-en`) or hinted via `language_hint` on multi. -For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn requires deployment enablement; see `examples/16-transcription-force-end-turn.py`. +For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with `listen_v2_force_end_turn = true` under `[features]`; see `examples/16-transcription-force-end-turn.py`. ## Events (server → client) diff --git a/.fernignore b/.fernignore index 8075b195..dfb71292 100644 --- a/.fernignore +++ b/.fernignore @@ -65,7 +65,7 @@ src/deepgram/speak/v2/socket_client.py # Agent TTS provider expressivity must reject fractional values rather than # allowing Pydantic v1 to truncate them before the API sees the request. -# [temporarily frozen — remove when Fern emits a strict integer] +# [temporarily frozen — remove when Pydantic v1 support is dropped] src/deepgram/types/speak_settings_v1provider.py src/deepgram/types/deepgram.py diff --git a/AGENTS.md b/AGENTS.md index 233eec38..b551f725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,10 +66,10 @@ Current temporarily frozen files: - `.gitignore` — Fern generates a baseline version and has regenerated it before (`fdcce88`, `ad93815`, `4bee463`). We hand-add the local coverage artifact ignores (`.coverage`, `htmlcov/`, `coverage.xml`). Before each regen, unfreeze and re-diff so Fern's own additions are picked up, then re-apply the three coverage lines. - `src/deepgram/speak/v1/socket_client.py` — optional message param defaults, broad exception catch - `src/deepgram/speak/v2/socket_client.py` — same (optional `send_flush`/`send_close`/`send_interrupt` defaults, broad exception catch); new websocket TTS client added in the 2026-07-08 regen. `send_interrupt` carries no required payload so it takes the same optional-default treatment as the other control sends; `send_configure` deliberately keeps its required argument (a Configure with no settings is meaningless) -- `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Fern emits a strict integer. - `src/deepgram/listen/v1/socket_client.py` — same - `src/deepgram/listen/v2/socket_client.py` — same (broad except, optional `send_close_stream` default). As of the 2026-08-11 regen the generator properly types `send_configure(ListenV2Configure)` and puts `ListenV2ConfigureSuccess` in the response Union, so those are taken from the generator; the only `send_configure` patch retained is runtime tolerance for a raw dict (sent verbatim) for back-compat with pre-typed-model callers - `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types` +- `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Pydantic v1 support is dropped. - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. diff --git a/examples/16-transcription-force-end-turn.py b/examples/16-transcription-force-end-turn.py index 31df4f6d..d190d45f 100644 --- a/examples/16-transcription-force-end-turn.py +++ b/examples/16-transcription-force-end-turn.py @@ -21,9 +21,10 @@ push-to-talk button being released. The connection stays open after a forced end: the turn index advances and transcription continues. -Note: ForceEndTurn requires server-side enablement and is not available on every -deployment. Where it is not enabled the server replies UNPARSABLE_CLIENT_MESSAGE and -closes the connection; this example reports that and exits. +ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On +self-hosted deployments, it requires the 2026-09-15 release or later with +listen_v2_force_end_turn = true under [features]. Unsupported deployments reply +UNPARSABLE_CLIENT_MESSAGE and close the connection; this example reports that and exits. """ import os diff --git a/examples/32-voice-agent-force-end-turn.py b/examples/32-voice-agent-force-end-turn.py index a5861462..53836798 100644 --- a/examples/32-voice-agent-force-end-turn.py +++ b/examples/32-voice-agent-force-end-turn.py @@ -36,6 +36,8 @@ def main() -> None: user_started = threading.Event() agent_finished = threading.Event() + force_end_turn_rejected = threading.Event() + force_end_turn_error: str | None = None settings = AgentV1Settings( audio=AgentV1SettingsAudio(input=AgentV1SettingsAudioInput(encoding="linear16", sample_rate=44100)), @@ -56,6 +58,7 @@ def main() -> None: with DeepgramClient().agent.v1.connect() as agent: def on_message(message: object) -> None: + nonlocal force_end_turn_error message_type = getattr(message, "type", None) if message_type == "UserStartedSpeaking": user_started.set() @@ -67,6 +70,9 @@ def on_message(message: object) -> None: print("AgentAudioDone received") elif message_type in {"Warning", "Error"}: print(f"{message_type}: {message.code} - {message.description}") + if message.code == "FORCE_END_TURN_UNSUPPORTED": + force_end_turn_error = f"{message.code}: {message.description}" + force_end_turn_rejected.set() agent.on(EventType.MESSAGE, on_message) agent.on(EventType.ERROR, lambda error: print(f"Connection error: {error}")) @@ -88,8 +94,15 @@ def on_message(message: object) -> None: print("Sending ForceEndTurn") agent.send_force_end_turn() - if not agent_finished.wait(15): - raise TimeoutError("Timed out waiting for the agent response") + deadline = time.monotonic() + 15 + while not agent_finished.wait(0.1): + if force_end_turn_rejected.is_set(): + raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") + if time.monotonic() >= deadline: + raise TimeoutError("Timed out waiting for the agent response") + # The server can reject ForceEndTurn after AgentAudioDone arrives, so check once more. + if force_end_turn_rejected.is_set(): + raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") if __name__ == "__main__": diff --git a/tests/manual/listen/v2/force_end_turn/async.py b/tests/manual/listen/v2/force_end_turn/async.py index d38af3d0..bd13c2e1 100644 --- a/tests/manual/listen/v2/force_end_turn/async.py +++ b/tests/manual/listen/v2/force_end_turn/async.py @@ -5,8 +5,9 @@ step 1 of `main.py`: force an in-progress turn to end and confirm the resulting EndOfTurn reports trigger="manual". See `main.py` for the full end-of-turn control matrix. -ForceEndTurn is gated per deployment; where it is not enabled this reports SKIP rather -than failing. Point it at a deployment that has the feature with DEEPGRAM_BASE_URL. +ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On +self-hosted deployments, it requires the 2026-09-15 release or later with +listen_v2_force_end_turn = true under [features]. Unsupported deployments report SKIP. Requires DEEPGRAM_API_KEY. Run with: @@ -114,7 +115,8 @@ def on_message(message: Any) -> None: if gated: print(" SKIP: ForceEndTurn is not enabled on this deployment") print(f" ({errors[0]})") - print(" Set DEEPGRAM_BASE_URL to a deployment that has the feature.") + print(" Self-hosted deployments need the 2026-09-15 release or later with") + print(" listen_v2_force_end_turn = true under [features].") return if errors: print(f" FAIL: {errors[0]}") diff --git a/tests/manual/listen/v2/force_end_turn/main.py b/tests/manual/listen/v2/force_end_turn/main.py index d8d546b5..e630ee51 100644 --- a/tests/manual/listen/v2/force_end_turn/main.py +++ b/tests/manual/listen/v2/force_end_turn/main.py @@ -21,10 +21,11 @@ ForceEndTurn becomes the only way to close a turn. This is the combination to reach for when the application owns turn boundaries. -ForceEndTurn is gated per deployment. Where it is not enabled the server replies -UNPARSABLE_CLIENT_MESSAGE ("not enabled on this deployment") and closes the connection; -this script reports SKIP rather than failing. Point it at a deployment that has the -feature with DEEPGRAM_BASE_URL. +ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On +self-hosted deployments, it requires the 2026-09-15 release or later with +listen_v2_force_end_turn = true under [features]. Unsupported deployments reply +UNPARSABLE_CLIENT_MESSAGE ("not enabled on this deployment") and close the connection; +this script reports SKIP rather than failing. Requires DEEPGRAM_API_KEY. Run with: @@ -173,7 +174,8 @@ def main() -> None: if gated: print(" SKIP: ForceEndTurn is not enabled on this deployment") print(f" ({errors[0]})") - print(" Set DEEPGRAM_BASE_URL to a deployment that has the feature.") + print(" Self-hosted deployments need the 2026-09-15 release or later with") + print(" listen_v2_force_end_turn = true under [features].") return if errors: raise AssertionError(errors[0]) From d488e556b33eb66cb557b0ce5ca8c3ea0f36030a Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 6 Oct 2026 12:27:33 +0100 Subject: [PATCH 2/7] docs: clarify self-hosted ForceEndTurn configuration --- examples/16-transcription-force-end-turn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/16-transcription-force-end-turn.py b/examples/16-transcription-force-end-turn.py index d190d45f..7f1197e8 100644 --- a/examples/16-transcription-force-end-turn.py +++ b/examples/16-transcription-force-end-turn.py @@ -149,7 +149,8 @@ def send_audio() -> None: # before it could print anything. if feature_disabled.is_set(): print("\nForceEndTurn is not enabled on this deployment.") - print("Set DEEPGRAM_BASE_URL to a deployment that has the feature.") + print("Self-hosted deployments need the 2026-09-15 release or later with") + print("listen_v2_force_end_turn = true under [features].") except Exception as e: print(f"Error: {type(e).__name__}: {e}") From dd7cad786b6202c607be8064fd6e6ae2b5d9f0f7 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 6 Oct 2026 12:37:40 +0100 Subject: [PATCH 3/7] fix: wait for Voice Agent ForceEndTurn outcome --- examples/32-voice-agent-force-end-turn.py | 36 ++++++++++++++---- ...test_voice_agent_force_end_turn_example.py | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 tests/custom/test_voice_agent_force_end_turn_example.py diff --git a/examples/32-voice-agent-force-end-turn.py b/examples/32-voice-agent-force-end-turn.py index 53836798..5f094fba 100644 --- a/examples/32-voice-agent-force-end-turn.py +++ b/examples/32-voice-agent-force-end-turn.py @@ -31,6 +31,29 @@ load_dotenv() AUDIO_PATH = Path(__file__).parent / "fixtures" / "audio.wav" +FORCE_END_TURN_REJECTION_GRACE_SECONDS = 0.5 + + +def wait_for_force_end_turn_outcome( + agent_finished: threading.Event, + force_end_turn_rejected: threading.Event, + *, + timeout_seconds: float, +) -> bool: + """Return whether ForceEndTurn completed without a queued rejection.""" + deadline = time.monotonic() + timeout_seconds + while True: + if force_end_turn_rejected.is_set(): + return False + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timed out waiting for the agent response") + if agent_finished.wait(min(0.1, remaining)): + # AgentAudioDone can precede FORCE_END_TURN_UNSUPPORTED in the socket reader. + return not force_end_turn_rejected.wait( + min(FORCE_END_TURN_REJECTION_GRACE_SECONDS, remaining) + ) def main() -> None: @@ -94,14 +117,11 @@ def on_message(message: object) -> None: print("Sending ForceEndTurn") agent.send_force_end_turn() - deadline = time.monotonic() + 15 - while not agent_finished.wait(0.1): - if force_end_turn_rejected.is_set(): - raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") - if time.monotonic() >= deadline: - raise TimeoutError("Timed out waiting for the agent response") - # The server can reject ForceEndTurn after AgentAudioDone arrives, so check once more. - if force_end_turn_rejected.is_set(): + if not wait_for_force_end_turn_outcome( + agent_finished, + force_end_turn_rejected, + timeout_seconds=15, + ): raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") diff --git a/tests/custom/test_voice_agent_force_end_turn_example.py b/tests/custom/test_voice_agent_force_end_turn_example.py new file mode 100644 index 00000000..3122f61b --- /dev/null +++ b/tests/custom/test_voice_agent_force_end_turn_example.py @@ -0,0 +1,38 @@ +import importlib.util +import sys +import threading +import types +from pathlib import Path + +EXAMPLE_PATH = Path(__file__).parents[2] / "examples" / "32-voice-agent-force-end-turn.py" + + +def load_example(monkeypatch): + dotenv = types.ModuleType("dotenv") + dotenv.load_dotenv = lambda: None + monkeypatch.setitem(sys.modules, "dotenv", dotenv) + + spec = importlib.util.spec_from_file_location("voice_agent_force_end_turn_example", EXAMPLE_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_agent_audio_done_waits_for_late_force_end_turn_rejection(monkeypatch): + example = load_example(monkeypatch) + agent_finished = threading.Event() + force_end_turn_rejected = threading.Event() + agent_finished.set() + + rejection = threading.Timer(0.01, force_end_turn_rejected.set) + rejection.start() + try: + assert not example.wait_for_force_end_turn_outcome( + agent_finished, + force_end_turn_rejected, + timeout_seconds=1, + ) + finally: + rejection.join() From be7186dff01845e8827b8e20635a967a2f16f69d Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 6 Oct 2026 12:55:24 +0100 Subject: [PATCH 4/7] fix: document self-hosted ForceEndTurn prerequisites --- .../SKILL.md | 2 +- .fernignore | 1 + AGENTS.md | 1 + examples/16-transcription-force-end-turn.py | 11 +++++-- ...test_voice_agent_force_end_turn_example.py | 29 ++++++++++++------- .../manual/listen/v2/force_end_turn/async.py | 5 ++-- tests/manual/listen/v2/force_end_turn/main.py | 12 +++++--- 7 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.agents/skills/deepgram-python-conversational-stt/SKILL.md b/.agents/skills/deepgram-python-conversational-stt/SKILL.md index 20b98a21..83fff585 100644 --- a/.agents/skills/deepgram-python-conversational-stt/SKILL.md +++ b/.agents/skills/deepgram-python-conversational-stt/SKILL.md @@ -91,7 +91,7 @@ with client.listen.v2.connect( **No `language` parameter** on v2 — language is implied by model (`flux-general-en`) or hinted via `language_hint` on multi. -For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with `listen_v2_force_end_turn = true` under `[features]`; see `examples/16-transcription-force-end-turn.py`. +For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with `listen_v2 = true` and `listen_v2_force_end_turn = true` under `[features]`; see `examples/16-transcription-force-end-turn.py`. ## Events (server → client) diff --git a/.fernignore b/.fernignore index dfb71292..f1a98e8a 100644 --- a/.fernignore +++ b/.fernignore @@ -270,6 +270,7 @@ tests/custom/test_flux_tts_controls.py tests/custom/test_topics_intents_v7_compat.py tests/custom/test_transport.py tests/custom/test_websocket_streaming_coverage.py +tests/custom/test_voice_agent_force_end_turn_example.py tests/typecheck/compat_aliases.py # Wire test with restored compatibility coverage for legacy create-key request alias diff --git a/AGENTS.md b/AGENTS.md index b551f725..8a7e660d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Current permanently frozen files: - `tests/custom/test_regen_response_fields.py` — hand-written regression test guarding the response fields added by the 2026-08-18 regen: `ListenV1Response/ResultsMetadata.diarize_info` (`model_uuid`, `arch`), per-word `speaker` / `speaker_confidence`, and `ListenV2TurnInfo.trigger`. Same rationale as `test_latency_report_stt_compat.py`: `UncheckedBaseModel` + `skip_validation` makes response parsing lenient, so a future spec removal would silently turn these into `None` rather than failing. Fixtures are real captured payloads; the `trigger` cases pin the parsing contract for the values the server sends (`model` / `manual` / `timeout`) plus the absent case, which is what a deployment without the server-gated `ForceEndTurn` feature returns. Live coverage of the same field lives in `tests/manual/listen/v2/force_end_turn/` - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) +- `tests/custom/test_voice_agent_force_end_turn_example.py` — hand-written regression coverage that the Voice Agent ForceEndTurn example waits for a rejection queued after `AgentAudioDone` - `tests/custom/test_text_builder.py`, `tests/custom/test_transport.py` — hand-written tests - `tests/custom/test_flux_tts_controls.py`, `tests/custom/test_topics_intents_v7_compat.py` — hand-written regression coverage for Flux TTS controls and direct Topics/Intents response compatibility - `tests/typecheck/compat_aliases.py` — hand-written mypy `assert_type` coverage for backward-compatible alias TypedDicts diff --git a/examples/16-transcription-force-end-turn.py b/examples/16-transcription-force-end-turn.py index 7f1197e8..f1825133 100644 --- a/examples/16-transcription-force-end-turn.py +++ b/examples/16-transcription-force-end-turn.py @@ -23,8 +23,13 @@ ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with -listen_v2_force_end_turn = true under [features]. Unsupported deployments reply -UNPARSABLE_CLIENT_MESSAGE and close the connection; this example reports that and exits. +the following [features] settings: + + listen_v2 = true + listen_v2_force_end_turn = true + +Unsupported deployments reply UNPARSABLE_CLIENT_MESSAGE and close the connection; this +example reports that and exits. """ import os @@ -150,7 +155,7 @@ def send_audio() -> None: if feature_disabled.is_set(): print("\nForceEndTurn is not enabled on this deployment.") print("Self-hosted deployments need the 2026-09-15 release or later with") - print("listen_v2_force_end_turn = true under [features].") + print("listen_v2 = true and listen_v2_force_end_turn = true under [features].") except Exception as e: print(f"Error: {type(e).__name__}: {e}") diff --git a/tests/custom/test_voice_agent_force_end_turn_example.py b/tests/custom/test_voice_agent_force_end_turn_example.py index 3122f61b..a8c9b3db 100644 --- a/tests/custom/test_voice_agent_force_end_turn_example.py +++ b/tests/custom/test_voice_agent_force_end_turn_example.py @@ -7,6 +7,17 @@ EXAMPLE_PATH = Path(__file__).parents[2] / "examples" / "32-voice-agent-force-end-turn.py" +class RejectionDuringGraceWait(threading.Event): + def __init__(self): + super().__init__() + self.wait_calls = 0 + + def wait(self, _timeout): + self.wait_calls += 1 + self.set() + return True + + def load_example(monkeypatch): dotenv = types.ModuleType("dotenv") dotenv.load_dotenv = lambda: None @@ -23,16 +34,12 @@ def load_example(monkeypatch): def test_agent_audio_done_waits_for_late_force_end_turn_rejection(monkeypatch): example = load_example(monkeypatch) agent_finished = threading.Event() - force_end_turn_rejected = threading.Event() + force_end_turn_rejected = RejectionDuringGraceWait() agent_finished.set() - rejection = threading.Timer(0.01, force_end_turn_rejected.set) - rejection.start() - try: - assert not example.wait_for_force_end_turn_outcome( - agent_finished, - force_end_turn_rejected, - timeout_seconds=1, - ) - finally: - rejection.join() + assert not example.wait_for_force_end_turn_outcome( + agent_finished, + force_end_turn_rejected, + timeout_seconds=1, + ) + assert force_end_turn_rejected.wait_calls == 1 diff --git a/tests/manual/listen/v2/force_end_turn/async.py b/tests/manual/listen/v2/force_end_turn/async.py index bd13c2e1..99335285 100644 --- a/tests/manual/listen/v2/force_end_turn/async.py +++ b/tests/manual/listen/v2/force_end_turn/async.py @@ -7,7 +7,8 @@ ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with -listen_v2_force_end_turn = true under [features]. Unsupported deployments report SKIP. +`listen_v2 = true` and `listen_v2_force_end_turn = true` under [features]. Unsupported +deployments report SKIP. Requires DEEPGRAM_API_KEY. Run with: @@ -116,7 +117,7 @@ def on_message(message: Any) -> None: print(" SKIP: ForceEndTurn is not enabled on this deployment") print(f" ({errors[0]})") print(" Self-hosted deployments need the 2026-09-15 release or later with") - print(" listen_v2_force_end_turn = true under [features].") + print(" listen_v2 = true and listen_v2_force_end_turn = true under [features].") return if errors: print(f" FAIL: {errors[0]}") diff --git a/tests/manual/listen/v2/force_end_turn/main.py b/tests/manual/listen/v2/force_end_turn/main.py index e630ee51..66f10fcb 100644 --- a/tests/manual/listen/v2/force_end_turn/main.py +++ b/tests/manual/listen/v2/force_end_turn/main.py @@ -23,9 +23,13 @@ ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with -listen_v2_force_end_turn = true under [features]. Unsupported deployments reply -UNPARSABLE_CLIENT_MESSAGE ("not enabled on this deployment") and close the connection; -this script reports SKIP rather than failing. +the following [features] settings: + + listen_v2 = true + listen_v2_force_end_turn = true + +Unsupported deployments reply UNPARSABLE_CLIENT_MESSAGE ("not enabled on this deployment") +and close the connection; this script reports SKIP rather than failing. Requires DEEPGRAM_API_KEY. Run with: @@ -175,7 +179,7 @@ def main() -> None: print(" SKIP: ForceEndTurn is not enabled on this deployment") print(f" ({errors[0]})") print(" Self-hosted deployments need the 2026-09-15 release or later with") - print(" listen_v2_force_end_turn = true under [features].") + print(" listen_v2 = true and listen_v2_force_end_turn = true under [features].") return if errors: raise AssertionError(errors[0]) From 715f194a407e7017770923474d7268c082fadd80 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 16 Oct 2026 10:40:25 +0100 Subject: [PATCH 5/7] fix: address ForceEndTurn review feedback --- examples/16-transcription-force-end-turn.py | 4 ++-- examples/32-voice-agent-force-end-turn.py | 12 ++++++++---- reference.md | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/16-transcription-force-end-turn.py b/examples/16-transcription-force-end-turn.py index f1825133..cb37b79f 100644 --- a/examples/16-transcription-force-end-turn.py +++ b/examples/16-transcription-force-end-turn.py @@ -56,8 +56,8 @@ def _client_environment() -> DeepgramClientEnvironment: - """Because ForceEndTurn is gated, this example accepts DEEPGRAM_BASE_URL=wss:// - so it can be pointed at a deployment that has the feature. Defaults to production.""" + """Accept DEEPGRAM_BASE_URL=wss:// to target a regional or self-hosted deployment. + Defaults to production.""" base = os.environ.get("DEEPGRAM_BASE_URL") if not base: return DeepgramClientEnvironment.PRODUCTION diff --git a/examples/32-voice-agent-force-end-turn.py b/examples/32-voice-agent-force-end-turn.py index 5f094fba..89e95a5f 100644 --- a/examples/32-voice-agent-force-end-turn.py +++ b/examples/32-voice-agent-force-end-turn.py @@ -10,6 +10,7 @@ import threading import time from pathlib import Path +from typing import Any from dotenv import load_dotenv @@ -61,6 +62,7 @@ def main() -> None: agent_finished = threading.Event() force_end_turn_rejected = threading.Event() force_end_turn_error: str | None = None + force_end_turn_failed = False settings = AgentV1Settings( audio=AgentV1SettingsAudio(input=AgentV1SettingsAudioInput(encoding="linear16", sample_rate=44100)), @@ -80,7 +82,7 @@ def main() -> None: with DeepgramClient().agent.v1.connect() as agent: - def on_message(message: object) -> None: + def on_message(message: Any) -> None: nonlocal force_end_turn_error message_type = getattr(message, "type", None) if message_type == "UserStartedSpeaking": @@ -117,12 +119,14 @@ def on_message(message: object) -> None: print("Sending ForceEndTurn") agent.send_force_end_turn() - if not wait_for_force_end_turn_outcome( + force_end_turn_failed = not wait_for_force_end_turn_outcome( agent_finished, force_end_turn_rejected, timeout_seconds=15, - ): - raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") + ) + + if force_end_turn_failed: + raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}") if __name__ == "__main__": diff --git a/reference.md b/reference.md index 85572c7d..8dd54c2d 100644 --- a/reference.md +++ b/reference.md @@ -5431,7 +5431,7 @@ asyncio.run(main()) **`send_force_end_turn()`** — Force the current turn to end immediately - `connection.send_force_end_turn()` — The resulting `EndOfTurn` event reports `trigger="manual"` (turns ended by end-of-turn detection report `"model"`, and turns ended by `eot_timeout_ms` report `"timeout"`) -- Requires server-side enablement; deployments without the feature reject the message and close the connection +- Available on Deepgram-hosted deployments (including EU and AU). Self-hosted deployments need the 2026-09-15 release or later with the ForceEndTurn feature enabled; unsupported deployments reply UNPARSABLE_CLIENT_MESSAGE and close the connection From 5798467e90f1a4543d6a0f6cae255a1e40e4fca6 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 16 Oct 2026 10:50:06 +0100 Subject: [PATCH 6/7] test: cover ForceEndTurn rejection cleanup --- ...test_voice_agent_force_end_turn_example.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/custom/test_voice_agent_force_end_turn_example.py b/tests/custom/test_voice_agent_force_end_turn_example.py index a8c9b3db..ec6803af 100644 --- a/tests/custom/test_voice_agent_force_end_turn_example.py +++ b/tests/custom/test_voice_agent_force_end_turn_example.py @@ -4,6 +4,8 @@ import types from pathlib import Path +import pytest + EXAMPLE_PATH = Path(__file__).parents[2] / "examples" / "32-voice-agent-force-end-turn.py" @@ -43,3 +45,61 @@ def test_agent_audio_done_waits_for_late_force_end_turn_rejection(monkeypatch): timeout_seconds=1, ) assert force_end_turn_rejected.wait_calls == 1 + + +def test_force_end_turn_rejection_leaves_context_before_raising(monkeypatch): + example = load_example(monkeypatch) + + class FakeAgent: + def __init__(self): + self.message_handler = None + self.user_started = False + + def on(self, event, callback): + if event == example.EventType.MESSAGE: + self.message_handler = callback + + def start_listening(self): + pass + + def send_settings(self, _settings): + pass + + def send_media(self, _audio): + if not self.user_started: + self.user_started = True + self.message_handler(types.SimpleNamespace(type="UserStartedSpeaking")) + + def send_force_end_turn(self): + self.message_handler(types.SimpleNamespace(type="AgentAudioDone")) + self.message_handler( + types.SimpleNamespace( + type="Warning", + code="FORCE_END_TURN_UNSUPPORTED", + description="ForceEndTurn is unsupported", + ) + ) + + class FakeAgentContext: + def __init__(self): + self.agent = FakeAgent() + self.exit_exception_type = None + + def __enter__(self): + return self.agent + + def __exit__(self, exc_type, _exc_value, _traceback): + self.exit_exception_type = exc_type + + context = FakeAgentContext() + + class FakeDeepgramClient: + def __init__(self): + self.agent = types.SimpleNamespace(v1=types.SimpleNamespace(connect=lambda: context)) + + monkeypatch.setattr(example, "DeepgramClient", FakeDeepgramClient) + + with pytest.raises(RuntimeError, match="FORCE_END_TURN_UNSUPPORTED"): + example.main() + + assert context.exit_exception_type is None From 1db8e8a13efbae711343ef20d18b44db91585d40 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 16 Oct 2026 17:00:19 -0400 Subject: [PATCH 7/7] docs: align ForceEndTurn wording and defer example timeouts Replace remaining "gated" wording with unsupported-deployment phrasing, raise example 32 timeouts after the connection closes so they no longer print a spurious 1011 connection error, and apply ruff format. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- examples/16-transcription-force-end-turn.py | 4 +-- examples/32-voice-agent-force-end-turn.py | 32 ++++++++++++--------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a7e660d..964c02fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Current permanently frozen files: - `tests/custom/test_listen_v1_configure.py` — hand-written regression coverage for Listen V1 Configure message serialization and typed Error response parsing on both synchronous and asynchronous websocket clients - `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only subscript compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, and scope isolation from unrelated generated models - `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct -- `tests/custom/test_regen_response_fields.py` — hand-written regression test guarding the response fields added by the 2026-08-18 regen: `ListenV1Response/ResultsMetadata.diarize_info` (`model_uuid`, `arch`), per-word `speaker` / `speaker_confidence`, and `ListenV2TurnInfo.trigger`. Same rationale as `test_latency_report_stt_compat.py`: `UncheckedBaseModel` + `skip_validation` makes response parsing lenient, so a future spec removal would silently turn these into `None` rather than failing. Fixtures are real captured payloads; the `trigger` cases pin the parsing contract for the values the server sends (`model` / `manual` / `timeout`) plus the absent case, which is what a deployment without the server-gated `ForceEndTurn` feature returns. Live coverage of the same field lives in `tests/manual/listen/v2/force_end_turn/` +- `tests/custom/test_regen_response_fields.py` — hand-written regression test guarding the response fields added by the 2026-08-18 regen: `ListenV1Response/ResultsMetadata.diarize_info` (`model_uuid`, `arch`), per-word `speaker` / `speaker_confidence`, and `ListenV2TurnInfo.trigger`. Same rationale as `test_latency_report_stt_compat.py`: `UncheckedBaseModel` + `skip_validation` makes response parsing lenient, so a future spec removal would silently turn these into `None` rather than failing. Fixtures are real captured payloads; the `trigger` cases pin the parsing contract for the values the server sends (`model` / `manual` / `timeout`) plus the absent case, which is what a deployment without ForceEndTurn enabled (self-hosted without the feature flag) returns. Live coverage of the same field lives in `tests/manual/listen/v2/force_end_turn/` - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) - `tests/custom/test_voice_agent_force_end_turn_example.py` — hand-written regression coverage that the Voice Agent ForceEndTurn example waits for a rejection queued after `AgentAudioDone` diff --git a/examples/16-transcription-force-end-turn.py b/examples/16-transcription-force-end-turn.py index cb37b79f..015a219f 100644 --- a/examples/16-transcription-force-end-turn.py +++ b/examples/16-transcription-force-end-turn.py @@ -122,7 +122,7 @@ def send_audio() -> None: try: connection.send_media(audio[i : i + chunk_size]) except Exception: - # A gated deployment closes the socket rather than ignoring the + # An unsupported deployment closes the socket rather than ignoring the # message, so later sends fail. Stop streaming. break time.sleep(0.1) @@ -149,7 +149,7 @@ def send_audio() -> None: # This blocks until the connection closes connection.start_listening() - # Reported here rather than in the sender thread: on a gated deployment the server + # Reported here rather than in the sender thread: on an unsupported deployment the server # closes the connection, start_listening() returns, and the daemon thread is killed # before it could print anything. if feature_disabled.is_set(): diff --git a/examples/32-voice-agent-force-end-turn.py b/examples/32-voice-agent-force-end-turn.py index 89e95a5f..1815539c 100644 --- a/examples/32-voice-agent-force-end-turn.py +++ b/examples/32-voice-agent-force-end-turn.py @@ -52,9 +52,7 @@ def wait_for_force_end_turn_outcome( raise TimeoutError("Timed out waiting for the agent response") if agent_finished.wait(min(0.1, remaining)): # AgentAudioDone can precede FORCE_END_TURN_UNSUPPORTED in the socket reader. - return not force_end_turn_rejected.wait( - min(FORCE_END_TURN_REJECTION_GRACE_SECONDS, remaining) - ) + return not force_end_turn_rejected.wait(min(FORCE_END_TURN_REJECTION_GRACE_SECONDS, remaining)) def main() -> None: @@ -63,6 +61,7 @@ def main() -> None: force_end_turn_rejected = threading.Event() force_end_turn_error: str | None = None force_end_turn_failed = False + timeout_error: TimeoutError | None = None settings = AgentV1Settings( audio=AgentV1SettingsAudio(input=AgentV1SettingsAudioInput(encoding="linear16", sample_rate=44100)), @@ -114,17 +113,24 @@ def on_message(message: Any) -> None: if user_started.is_set(): break + # Errors are raised after the connection closes, so the close does not + # report a spurious connection error on top of them. if not user_started.is_set(): - raise TimeoutError("Timed out waiting for UserStartedSpeaking") - - print("Sending ForceEndTurn") - agent.send_force_end_turn() - force_end_turn_failed = not wait_for_force_end_turn_outcome( - agent_finished, - force_end_turn_rejected, - timeout_seconds=15, - ) - + timeout_error = TimeoutError("Timed out waiting for UserStartedSpeaking") + else: + print("Sending ForceEndTurn") + agent.send_force_end_turn() + try: + force_end_turn_failed = not wait_for_force_end_turn_outcome( + agent_finished, + force_end_turn_rejected, + timeout_seconds=15, + ) + except TimeoutError as error: + timeout_error = error + + if timeout_error is not None: + raise timeout_error if force_end_turn_failed: raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}")