Skip to content

Commit cc04baa

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 180ab13 commit cc04baa

File tree

3 files changed

+219
-1
lines changed

3 files changed

+219
-1
lines changed

decart/realtime/messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,9 @@ def message_to_json(message: OutgoingMessage) -> str:
171171
Returns:
172172
JSON string
173173
"""
174+
# SetAvatarImageMessage uses exclude_unset so explicitly-passed None values
175+
# (e.g. image_data=None, prompt=None for passthrough) are serialized as null,
176+
# while fields that were never set are omitted.
177+
if isinstance(message, SetAvatarImageMessage):
178+
return message.model_dump_json(exclude_unset=True)
174179
return message.model_dump_json(exclude_none=True)

decart/realtime/webrtc_connection.py

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

103105
await self._create_and_send_offer()
@@ -171,6 +173,31 @@ async def _send_initial_prompt_and_wait(self, prompt: dict, timeout: float = 15.
171173
finally:
172174
self.unregister_prompt_wait(prompt_text)
173175

176+
async def _send_passthrough_and_wait(self, timeout: float = 30.0) -> None:
177+
"""Send passthrough set_image (null image + null prompt) and wait for ack.
178+
179+
When connecting without an initial prompt or image, the server still
180+
expects an explicit initial state. Sending image_data=null + prompt=null
181+
tells the server to use passthrough mode.
182+
"""
183+
event, result = self.register_image_set_wait()
184+
185+
try:
186+
message = SetAvatarImageMessage(type="set_image", image_data=None, prompt=None)
187+
await self._send_message(message)
188+
189+
try:
190+
await asyncio.wait_for(event.wait(), timeout=timeout)
191+
except asyncio.TimeoutError:
192+
raise WebRTCError("Passthrough acknowledgment timed out")
193+
194+
if not result["success"]:
195+
raise WebRTCError(
196+
f"Failed to send passthrough: {result.get('error', 'unknown error')}"
197+
)
198+
finally:
199+
self.unregister_image_set_wait()
200+
174201
async def _setup_peer_connection(
175202
self,
176203
local_track: Optional[MediaStreamTrack],
@@ -344,6 +371,20 @@ def _handle_set_image_ack(self, message: SetImageAckMessage) -> None:
344371
def _handle_error(self, message: ErrorMessage) -> None:
345372
logger.error(f"Received error from server: {message.error}")
346373
error = WebRTCError(message.error)
374+
375+
# Fail-fast: resolve any pending Phase-2 waits so they surface the
376+
# real server error instead of timing out after 30 s.
377+
if self._pending_image_set:
378+
event, result = self._pending_image_set
379+
result["success"] = False
380+
result["error"] = message.error
381+
event.set()
382+
383+
for _prompt, (event, result) in list(self._pending_prompts.items()):
384+
result["success"] = False
385+
result["error"] = message.error
386+
event.set()
387+
347388
if self._on_error:
348389
self._on_error(error)
349390

tests/test_realtime_unit.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,3 +1094,175 @@ 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 (patch("decart.realtime.client.WebRTCManager") as mock_manager_class,):
1186+
mock_manager = AsyncMock()
1187+
mock_manager.connect = AsyncMock(return_value=True)
1188+
mock_manager_class.return_value = mock_manager
1189+
1190+
# subscribe() passes local_track=None internally
1191+
from decart.realtime.subscribe import SubscribeClient, encode_subscribe_token
1192+
1193+
token = encode_subscribe_token("test-sid", "1.2.3.4", 8080)
1194+
1195+
from decart.realtime.subscribe import SubscribeOptions
1196+
1197+
sub_client = await RealtimeClient.subscribe(
1198+
base_url=client.base_url,
1199+
api_key=client.api_key,
1200+
options=SubscribeOptions(
1201+
token=token,
1202+
on_remote_stream=lambda t: None,
1203+
),
1204+
)
1205+
1206+
assert sub_client is not None
1207+
# Verify connect was called with local_track=None (subscribe mode)
1208+
mock_manager.connect.assert_called_once()
1209+
call_args = mock_manager.connect.call_args
1210+
assert call_args[0][0] is None # first positional arg is local_track=None
1211+
1212+
1213+
@pytest.mark.asyncio
1214+
async def test_server_error_during_passthrough_fails_fast():
1215+
"""Server error during passthrough surfaces real error instead of 30s timeout."""
1216+
from decart.realtime.webrtc_connection import WebRTCConnection
1217+
from decart.realtime.messages import ErrorMessage
1218+
from decart.errors import WebRTCError
1219+
1220+
connection = WebRTCConnection()
1221+
1222+
async def fake_send(message):
1223+
# Simulate the server responding with an error instead of set_image_ack
1224+
await asyncio.sleep(0) # yield so wait_for is listening
1225+
connection._handle_error(ErrorMessage(type="error", error="insufficient_credits"))
1226+
1227+
connection._send_message = fake_send # type: ignore[assignment]
1228+
1229+
with pytest.raises(WebRTCError, match="insufficient_credits"):
1230+
await connection._send_passthrough_and_wait()
1231+
1232+
1233+
@pytest.mark.asyncio
1234+
async def test_server_error_during_initial_image_fails_fast():
1235+
"""Server error during initial image setup surfaces real error (pre-existing fix)."""
1236+
from decart.realtime.webrtc_connection import WebRTCConnection
1237+
from decart.realtime.messages import ErrorMessage
1238+
from decart.errors import WebRTCError
1239+
1240+
connection = WebRTCConnection()
1241+
1242+
async def fake_send(message):
1243+
await asyncio.sleep(0)
1244+
connection._handle_error(ErrorMessage(type="error", error="invalid_image"))
1245+
1246+
connection._send_message = fake_send # type: ignore[assignment]
1247+
1248+
with pytest.raises(WebRTCError, match="invalid_image"):
1249+
await connection._send_initial_image_and_wait("base64data")
1250+
1251+
1252+
@pytest.mark.asyncio
1253+
async def test_server_error_during_initial_prompt_fails_fast():
1254+
"""Server error during initial prompt setup surfaces real error (pre-existing fix)."""
1255+
from decart.realtime.webrtc_connection import WebRTCConnection
1256+
from decart.realtime.messages import ErrorMessage
1257+
from decart.errors import WebRTCError
1258+
1259+
connection = WebRTCConnection()
1260+
1261+
async def fake_send(message):
1262+
await asyncio.sleep(0)
1263+
connection._handle_error(ErrorMessage(type="error", error="rate_limited"))
1264+
1265+
connection._send_message = fake_send # type: ignore[assignment]
1266+
1267+
with pytest.raises(WebRTCError, match="rate_limited"):
1268+
await connection._send_initial_prompt_and_wait({"text": "test", "enhance": True})

0 commit comments

Comments
 (0)