Skip to content

Commit eed4a23

Browse files
committed
feat(realtime): default to passthrough when no initial prompt is provided
Port JS SDK PR #93 to Python SDK. When connecting without an initial prompt or image, send a passthrough set_image message (null image_data + null prompt) so the server receives an explicit initial state. Changes: - Add passthrough branch in connect() for when neither initial_image nor initial_prompt is provided (skipped in subscribe mode) - Add _send_passthrough_and_wait() method - Use exclude_unset serialization for SetAvatarImageMessage so explicitly-passed None values serialize as JSON null - Fix pre-existing bug: _handle_error now resolves pending Phase-2 waits (image/prompt) so server errors fail fast instead of timing out - Add 6 tests covering passthrough, subscribe skip, and fail-fast error handling for all Phase-2 paths
1 parent c762747 commit eed4a23

3 files changed

Lines changed: 222 additions & 2 deletions

File tree

decart/realtime/messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,9 @@ def message_to_json(message: OutgoingMessage) -> str:
187187
Returns:
188188
JSON string
189189
"""
190+
# SetAvatarImageMessage uses exclude_unset so explicitly-passed None values
191+
# (e.g. image_data=None, prompt=None for passthrough) are serialized as null,
192+
# while fields that were never set are omitted.
193+
if isinstance(message, SetAvatarImageMessage):
194+
return message.model_dump_json(exclude_unset=True)
190195
return message.model_dump_json(exclude_none=True)

decart/realtime/webrtc_connection.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ async def connect(
9898
)
9999
elif initial_prompt:
100100
await self._send_initial_prompt_and_wait(initial_prompt)
101-
101+
elif local_track is not None:
102+
# No image and no prompt — send passthrough (skip for subscribe mode which has no local stream)
103+
await self._send_passthrough_and_wait()
102104
await self._setup_peer_connection(local_track, model_name=model_name)
103105

104106
await self._create_and_send_offer()
@@ -172,6 +174,32 @@ async def _send_initial_prompt_and_wait(self, prompt: dict, timeout: float = 15.
172174
finally:
173175
self.unregister_prompt_wait(prompt_text)
174176

177+
async def _send_passthrough_and_wait(self, timeout: float = 30.0) -> None:
178+
"""Send passthrough set_image (null image + null prompt) and wait for ack.
179+
180+
When connecting without an initial prompt or image, the server still
181+
expects an explicit initial state. Sending image_data=null + prompt=null
182+
tells the server to use passthrough mode.
183+
"""
184+
event, result = self.register_image_set_wait()
185+
186+
try:
187+
message = SetAvatarImageMessage(
188+
type="set_image", image_data=None, prompt=None
189+
)
190+
await self._send_message(message)
191+
192+
try:
193+
await asyncio.wait_for(event.wait(), timeout=timeout)
194+
except asyncio.TimeoutError:
195+
raise WebRTCError("Passthrough acknowledgment timed out")
196+
197+
if not result["success"]:
198+
raise WebRTCError(
199+
f"Failed to send passthrough: {result.get('error', 'unknown error')}"
200+
)
201+
finally:
202+
self.unregister_image_set_wait()
175203
async def _setup_peer_connection(
176204
self,
177205
local_track: Optional[MediaStreamTrack],
@@ -347,9 +375,22 @@ def _handle_set_image_ack(self, message: SetImageAckMessage) -> None:
347375
def _handle_error(self, message: ErrorMessage) -> None:
348376
logger.error(f"Received error from server: {message.error}")
349377
error = WebRTCError(message.error)
378+
379+
# Fail-fast: resolve any pending Phase-2 waits so they surface the
380+
# real server error instead of timing out after 30 s.
381+
if self._pending_image_set:
382+
event, result = self._pending_image_set
383+
result["success"] = False
384+
result["error"] = message.error
385+
event.set()
386+
387+
for _prompt, (event, result) in list(self._pending_prompts.items()):
388+
result["success"] = False
389+
result["error"] = message.error
390+
event.set()
391+
350392
if self._on_error:
351393
self._on_error(error)
352-
353394
async def _handle_ice_restart(self, message: IceRestartMessage) -> None:
354395
logger.info("Received ICE restart request from server")
355396
turn_config = message.turn_config

tests/test_realtime_unit.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,3 +1094,177 @@ async def test_image_to_base64_file_path_string(tmp_path):
10941094
mock_session = MagicMock()
10951095
result = await _image_to_base64(str(img), mock_session)
10961096
assert result == base64.b64encode(b"PNGDATA").decode("utf-8")
1097+
1098+
1099+
# Tests for passthrough mode (no initial prompt/image)
1100+
1101+
1102+
@pytest.mark.asyncio
1103+
async def test_connect_without_initial_state_sends_passthrough():
1104+
"""Connecting without prompt/image sends passthrough set_image (null image + null prompt)."""
1105+
client = DecartClient(api_key="test-key")
1106+
1107+
with (
1108+
patch("decart.realtime.client.WebRTCManager") as mock_manager_class,
1109+
patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls,
1110+
):
1111+
mock_manager = AsyncMock()
1112+
mock_manager.connect = AsyncMock(return_value=True)
1113+
mock_manager.is_connected = MagicMock(return_value=True)
1114+
mock_manager_class.return_value = mock_manager
1115+
1116+
mock_session = MagicMock()
1117+
mock_session.closed = False
1118+
mock_session.close = AsyncMock()
1119+
mock_session_cls.return_value = mock_session
1120+
1121+
mock_track = MagicMock()
1122+
1123+
from decart.realtime.types import RealtimeConnectOptions
1124+
1125+
realtime_client = await RealtimeClient.connect(
1126+
base_url=client.base_url,
1127+
api_key=client.api_key,
1128+
local_track=mock_track,
1129+
options=RealtimeConnectOptions(
1130+
model=models.realtime("mirage"),
1131+
on_remote_stream=lambda t: None,
1132+
# No initial_state — should trigger passthrough
1133+
),
1134+
)
1135+
1136+
assert realtime_client is not None
1137+
mock_manager.connect.assert_called_once()
1138+
call_kwargs = mock_manager.connect.call_args[1]
1139+
# initial_image and initial_prompt should both be None
1140+
assert call_kwargs.get("initial_image") is None
1141+
assert call_kwargs.get("initial_prompt") is None
1142+
1143+
1144+
@pytest.mark.asyncio
1145+
async def test_passthrough_sends_set_image_with_null_prompt():
1146+
"""_send_passthrough_and_wait sends set_image with null image_data and null prompt."""
1147+
from decart.realtime.webrtc_connection import WebRTCConnection
1148+
1149+
connection = WebRTCConnection()
1150+
1151+
sent_messages: list = []
1152+
1153+
async def capture_send(message):
1154+
sent_messages.append(message)
1155+
# Simulate set_image_ack arriving immediately (like FakeWebSocket in JS tests)
1156+
if connection._pending_image_set:
1157+
event, result = connection._pending_image_set
1158+
result["success"] = True
1159+
event.set()
1160+
1161+
connection._send_message = capture_send # type: ignore[assignment]
1162+
1163+
await connection._send_passthrough_and_wait()
1164+
1165+
assert len(sent_messages) == 1
1166+
msg = sent_messages[0]
1167+
assert msg.type == "set_image"
1168+
assert msg.image_data is None
1169+
assert msg.prompt is None
1170+
1171+
# Verify JSON serialization includes null values
1172+
from decart.realtime.messages import message_to_json
1173+
import json
1174+
1175+
json_str = message_to_json(msg)
1176+
parsed = json.loads(json_str)
1177+
assert parsed == {"type": "set_image", "image_data": None, "prompt": None}
1178+
1179+
1180+
@pytest.mark.asyncio
1181+
async def test_subscribe_mode_skips_passthrough():
1182+
"""Subscribe mode (null local_track) must not send passthrough set_image."""
1183+
client = DecartClient(api_key="test-key")
1184+
1185+
with (
1186+
patch("decart.realtime.client.WebRTCManager") as mock_manager_class,
1187+
):
1188+
mock_manager = AsyncMock()
1189+
mock_manager.connect = AsyncMock(return_value=True)
1190+
mock_manager_class.return_value = mock_manager
1191+
1192+
# subscribe() passes local_track=None internally
1193+
from decart.realtime.subscribe import SubscribeClient, encode_subscribe_token
1194+
1195+
token = encode_subscribe_token("test-sid", "1.2.3.4", 8080)
1196+
1197+
from decart.realtime.subscribe import SubscribeOptions
1198+
1199+
sub_client = await RealtimeClient.subscribe(
1200+
base_url=client.base_url,
1201+
api_key=client.api_key,
1202+
options=SubscribeOptions(
1203+
token=token,
1204+
on_remote_stream=lambda t: None,
1205+
),
1206+
)
1207+
1208+
assert sub_client is not None
1209+
# Verify connect was called with local_track=None (subscribe mode)
1210+
mock_manager.connect.assert_called_once()
1211+
call_args = mock_manager.connect.call_args
1212+
assert call_args[0][0] is None # first positional arg is local_track=None
1213+
1214+
1215+
@pytest.mark.asyncio
1216+
async def test_server_error_during_passthrough_fails_fast():
1217+
"""Server error during passthrough surfaces real error instead of 30s timeout."""
1218+
from decart.realtime.webrtc_connection import WebRTCConnection
1219+
from decart.realtime.messages import ErrorMessage
1220+
from decart.errors import WebRTCError
1221+
1222+
connection = WebRTCConnection()
1223+
1224+
async def fake_send(message):
1225+
# Simulate the server responding with an error instead of set_image_ack
1226+
await asyncio.sleep(0) # yield so wait_for is listening
1227+
connection._handle_error(ErrorMessage(type="error", error="insufficient_credits"))
1228+
1229+
connection._send_message = fake_send # type: ignore[assignment]
1230+
1231+
with pytest.raises(WebRTCError, match="insufficient_credits"):
1232+
await connection._send_passthrough_and_wait()
1233+
1234+
1235+
@pytest.mark.asyncio
1236+
async def test_server_error_during_initial_image_fails_fast():
1237+
"""Server error during initial image setup surfaces real error (pre-existing fix)."""
1238+
from decart.realtime.webrtc_connection import WebRTCConnection
1239+
from decart.realtime.messages import ErrorMessage
1240+
from decart.errors import WebRTCError
1241+
1242+
connection = WebRTCConnection()
1243+
1244+
async def fake_send(message):
1245+
await asyncio.sleep(0)
1246+
connection._handle_error(ErrorMessage(type="error", error="invalid_image"))
1247+
1248+
connection._send_message = fake_send # type: ignore[assignment]
1249+
1250+
with pytest.raises(WebRTCError, match="invalid_image"):
1251+
await connection._send_initial_image_and_wait("base64data")
1252+
1253+
1254+
@pytest.mark.asyncio
1255+
async def test_server_error_during_initial_prompt_fails_fast():
1256+
"""Server error during initial prompt setup surfaces real error (pre-existing fix)."""
1257+
from decart.realtime.webrtc_connection import WebRTCConnection
1258+
from decart.realtime.messages import ErrorMessage
1259+
from decart.errors import WebRTCError
1260+
1261+
connection = WebRTCConnection()
1262+
1263+
async def fake_send(message):
1264+
await asyncio.sleep(0)
1265+
connection._handle_error(ErrorMessage(type="error", error="rate_limited"))
1266+
1267+
connection._send_message = fake_send # type: ignore[assignment]
1268+
1269+
with pytest.raises(WebRTCError, match="rate_limited"):
1270+
await connection._send_initial_prompt_and_wait({"text": "test", "enhance": True})

0 commit comments

Comments
 (0)