Skip to content

Commit a444e42

Browse files
test: verify Live grouper disposal on a shared Python WebSocket (openai#3977)
When two transcript groupers observe the same Live dispatcher, callers must be able to detach and close one without stopping the other or losing the event stream. The existing grouper tests covered its own listeners and timers but did not exercise an attached connection. This tests both sync and async clients over a real local WebSocket. It checks that the first grouper cancels its active timer, typed and wildcard observers keep receiving the original values and future metadata, the second grouper continues accumulating, and the same socket accepts a post-disposal update. The peer waits for explicit caller teardown. Tests only; no generated or API changes. Validation: - 238 Live tests passed on Pydantic 2.12.5 and 238 on 1.10.26. - After typing the close callbacks and checking only event IDs present on the wire: 14 socket-role and grouper tests pass; Pyright reports 0 errors on the affected file. - Repository-configured `mypy .` passes across 1,920 source files; Ruff check, format and diff checks pass.
1 parent d0207b4 commit a444e42

1 file changed

Lines changed: 142 additions & 1 deletion

File tree

‎tests/lib/live/test_websocket_roles.py‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
from websockets.sync.server import ServerConnection
1010

1111
from openai import OpenAI, AsyncOpenAI
12-
from openai.types.live import SessionStartedEvent, SessionUpdatedEvent, OutputTranscriptDeltaEvent
12+
from openai.types.live import (
13+
ServerEvent,
14+
SessionClosedEvent,
15+
SessionStartedEvent,
16+
SessionUpdatedEvent,
17+
OutputTranscriptDeltaEvent,
18+
)
1319
from openai.resources.live.live import LiveConnection, AsyncLiveConnection
1420
from openai.resources.live.forks import ForksConnection, AsyncForksConnection
1521

22+
from .helpers import FakeClock, Recording, ClockGrouper, AsyncClockGrouper
1623
from ..responses.test_websocket_session import script_server
1724

1825

@@ -157,3 +164,137 @@ def script(socket: ServerConnection) -> None:
157164
assert isinstance(async_updated, SessionUpdatedEvent)
158165
assert async_updated.client_event_id == "caller-update"
159166
assert async_updated.session.id == "live_fixture"
167+
168+
169+
@pytest.mark.parametrize("mode", ["sync", "async"])
170+
async def test_disposed_grouper_leaves_dispatcher_other_observers_and_socket_usable(mode: str) -> None:
171+
session = {"id": "live_fixture", "model": "gpt-live-1", "status": "active", "expires_at": 123}
172+
first_clock, second_clock = FakeClock(), FakeClock()
173+
transcripts = [
174+
{
175+
"type": "session.output_transcript.delta",
176+
"event_id": f"part-{index}",
177+
"delta": value,
178+
"start_ms": index * 200,
179+
"end_ms": (index + 1) * 200,
180+
}
181+
for index, value in enumerate(["One", " two", " three", " four"])
182+
]
183+
observed: list[ServerEvent] = []
184+
typed: list[OutputTranscriptDeltaEvent] = []
185+
186+
def script(socket: ServerConnection) -> None:
187+
assert json.loads(socket.recv(timeout=5)) == {
188+
"type": "session.start",
189+
"event_id": "caller-start",
190+
"session": {"model": "gpt-live-1"},
191+
}
192+
socket.send(json.dumps({"type": "session.started", "event_id": "started", "session": session}))
193+
for event in transcripts[:2]:
194+
socket.send(json.dumps(event))
195+
# Issued only after the caller detaches and closes its first grouper.
196+
assert json.loads(socket.recv(timeout=5)) == {
197+
"type": "session.update",
198+
"event_id": "after-dispose",
199+
"session": {},
200+
}
201+
socket.send(
202+
json.dumps(
203+
{
204+
"type": "session.updated",
205+
"event_id": "updated",
206+
"client_event_id": "after-dispose",
207+
"session": session,
208+
"future_metadata": {"explicit_null": None, "nested": [1, "retained"]},
209+
}
210+
)
211+
)
212+
for event in transcripts[2:]:
213+
socket.send(json.dumps(event))
214+
assert json.loads(socket.recv(timeout=5)) == {"type": "session.close", "event_id": "caller-finish"}
215+
socket.send(json.dumps({"type": "session.closed", "event_id": "closed", "reason": "client_close"}))
216+
# The shared fixture rejects extra writes/reconnects and waits for caller close.
217+
218+
with script_server(script) as url:
219+
if mode == "sync":
220+
first = ClockGrouper(first_clock)
221+
second = ClockGrouper(second_clock)
222+
first_record, second_record = Recording(first, first_clock), Recording(second, second_clock)
223+
with OpenAI(api_key="ek_fake_live", base_url=url, http_client=httpx2.Client(trust_env=False)) as client:
224+
with client.live.connect() as connection:
225+
connection.on("session.output_transcript.delta", first.push)
226+
connection.on("session.output_transcript.delta", second.push)
227+
connection.on("session.output_transcript.delta", typed.append)
228+
connection.on("event", observed.append)
229+
230+
def manage(event: OutputTranscriptDeltaEvent) -> None:
231+
if event.event_id == "part-1":
232+
assert first_clock.pending
233+
connection.off("session.output_transcript.delta", first.push)
234+
first.close()
235+
first.close()
236+
assert not first_clock.pending
237+
connection.session.update(session={}, event_id="after-dispose")
238+
elif event.event_id == "part-3":
239+
connection.session.close(event_id="caller-finish")
240+
241+
def close_connection(_event: SessionClosedEvent) -> None:
242+
connection.close()
243+
244+
connection.on("session.output_transcript.delta", manage)
245+
connection.on("session.closed", close_connection)
246+
connection.session.start(session={"model": "gpt-live-1"}, event_id="caller-start")
247+
connection.dispatch_events()
248+
second.close()
249+
else:
250+
async_first = AsyncClockGrouper(first_clock)
251+
async_second = AsyncClockGrouper(second_clock)
252+
first_record, second_record = (
253+
Recording(async_first, first_clock),
254+
Recording(async_second, second_clock),
255+
)
256+
async with AsyncOpenAI(
257+
api_key="ek_fake_live", base_url=url, http_client=httpx2.AsyncClient(trust_env=False)
258+
) as async_client:
259+
async with async_client.live.connect() as async_connection:
260+
async_connection.on("session.output_transcript.delta", async_first.push)
261+
async_connection.on("session.output_transcript.delta", async_second.push)
262+
async_connection.on("session.output_transcript.delta", typed.append)
263+
async_connection.on("event", observed.append)
264+
265+
async def async_manage(event: OutputTranscriptDeltaEvent) -> None:
266+
if event.event_id == "part-1":
267+
assert first_clock.pending
268+
async_connection.off("session.output_transcript.delta", async_first.push)
269+
await async_first.close()
270+
await async_first.close()
271+
assert not first_clock.pending
272+
await async_connection.session.update(session={}, event_id="after-dispose")
273+
elif event.event_id == "part-3":
274+
await async_connection.session.close(event_id="caller-finish")
275+
276+
async def close_async_connection(_event: SessionClosedEvent) -> None:
277+
await async_connection.close()
278+
279+
async_connection.on("session.output_transcript.delta", async_manage)
280+
async_connection.on("session.closed", close_async_connection)
281+
await async_connection.session.start(session={"model": "gpt-live-1"}, event_id="caller-start")
282+
await asyncio.wait_for(async_connection.dispatch_events(), timeout=5)
283+
await async_second.close()
284+
285+
assert [event.to_dict().get("event_id") for event in observed] == [
286+
"started",
287+
"part-0",
288+
"part-1",
289+
"updated",
290+
"part-2",
291+
"part-3",
292+
"closed",
293+
]
294+
assert [event.to_dict() for event in typed] == transcripts
295+
updated = observed[3]
296+
assert isinstance(updated, SessionUpdatedEvent)
297+
assert updated.to_dict()["future_metadata"] == {"explicit_null": None, "nested": [1, "retained"]}
298+
assert [(event.segment.text, event.reason) for event in first_record.closed] == [("One two", "manual")]
299+
assert [(event.segment.text, event.reason) for event in second_record.closed] == [("One two three four", "manual")]
300+
assert not first_clock.pending and not second_clock.pending

0 commit comments

Comments
 (0)