|
9 | 9 | from websockets.sync.server import ServerConnection |
10 | 10 |
|
11 | 11 | 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 | +) |
13 | 19 | from openai.resources.live.live import LiveConnection, AsyncLiveConnection |
14 | 20 | from openai.resources.live.forks import ForksConnection, AsyncForksConnection |
15 | 21 |
|
| 22 | +from .helpers import FakeClock, Recording, ClockGrouper, AsyncClockGrouper |
16 | 23 | from ..responses.test_websocket_session import script_server |
17 | 24 |
|
18 | 25 |
|
@@ -157,3 +164,137 @@ def script(socket: ServerConnection) -> None: |
157 | 164 | assert isinstance(async_updated, SessionUpdatedEvent) |
158 | 165 | assert async_updated.client_event_id == "caller-update" |
159 | 166 | 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