Skip to content

Commit 8c93873

Browse files
IM594seratch
andauthored
fix: #613 Listen to peerConnection state in OpenAIRealtimeWebRTC to detect disconnects (#620)
Co-authored-by: Kazuhiro Sera <[email protected]>
1 parent b3148a2 commit 8c93873

File tree

3 files changed

+165
-2
lines changed

3 files changed

+165
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openai/agents-realtime": patch
3+
---
4+
5+
fix: #613 Listen to peerConnection state in `OpenAIRealtimeWebRTC` to detect disconnects

packages/agents-realtime/src/openaiRealtimeWebRtc.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ export class OpenAIRealtimeWebRTC
181181
const dataChannel = peerConnection.createDataChannel('oai-events');
182182
let callId: string | undefined = undefined;
183183

184+
const attachConnectionStateHandler = (
185+
connection: RTCPeerConnection,
186+
) => {
187+
connection.onconnectionstatechange = () => {
188+
switch (connection.connectionState) {
189+
case 'disconnected':
190+
case 'failed':
191+
case 'closed':
192+
this.close();
193+
break;
194+
// 'connected' state is handled by dataChannel.onopen. So we don't need to handle it here.
195+
// 'new' and 'connecting' are intermediate states and do not require action here.
196+
}
197+
};
198+
};
199+
attachConnectionStateHandler(peerConnection);
200+
184201
this.#state = {
185202
status: 'connecting',
186203
peerConnection,
@@ -249,8 +266,13 @@ export class OpenAIRealtimeWebRTC
249266
peerConnection.addTrack(stream.getAudioTracks()[0]);
250267

251268
if (this.options.changePeerConnection) {
269+
const originalPeerConnection = peerConnection;
252270
peerConnection =
253271
await this.options.changePeerConnection(peerConnection);
272+
if (originalPeerConnection !== peerConnection) {
273+
originalPeerConnection.onconnectionstatechange = null;
274+
}
275+
attachConnectionStateHandler(peerConnection);
254276
this.#state = { ...this.#state, peerConnection };
255277
}
256278

@@ -332,6 +354,7 @@ export class OpenAIRealtimeWebRTC
332354

