From 4715570acc45c83e680665ab53d534ea0a40f643 Mon Sep 17 00:00:00 2001 From: Nathan Price <125999937+TheCodeWrangler@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:17:25 -0500 Subject: [PATCH] [Bugfix][Full Duplex] Make runtime_control payloads JSON serializable (#5613) Signed-off-by: thecodewrangler Co-authored-by: Claude Opus 5 --- .../openai/test_runtime_control_redaction.py | 115 ++++++++++++++++++ .../fullduplex/openai/runtime_bridge.py | 13 ++ 2 files changed, 128 insertions(+) create mode 100644 tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py diff --git a/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py b/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py new file mode 100644 index 000000000..16bea686d --- /dev/null +++ b/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Regression tests for duplex ``runtime_control`` payload redaction. + +The redacted result is emitted on the WebSocket (for example +``session.created.runtime_control``), so it must always survive +``json.dumps``. Control results carry typed values straight from the engine -- +``fence`` and ``accepted_fence`` are ``DuplexFence`` instances added by +``DuplexControlClient.execute`` -- which previously reached +``WebSocket.send_json`` unconverted and killed the session at handshake. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from enum import Enum + +import pytest + +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.openai.runtime_bridge import NativeRuntimeBridgeMixin + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_redact = NativeRuntimeBridgeMixin._redact_runtime_control_result + + +def _control_result() -> dict[str, object]: + """The shape ``DuplexControlClient.execute`` returns for an open.""" + return { + "ok": True, + "operation": "open", + "session_id": "sess-1", + "fence": DuplexFence("sess-1", epoch=0, turn_id=0, response_seq=0, incarnation=0), + "accepted_fence": DuplexFence("sess-1", epoch=1, turn_id=2, response_seq=3, incarnation=4), + "stage_results": [{"stage_id": -1, "replica_id": -1, "result": {"supported": True}}], + } + + +def test_redacted_control_result_is_json_serializable() -> None: + """The whole payload must serialize the way send_json does.""" + redacted = _redact(_control_result()) + # Must not raise. + json.dumps({"type": "session.created", "runtime_control": redacted}) + + +def test_fences_are_converted_to_plain_dicts() -> None: + redacted = _redact(_control_result()) + assert redacted["fence"] == { + "session_id": "sess-1", + "epoch": 0, + "turn_id": 0, + "response_seq": 0, + "incarnation": 0, + } + # Field values are preserved, not just the type. + assert redacted["accepted_fence"]["epoch"] == 1 + assert redacted["accepted_fence"]["turn_id"] == 2 + assert redacted["accepted_fence"]["response_seq"] == 3 + assert redacted["accepted_fence"]["incarnation"] == 4 + + +def test_nested_dataclasses_are_converted() -> None: + """Fences can appear inside stage results, not only at the top level.""" + redacted = _redact( + { + "stage_results": [ + {"stage_id": 0, "result": {"fence": DuplexFence("sess-2", epoch=7)}}, + ] + } + ) + json.dumps(redacted) + assert redacted["stage_results"][0]["result"]["fence"]["epoch"] == 7 + + +def test_enums_are_reduced_to_values() -> None: + class _Mode(str, Enum): + APPEND = "append_audio_chunk" + + redacted = _redact({"mode": _Mode.APPEND}) + json.dumps(redacted) + assert redacted["mode"] == "append_audio_chunk" + + +def test_redaction_still_drops_heavy_payload_keys() -> None: + """The pre-existing contract must be preserved.""" + redacted = _redact( + { + "ok": True, + "stage_handoff": object(), + "tts_handoff": object(), + "omni_payload": object(), + "tts_hidden_states": object(), + "tts_token_ids": object(), + "traceback": "long trace", + } + ) + assert redacted == {"ok": True} + + +def test_plain_values_pass_through_unchanged() -> None: + payload = {"ok": True, "count": 3, "name": "x", "missing": None, "items": [1, "two"]} + assert _redact(payload) == payload + + +def test_dataclass_types_are_not_treated_as_instances() -> None: + """A class object is not a dataclass instance and must not be asdict'd.""" + + @dataclass + class _Shape: + x: int = 1 + + assert _redact(_Shape) is _Shape diff --git a/vllm_omni/experimental/fullduplex/openai/runtime_bridge.py b/vllm_omni/experimental/fullduplex/openai/runtime_bridge.py index 3d6674621..258f512b6 100644 --- a/vllm_omni/experimental/fullduplex/openai/runtime_bridge.py +++ b/vllm_omni/experimental/fullduplex/openai/runtime_bridge.py @@ -4,6 +4,8 @@ import asyncio import base64 import inspect from collections.abc import Mapping +from dataclasses import asdict, is_dataclass +from enum import Enum import numpy as np from vllm.logger import init_logger @@ -620,6 +622,17 @@ class NativeRuntimeBridgeMixin: return redacted if isinstance(value, list | tuple): return [cls._redact_runtime_control_result(item) for item in value] + if is_dataclass(value) and not isinstance(value, type): + # Control results carry typed values straight from the engine: + # `fence` and `accepted_fence` are `DuplexFence` instances added by + # `DuplexControlClient.execute`. This redacted value is emitted on + # the wire (for example `session.created.runtime_control` in + # `session_runner`), where `WebSocket.send_json` would raise + # `TypeError: Object of type DuplexFence is not JSON serializable` + # and kill the session during the handshake. + return {key: cls._redact_runtime_control_result(child) for key, child in asdict(value).items()} + if isinstance(value, Enum): + return value.value return value async def _send_native_duplex_events(