From d09de8de5729cc9e43c2e40ff9e43637e6d2abea Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Fri, 27 Mar 2026 19:47:34 +0000 Subject: [PATCH 1/2] fix(websockets): restore optional message param on control send_ methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control messages (keep-alive, close-stream, finalize, flush, clear, close) carry no meaningful payload — the type field is the whole message. Making the message param optional with a typed default means users can call e.g. send_keep_alive() without constructing the type themselves. Payload-carrying methods (send_text, send_media, send_settings, etc.) remain required. This restores behaviour lost in the last regen and fixes a breaking change shipped in a minor patch. --- .fernignore | 2 ++ src/deepgram/agent/v1/socket_client.py | 8 ++++---- src/deepgram/listen/v1/socket_client.py | 24 ++++++++++++------------ src/deepgram/listen/v2/socket_client.py | 8 ++++---- src/deepgram/speak/v1/socket_client.py | 24 ++++++++++++------------ 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/.fernignore b/.fernignore index 19521f0c..95cce1f0 100644 --- a/.fernignore +++ b/.fernignore @@ -8,6 +8,8 @@ src/deepgram/client.py # - construct_type keyword args fix (generator uses positional, function requires keyword-only) # - except Exception broad catch (supports custom transports, generator narrows to WebSocketException) # - _sanitize_numeric_types in agent socket client (float→int for API) +# - optional message param on control send_ methods (send_keep_alive, send_close_stream, etc.) +# so users don't need to instantiate the type themselves for no-payload control messages # [temporarily frozen — generator bugs in construct_type call convention and exception handling] src/deepgram/agent/v1/socket_client.py src/deepgram/listen/v1/socket_client.py diff --git a/src/deepgram/agent/v1/socket_client.py b/src/deepgram/agent/v1/socket_client.py index 1ef9014d..aa5460a8 100644 --- a/src/deepgram/agent/v1/socket_client.py +++ b/src/deepgram/agent/v1/socket_client.py @@ -159,12 +159,12 @@ async def send_function_call_response(self, message: AgentV1SendFunctionCallResp """ await self._send_model(message) - async def send_keep_alive(self, message: AgentV1KeepAlive) -> None: + async def send_keep_alive(self, message: typing.Optional[AgentV1KeepAlive] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a AgentV1KeepAlive. """ - await self._send_model(message) + await self._send_model(message or AgentV1KeepAlive()) async def send_update_prompt(self, message: AgentV1UpdatePrompt) -> None: """ @@ -292,12 +292,12 @@ def send_function_call_response(self, message: AgentV1SendFunctionCallResponse) """ self._send_model(message) - def send_keep_alive(self, message: AgentV1KeepAlive) -> None: + def send_keep_alive(self, message: typing.Optional[AgentV1KeepAlive] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a AgentV1KeepAlive. """ - self._send_model(message) + self._send_model(message or AgentV1KeepAlive()) def send_update_prompt(self, message: AgentV1UpdatePrompt) -> None: """ diff --git a/src/deepgram/listen/v1/socket_client.py b/src/deepgram/listen/v1/socket_client.py index 68feddb6..387c107f 100644 --- a/src/deepgram/listen/v1/socket_client.py +++ b/src/deepgram/listen/v1/socket_client.py @@ -81,26 +81,26 @@ async def send_media(self, message: bytes) -> None: """ await self._send(message) - async def send_finalize(self, message: ListenV1Finalize) -> None: + async def send_finalize(self, message: typing.Optional[ListenV1Finalize] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1Finalize. """ - await self._send_model(message) + await self._send_model(message or ListenV1Finalize(type="Finalize")) - async def send_close_stream(self, message: ListenV1CloseStream) -> None: + async def send_close_stream(self, message: typing.Optional[ListenV1CloseStream] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1CloseStream. """ - await self._send_model(message) + await self._send_model(message or ListenV1CloseStream(type="CloseStream")) - async def send_keep_alive(self, message: ListenV1KeepAlive) -> None: + async def send_keep_alive(self, message: typing.Optional[ListenV1KeepAlive] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1KeepAlive. """ - await self._send_model(message) + await self._send_model(message or ListenV1KeepAlive(type="KeepAlive")) async def recv(self) -> V1SocketClientResponse: """ @@ -186,26 +186,26 @@ def send_media(self, message: bytes) -> None: """ self._send(message) - def send_finalize(self, message: ListenV1Finalize) -> None: + def send_finalize(self, message: typing.Optional[ListenV1Finalize] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1Finalize. """ - self._send_model(message) + self._send_model(message or ListenV1Finalize(type="Finalize")) - def send_close_stream(self, message: ListenV1CloseStream) -> None: + def send_close_stream(self, message: typing.Optional[ListenV1CloseStream] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1CloseStream. """ - self._send_model(message) + self._send_model(message or ListenV1CloseStream(type="CloseStream")) - def send_keep_alive(self, message: ListenV1KeepAlive) -> None: + def send_keep_alive(self, message: typing.Optional[ListenV1KeepAlive] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV1KeepAlive. """ - self._send_model(message) + self._send_model(message or ListenV1KeepAlive(type="KeepAlive")) def recv(self) -> V1SocketClientResponse: """ diff --git a/src/deepgram/listen/v2/socket_client.py b/src/deepgram/listen/v2/socket_client.py index 0811393c..4bf24c36 100644 --- a/src/deepgram/listen/v2/socket_client.py +++ b/src/deepgram/listen/v2/socket_client.py @@ -78,12 +78,12 @@ async def send_media(self, message: bytes) -> None: """ await self._send(message) - async def send_close_stream(self, message: ListenV2CloseStream) -> None: + async def send_close_stream(self, message: typing.Optional[ListenV2CloseStream] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV2CloseStream. """ - await self._send_model(message) + await self._send_model(message or ListenV2CloseStream(type="CloseStream")) async def recv(self) -> V2SocketClientResponse: """ @@ -169,12 +169,12 @@ def send_media(self, message: bytes) -> None: """ self._send(message) - def send_close_stream(self, message: ListenV2CloseStream) -> None: + def send_close_stream(self, message: typing.Optional[ListenV2CloseStream] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a ListenV2CloseStream. """ - self._send_model(message) + self._send_model(message or ListenV2CloseStream(type="CloseStream")) def recv(self) -> V2SocketClientResponse: """ diff --git a/src/deepgram/speak/v1/socket_client.py b/src/deepgram/speak/v1/socket_client.py index e71ba038..671e0bd2 100644 --- a/src/deepgram/speak/v1/socket_client.py +++ b/src/deepgram/speak/v1/socket_client.py @@ -82,26 +82,26 @@ async def send_text(self, message: SpeakV1Text) -> None: """ await self._send_model(message) - async def send_flush(self, message: SpeakV1Flush) -> None: + async def send_flush(self, message: typing.Optional[SpeakV1Flush] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Flush. """ - await self._send_model(message) + await self._send_model(message or SpeakV1Flush(type="Flush")) - async def send_clear(self, message: SpeakV1Clear) -> None: + async def send_clear(self, message: typing.Optional[SpeakV1Clear] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Clear. """ - await self._send_model(message) + await self._send_model(message or SpeakV1Clear(type="Clear")) - async def send_close(self, message: SpeakV1Close) -> None: + async def send_close(self, message: typing.Optional[SpeakV1Close] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Close. """ - await self._send_model(message) + await self._send_model(message or SpeakV1Close(type="Close")) async def recv(self) -> V1SocketClientResponse: """ @@ -187,26 +187,26 @@ def send_text(self, message: SpeakV1Text) -> None: """ self._send_model(message) - def send_flush(self, message: SpeakV1Flush) -> None: + def send_flush(self, message: typing.Optional[SpeakV1Flush] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Flush. """ - self._send_model(message) + self._send_model(message or SpeakV1Flush(type="Flush")) - def send_clear(self, message: SpeakV1Clear) -> None: + def send_clear(self, message: typing.Optional[SpeakV1Clear] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Clear. """ - self._send_model(message) + self._send_model(message or SpeakV1Clear(type="Clear")) - def send_close(self, message: SpeakV1Close) -> None: + def send_close(self, message: typing.Optional[SpeakV1Close] = None) -> None: """ Send a message to the websocket connection. The message will be sent as a SpeakV1Close. """ - self._send_model(message) + self._send_model(message or SpeakV1Close(type="Close")) def recv(self) -> V1SocketClientResponse: """ From f8c1e429d6ed890cd535d0607006077ced2ff48b Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Fri, 27 Mar 2026 19:51:14 +0000 Subject: [PATCH 2/2] test(websockets): add regression tests for no-arg control send_ methods Also fixes AgentV1KeepAlive default to include type='KeepAlive' (bare AgentV1KeepAlive() fails Pydantic validation since type is required). --- src/deepgram/agent/v1/socket_client.py | 4 +- .../custom/test_websocket_control_messages.py | 186 ++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 tests/custom/test_websocket_control_messages.py diff --git a/src/deepgram/agent/v1/socket_client.py b/src/deepgram/agent/v1/socket_client.py index aa5460a8..ce3e4aa1 100644 --- a/src/deepgram/agent/v1/socket_client.py +++ b/src/deepgram/agent/v1/socket_client.py @@ -164,7 +164,7 @@ async def send_keep_alive(self, message: typing.Optional[AgentV1KeepAlive] = Non Send a message to the websocket connection. The message will be sent as a AgentV1KeepAlive. """ - await self._send_model(message or AgentV1KeepAlive()) + await self._send_model(message or AgentV1KeepAlive(type="KeepAlive")) async def send_update_prompt(self, message: AgentV1UpdatePrompt) -> None: """ @@ -297,7 +297,7 @@ def send_keep_alive(self, message: typing.Optional[AgentV1KeepAlive] = None) -> Send a message to the websocket connection. The message will be sent as a AgentV1KeepAlive. """ - self._send_model(message or AgentV1KeepAlive()) + self._send_model(message or AgentV1KeepAlive(type="KeepAlive")) def send_update_prompt(self, message: AgentV1UpdatePrompt) -> None: """ diff --git a/tests/custom/test_websocket_control_messages.py b/tests/custom/test_websocket_control_messages.py new file mode 100644 index 00000000..68ff778a --- /dev/null +++ b/tests/custom/test_websocket_control_messages.py @@ -0,0 +1,186 @@ +"""Tests that control send_ methods work without requiring a message argument. + +Regression test for the breaking change where optional message params were lost +during a Fern regen, causing TypeError for callers using no-arg control calls. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from deepgram.agent.v1.socket_client import AsyncV1SocketClient as AsyncAgentV1SocketClient +from deepgram.agent.v1.socket_client import V1SocketClient as AgentV1SocketClient +from deepgram.listen.v1.socket_client import AsyncV1SocketClient as AsyncListenV1SocketClient +from deepgram.listen.v1.socket_client import V1SocketClient as ListenV1SocketClient +from deepgram.listen.v2.socket_client import AsyncV2SocketClient as AsyncListenV2SocketClient +from deepgram.listen.v2.socket_client import V2SocketClient as ListenV2SocketClient +from deepgram.speak.v1.socket_client import AsyncV1SocketClient as AsyncSpeakV1SocketClient +from deepgram.speak.v1.socket_client import V1SocketClient as SpeakV1SocketClient + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_async_ws(): + ws = AsyncMock() + ws.send = AsyncMock() + return ws + + +def _make_sync_ws(): + ws = MagicMock() + ws.send = MagicMock() + return ws + + +def _sent_json(ws): + """Return the parsed JSON from the first send() call.""" + call_args = ws.send.call_args + data = call_args[0][0] + return json.loads(data) + + +# --------------------------------------------------------------------------- +# speak/v1 — async +# --------------------------------------------------------------------------- + +class TestAsyncSpeakV1ControlMessages: + async def test_send_flush_no_args(self): + ws = _make_async_ws() + client = AsyncSpeakV1SocketClient(websocket=ws) + await client.send_flush() + assert _sent_json(ws)["type"] == "Flush" + + async def test_send_clear_no_args(self): + ws = _make_async_ws() + client = AsyncSpeakV1SocketClient(websocket=ws) + await client.send_clear() + assert _sent_json(ws)["type"] == "Clear" + + async def test_send_close_no_args(self): + ws = _make_async_ws() + client = AsyncSpeakV1SocketClient(websocket=ws) + await client.send_close() + assert _sent_json(ws)["type"] == "Close" + + +# --------------------------------------------------------------------------- +# speak/v1 — sync +# --------------------------------------------------------------------------- + +class TestSyncSpeakV1ControlMessages: + def test_send_flush_no_args(self): + ws = _make_sync_ws() + client = SpeakV1SocketClient(websocket=ws) + client.send_flush() + assert _sent_json(ws)["type"] == "Flush" + + def test_send_clear_no_args(self): + ws = _make_sync_ws() + client = SpeakV1SocketClient(websocket=ws) + client.send_clear() + assert _sent_json(ws)["type"] == "Clear" + + def test_send_close_no_args(self): + ws = _make_sync_ws() + client = SpeakV1SocketClient(websocket=ws) + client.send_close() + assert _sent_json(ws)["type"] == "Close" + + +# --------------------------------------------------------------------------- +# listen/v1 — async +# --------------------------------------------------------------------------- + +class TestAsyncListenV1ControlMessages: + async def test_send_finalize_no_args(self): + ws = _make_async_ws() + client = AsyncListenV1SocketClient(websocket=ws) + await client.send_finalize() + assert _sent_json(ws)["type"] == "Finalize" + + async def test_send_close_stream_no_args(self): + ws = _make_async_ws() + client = AsyncListenV1SocketClient(websocket=ws) + await client.send_close_stream() + assert _sent_json(ws)["type"] == "CloseStream" + + async def test_send_keep_alive_no_args(self): + ws = _make_async_ws() + client = AsyncListenV1SocketClient(websocket=ws) + await client.send_keep_alive() + assert _sent_json(ws)["type"] == "KeepAlive" + + +# --------------------------------------------------------------------------- +# listen/v1 — sync +# --------------------------------------------------------------------------- + +class TestSyncListenV1ControlMessages: + def test_send_finalize_no_args(self): + ws = _make_sync_ws() + client = ListenV1SocketClient(websocket=ws) + client.send_finalize() + assert _sent_json(ws)["type"] == "Finalize" + + def test_send_close_stream_no_args(self): + ws = _make_sync_ws() + client = ListenV1SocketClient(websocket=ws) + client.send_close_stream() + assert _sent_json(ws)["type"] == "CloseStream" + + def test_send_keep_alive_no_args(self): + ws = _make_sync_ws() + client = ListenV1SocketClient(websocket=ws) + client.send_keep_alive() + assert _sent_json(ws)["type"] == "KeepAlive" + + +# --------------------------------------------------------------------------- +# listen/v2 — async +# --------------------------------------------------------------------------- + +class TestAsyncListenV2ControlMessages: + async def test_send_close_stream_no_args(self): + ws = _make_async_ws() + client = AsyncListenV2SocketClient(websocket=ws) + await client.send_close_stream() + assert _sent_json(ws)["type"] == "CloseStream" + + +# --------------------------------------------------------------------------- +# listen/v2 — sync +# --------------------------------------------------------------------------- + +class TestSyncListenV2ControlMessages: + def test_send_close_stream_no_args(self): + ws = _make_sync_ws() + client = ListenV2SocketClient(websocket=ws) + client.send_close_stream() + assert _sent_json(ws)["type"] == "CloseStream" + + +# --------------------------------------------------------------------------- +# agent/v1 — async +# --------------------------------------------------------------------------- + +class TestAsyncAgentV1ControlMessages: + async def test_send_keep_alive_no_args(self): + ws = _make_async_ws() + client = AsyncAgentV1SocketClient(websocket=ws) + await client.send_keep_alive() + assert _sent_json(ws)["type"] == "KeepAlive" + + +# --------------------------------------------------------------------------- +# agent/v1 — sync +# --------------------------------------------------------------------------- + +class TestSyncAgentV1ControlMessages: + def test_send_keep_alive_no_args(self): + ws = _make_sync_ws() + client = AgentV1SocketClient(websocket=ws) + client.send_keep_alive() + assert _sent_json(ws)["type"] == "KeepAlive"