333355
if (this.#state.peerConnection) {
334356
const peerConnection = this.#state.peerConnection;
357+
peerConnection.onconnectionstatechange = null;
335358
peerConnection.getSenders().forEach((sender) => {
336359
sender.track?.stop();
337360
});

packages/agents-realtime/test/openaiRealtimeWebRtc.test.ts

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,49 @@ let lastChannel: FakeRTCDataChannel | null = null;
1616

1717
class FakeRTCPeerConnection {
1818
ontrack: ((ev: any) => void) | null = null;
19+
onconnectionstatechange: (() => void) | null = null;
20+
connectionState = 'new';
21+
1922
createDataChannel(_name: string) {
2023
lastChannel = new FakeRTCDataChannel();
2124
// simulate async open event
22-
setTimeout(() => lastChannel?.dispatchEvent(new Event('open')));
25+
setTimeout(() => {
26+
this._simulateStateChange('connected');
27+
lastChannel?.dispatchEvent(new Event('open'));
28+
}, 0);
2329
return lastChannel as unknown as RTCDataChannel;
2430
}
2531
addTrack() {}
2632
async createOffer() {
33+
this._simulateStateChange('connecting');
2734
return { sdp: 'offer', type: 'offer' };
2835
}
2936
async setLocalDescription(_desc: any) {}
3037
async setRemoteDescription(_desc: any) {}
31-
close() {}
38+
close() {
39+
this._simulateStateChange('closed');
40+
}
3241
getSenders() {
3342
return [] as any;
3443
}
44+
45+
_simulateStateChange(
46+
state:
47+
| 'new'
48+
| 'connecting'
49+
| 'connected'
50+
| 'disconnected'
51+
| 'failed'
52+
| 'closed',
53+
) {
54+
if (this.connectionState === state) return;
55+
this.connectionState = state;
56+
setTimeout(() => {
57+
if (this.onconnectionstatechange) {
58+
this.onconnectionstatechange();
59+
}
60+
}, 0);
61+
}
3562
}
3663

3764
describe('OpenAIRealtimeWebRTC.interrupt', () => {
@@ -219,6 +246,113 @@ describe('OpenAIRealtimeWebRTC.interrupt', () => {
219246
});
220247
});
221248

249+
describe('OpenAIRealtimeWebRTC.connectionState', () => {
250+
const originals: Record<string, any> = {};
251+
252+
beforeEach(() => {
253+
originals.RTCPeerConnection = (global as any).RTCPeerConnection;
254+
originals.navigator = (global as any).navigator;
255+
originals.document = (global as any).document;
256+
originals.fetch = (global as any).fetch;
257+
258+
(global as any).RTCPeerConnection = FakeRTCPeerConnection as any;
259+
Object.defineProperty(globalThis, 'navigator', {
260+
value: {
261+
mediaDevices: {
262+
getUserMedia: async () => ({
263+
getAudioTracks: () => [{ enabled: true }],
264+
}),
265+
},
266+
},
267+
configurable: true,
268+
writable: true,
269+
});
270+
Object.defineProperty(globalThis, 'document', {
271+
value: { createElement: () => ({ autoplay: true }) },
272+
configurable: true,
273+
writable: true,
274+
});
275+
Object.defineProperty(globalThis, 'fetch', {
276+
value: async () => ({
277+
text: async () => 'answer',
278+
headers: {
279+
get: (headerKey: string) => {
280+
if (headerKey === 'Location') {
281+
return 'https://api.openai.com/v1/calls/rtc_u1_1234567890';
282+
}
283+
return null;
284+
},
285+
},
286+
}),
287+
configurable: true,
288+
writable: true,
289+
});
290+
});
291+
292+
afterEach(() => {
293+
(global as any).RTCPeerConnection = originals.RTCPeerConnection;
294+
Object.defineProperty(globalThis, 'navigator', {
295+
value: originals.navigator,
296+
configurable: true,
297+
writable: true,
298+
});
299+
Object.defineProperty(globalThis, 'document', {
300+
value: originals.document,
301+
configurable: true,
302+
writable: true,
303+
});
304+
Object.defineProperty(globalThis, 'fetch', {
305+
value: originals.fetch,
306+
configurable: true,
307+
writable: true,
308+
});
309+
lastChannel = null;
310+
});
311+
312+
it('fires connection_change and disconnects on peer connection failure', async () => {
313+
const rtc = new OpenAIRealtimeWebRTC();
314+
const events: string[] = [];
315+
rtc.on('connection_change', (status) => events.push(status));
316+
await rtc.connect({ apiKey: 'ek_test' });
317+
expect(rtc.status).toBe('connected');
318+
expect(events).toEqual(['connecting', 'connected']);
319+
const pc = rtc.connectionState
320+
.peerConnection as unknown as FakeRTCPeerConnection;
321+
expect(pc).toBeInstanceOf(FakeRTCPeerConnection);
322+
pc._simulateStateChange('failed');
323+
await new Promise((resolve) => setTimeout(resolve, 0));
324+
expect(rtc.status).toBe('disconnected');
325+
expect(events).toEqual(['connecting', 'connected', 'disconnected']);
326+
});
327+
328+
it('migrates connection state handler when peer connection is replaced', async () => {
329+
class CustomFakePeerConnection extends FakeRTCPeerConnection {}
330+
const customPC = new CustomFakePeerConnection();
331+
332+
const rtc = new OpenAIRealtimeWebRTC({
333+
changePeerConnection: async () => customPC as any,
334+
});
335+
336+
const closeSpy = vi.spyOn(rtc, 'close');
337+
const events: string[] = [];
338+
rtc.on('connection_change', (status) => events.push(status));
339+
340+
await rtc.connect({ apiKey: 'ek_test' });
341+
342+
expect(rtc.status).toBe('connected');
343+
expect(rtc.connectionState.peerConnection).toBe(customPC as any);
344+
expect(closeSpy).not.toHaveBeenCalled();
345+
expect(events).toEqual(['connecting', 'connected']);
346+
347+
customPC._simulateStateChange('failed');
348+
await new Promise((resolve) => setTimeout(resolve, 0));
349+
350+
expect(closeSpy).toHaveBeenCalled();
351+
expect(rtc.status).toBe('disconnected');
352+
expect(events).toEqual(['connecting', 'connected', 'disconnected']);
353+
});
354+
});
355+
222356
describe('OpenAIRealtimeWebRTC.callId', () => {
223357
const originals: Record<string, any> = {};
224358
const callId = 'rtc_u1_1234567890';
@@ -288,6 +422,7 @@ describe('OpenAIRealtimeWebRTC.callId', () => {
288422
await rtc.connect({ apiKey: 'ek_test' });
289423
expect(rtc.callId).toBe(callId);
290424
rtc.close();
425+
await new Promise((resolve) => setTimeout(resolve, 0));
291426
expect(rtc.callId).toBeUndefined();
292427
});
293428
});

0 commit comments

Comments
 (0)