@memberjunction/realtime-runtime 6.2.0-edge.5 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/session/RealtimeSessionRuntime.d.ts +94 -15
- package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
- package/dist/session/RealtimeSessionRuntime.js +252 -70
- package/dist/session/RealtimeSessionRuntime.js.map +1 -1
- package/dist/session/RecordingShardQueue.d.ts +13 -4
- package/dist/session/RecordingShardQueue.d.ts.map +1 -1
- package/dist/session/RecordingShardQueue.js +22 -4
- package/dist/session/RecordingShardQueue.js.map +1 -1
- package/dist/session/RecordingTransport.d.ts +2 -1
- package/dist/session/RecordingTransport.d.ts.map +1 -1
- package/dist/session/RecordingTransport.js.map +1 -1
- package/package.json +9 -9
|
@@ -6,7 +6,7 @@ import { GraphQLRealtimeSessionClient } from '@memberjunction/graphql-dataprovid
|
|
|
6
6
|
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
7
7
|
import { DEFAULT_REALTIME_AUDIO_TRACKS, ParseRealtimeAvatarStatus, ParseRealtimeClientTransport } from '@memberjunction/ai';
|
|
8
8
|
import { CompareExposure, DeclaresNativeTools, IsIdentityVerifiedEventPayload, NormalizeChannelKey, ParseRealtimeSessionClientPolicy, RECORDING_TERMINAL_MAX_BYTES, ResolveClientTools, SelectNativeChannelTools, UserExposureReason } from '@memberjunction/ai-core-plus';
|
|
9
|
-
import { BaseRealtimeClient, LoadAssemblyAIRealtimeClient, LoadElevenLabsRealtimeClient, LoadGeminiEnterpriseRealtimeClient, LoadGeminiRealtimeClient, LoadHuggingFaceRealtimeClient, LoadOpenAIRealtimeClient, LoadxAIRealtimeClient, VideoSourceArbiter, REQUESTED_TRACKS_SESSION_KEY } from '@memberjunction/ai-realtime-client';
|
|
9
|
+
import { BaseRealtimeClient, LoadAssemblyAIRealtimeClient, LoadElevenLabsRealtimeClient, LoadElevenRealtimeClient, LoadGeminiEnterpriseRealtimeClient, LoadGeminiRealtimeClient, LoadHuggingFaceRealtimeClient, LoadOpenAIRealtimeClient, LoadxAIRealtimeClient, VideoSourceArbiter, REQUESTED_TRACKS_SESSION_KEY } from '@memberjunction/ai-realtime-client';
|
|
10
10
|
import { DefaultRealtimeSessionLauncher, HasClientCredential } from './session-launcher.js';
|
|
11
11
|
import { RequestsAgentVideo, ResolveAvatarNotice } from './avatar-notice.js';
|
|
12
12
|
import { ClientSessionDeadline } from './client-session-deadline.js';
|
|
@@ -72,6 +72,7 @@ LoadGeminiEnterpriseRealtimeClient();
|
|
|
72
72
|
LoadElevenLabsRealtimeClient();
|
|
73
73
|
LoadAssemblyAIRealtimeClient();
|
|
74
74
|
LoadxAIRealtimeClient();
|
|
75
|
+
LoadElevenRealtimeClient();
|
|
75
76
|
LoadHuggingFaceRealtimeClient();
|
|
76
77
|
/**
|
|
77
78
|
* A registry row's surface placement. A row whose `UIConfig` is not valid JSON places its surface as a row without one
|
|
@@ -169,6 +170,14 @@ function microphoneStartError(reason, message) {
|
|
|
169
170
|
error.name = MICROPHONE_ERROR_NAMES[reason];
|
|
170
171
|
return error;
|
|
171
172
|
}
|
|
173
|
+
/** Thrown by a start while a call is live: the runtime holds one driver, and never replaces a live one. */
|
|
174
|
+
export class RealtimeSessionAlreadyActiveError extends Error {
|
|
175
|
+
constructor(ActiveAgentSessionId) {
|
|
176
|
+
super(`A realtime session is already active${ActiveAgentSessionId ? ` (${ActiveAgentSessionId})` : ''}; end it before starting another.`);
|
|
177
|
+
this.ActiveAgentSessionId = ActiveAgentSessionId;
|
|
178
|
+
this.name = 'RealtimeSessionAlreadyActiveError';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
172
181
|
/**
|
|
173
182
|
* Drives a **client-direct** real-time voice session: the browser mints an ephemeral
|
|
174
183
|
* token from the MJ server, then connects DIRECTLY to the realtime provider. Audio
|
|
@@ -362,8 +371,8 @@ export class RealtimeSessionRuntime {
|
|
|
362
371
|
* Fired EXACTLY ONCE per session as teardown begins, with the prior
|
|
363
372
|
* `agentSessionId` (so subscribers can correlate against `SessionStarted$`'s
|
|
364
373
|
* sessionId) and the client-distinguishable reason — `'explicit'` when the
|
|
365
|
-
* user called `EndRealtimeSession`, `'error'` when
|
|
366
|
-
*
|
|
374
|
+
* user called `EndRealtimeSession`, `'error'` when the runtime ended the call over a failure: a
|
|
375
|
+
* failed start, or a fatal error from the provider mid-call. Server-side close paths (janitor, shutdown) do NOT propagate here —
|
|
367
376
|
* they happen out-of-process and have no client push channel today.
|
|
368
377
|
*/
|
|
369
378
|
this.SessionEnded$ = this._sessionEnded$.asObservable();
|
|
@@ -515,6 +524,13 @@ export class RealtimeSessionRuntime {
|
|
|
515
524
|
* final. An EMPTY user transcript releases the onset outright (see onClientTranscript).
|
|
516
525
|
*/
|
|
517
526
|
this.userSpeechStartSuperseded = false;
|
|
527
|
+
/**
|
|
528
|
+
* Recording-relative ms offsets of user speech onsets keyed by the provider's conversation item id
|
|
529
|
+
* (`input_audio_buffer.speech_started.item_id`), paired with the matching final user transcript
|
|
530
|
+
* (`RealtimeClientTranscript.ItemId`) so a late user transcript never claims a later turn's onset
|
|
531
|
+
* (MJ#5902). Unkeyed drivers continue to use {@link userSpeechStartMs}.
|
|
532
|
+
*/
|
|
533
|
+
this.userSpeechStartByItemId = new Map();
|
|
518
534
|
/**
|
|
519
535
|
* Wall-anchor of the SESSION clock (#3832): `performance.now()` at the moment the call went
|
|
520
536
|
* live, or `null` before any call has. Read only through {@link nowTurnOffsetMs}.
|
|
@@ -599,6 +615,15 @@ export class RealtimeSessionRuntime {
|
|
|
599
615
|
* two `SessionEnded$` emissions for one session.
|
|
600
616
|
*/
|
|
601
617
|
this.teardownInFlight = null;
|
|
618
|
+
/**
|
|
619
|
+
* True from the moment a start finishes (connected, liveness pulse running) until the slot is
|
|
620
|
+
* released. A driver's Fatal ends the call only while this is set: during a start, drivers emit
|
|
621
|
+
* Fatal from their socket handlers while Connect is still pending, and the start's own failure path
|
|
622
|
+
* ({@link failSessionStart}) is what reports that error, whether Connect then rejects or resolves
|
|
623
|
+
* (runMintedSession checks {@link lastSessionError} after Connect). Not {@link isSessionLive}: drivers move the
|
|
624
|
+
* state to 'listening' from inside Connect, and a live call that is resuming reads 'connecting'.
|
|
625
|
+
*/
|
|
626
|
+
this.callWentLive = false;
|
|
602
627
|
/**
|
|
603
628
|
* The latest start's opening of the host's microphone, until {@link IRealtimeMediaHost.ReleaseMicrophone} hands it
|
|
604
629
|
* back; `null` when no opening is owed a release, so a session that ends before asking for the microphone releases
|
|
@@ -954,6 +979,25 @@ export class RealtimeSessionRuntime {
|
|
|
954
979
|
IsActiveFor(conversationId) {
|
|
955
980
|
return this.IsActive && conversationId != null && UUIDsEqual(this.sessionConversationId, conversationId);
|
|
956
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* Takes the runtime's one driver slot for a new start; the two starts call it after awaiting any
|
|
984
|
+
* teardown in flight, and only when no End arrived during that wait (a changed {@link startGeneration}).
|
|
985
|
+
*
|
|
986
|
+
* A teardown still in flight is awaited rather than read as "busy": ending a call and starting the
|
|
987
|
+
* next one back to back is normal host behaviour (a retry, a re-mounted surface), and the flag it
|
|
988
|
+
* used to read flips only at the END of teardown, so the start was dropped with nothing to show
|
|
989
|
+
* for it — a bare `undefined`, which hosts had to guess at (MJ#4629). A call that is genuinely
|
|
990
|
+
* live is refused with an error the host can show; it is never silently ignored, and never
|
|
991
|
+
* overwritten, since a driver no slot points at can never be stopped again.
|
|
992
|
+
*
|
|
993
|
+
* Deliberately synchronous: the start's state must be set in the tick the host called it in, or an
|
|
994
|
+
* End issued straight after the Start (a user tapping back during the mint) finds nothing to abandon.
|
|
995
|
+
*/
|
|
996
|
+
claimSessionSlot() {
|
|
997
|
+
if (this.IsActive) {
|
|
998
|
+
throw new RealtimeSessionAlreadyActiveError(this.agentSessionId);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
957
1001
|
/**
|
|
958
1002
|
* Start a client-direct voice session fronting `targetAgentId`.
|
|
959
1003
|
*
|
|
@@ -1000,13 +1044,22 @@ export class RealtimeSessionRuntime {
|
|
|
1000
1044
|
* ({@link RealtimeSessionStartOptions.ConversationBranchID}; omitted is Main, where its transcript is written).
|
|
1001
1045
|
*/
|
|
1002
1046
|
async StartRealtimeSession(targetAgentId, conversationId, lastSessionId, agentName, preferredModelId, clientTools, coAgentId, configOverridesJson, recordingConsent, mediaCollectionId, applicationId, appContext, options) {
|
|
1003
|
-
|
|
1004
|
-
|
|
1047
|
+
// Captured BEFORE any wait: a teardown from here on bumps it, and the checks below (and in
|
|
1048
|
+
// runMintedSession) abandon the start. Re-capturing after the wait would absorb an End issued during it.
|
|
1049
|
+
const generation = this.startGeneration;
|
|
1050
|
+
// Synchronous unless a teardown is in flight: a start must take the slot in the same tick it was
|
|
1051
|
+
// called in, so a host's End issued right after it finds a start to abandon (see claimSessionSlot).
|
|
1052
|
+
if (this.teardownInFlight) {
|
|
1053
|
+
await this.teardownInFlight;
|
|
1054
|
+
// An End issued during the wait found IsActive still true (the last call was closing), so it only
|
|
1055
|
+
// joined that teardown and bumped the generation. Nothing is acquired yet, so nothing to release.
|
|
1056
|
+
if (this.startGeneration !== generation) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1005
1059
|
}
|
|
1060
|
+
this.claimSessionSlot();
|
|
1006
1061
|
const consent = this.beginSessionStart({ agentName, recordingConsent, applicationId, appContext });
|
|
1007
1062
|
this.sessionConversationId = conversationId ?? null;
|
|
1008
|
-
// A teardown while the channels are prepared or the mint runs bumps this; see the checks below.
|
|
1009
|
-
const generation = this.startGeneration;
|
|
1010
1063
|
// Captured BEFORE the channels are prepared so the mint carries exactly the snapshot the prologue
|
|
1011
1064
|
// resolved, whatever a channel plugin may push in the meantime.
|
|
1012
1065
|
const effectiveAppContext = this._appContext$.value;
|
|
@@ -1032,14 +1085,14 @@ export class RealtimeSessionRuntime {
|
|
|
1032
1085
|
// The host ended the start while the mint ran. That teardown had no session id to close, so
|
|
1033
1086
|
// close the minted row here, and touch no shared state: a newer start may already own it.
|
|
1034
1087
|
if (this.startGeneration !== generation) {
|
|
1035
|
-
await this.closeServerSession(session.AgentSessionId);
|
|
1088
|
+
await this.closeServerSession(session.AgentSessionId, 'Explicit');
|
|
1036
1089
|
return;
|
|
1037
1090
|
}
|
|
1038
1091
|
if (!this.hostCanUseProvider(session.Provider)) {
|
|
1039
1092
|
await this.abortUnusableSession(session);
|
|
1040
1093
|
return;
|
|
1041
1094
|
}
|
|
1042
|
-
await this.runMintedSession(session, conversationId ?? null, consent, options?.CameraCheck === true);
|
|
1095
|
+
await this.runMintedSession(session, conversationId ?? null, consent, options?.CameraCheck === true, generation);
|
|
1043
1096
|
}
|
|
1044
1097
|
/**
|
|
1045
1098
|
* Declares which provider keys this host can actually carry audio for.
|
|
@@ -1075,8 +1128,7 @@ export class RealtimeSessionRuntime {
|
|
|
1075
1128
|
// published them on ActiveChannels$; skipping teardown would leave them undisposed, their tool
|
|
1076
1129
|
// handlers registered, and their subscriptions live until the next start replaced them.
|
|
1077
1130
|
this.agentSessionId = session.AgentSessionId ?? this.agentSessionId;
|
|
1078
|
-
this.
|
|
1079
|
-
await this.teardown(true);
|
|
1131
|
+
await this.endOnFailure();
|
|
1080
1132
|
}
|
|
1081
1133
|
/**
|
|
1082
1134
|
* Run a session the HOST has already minted itself — the second half of
|
|
@@ -1100,22 +1152,28 @@ export class RealtimeSessionRuntime {
|
|
|
1100
1152
|
* Every field defaults exactly as its {@link StartRealtimeSession} counterpart does.
|
|
1101
1153
|
*/
|
|
1102
1154
|
async StartRealtimeSessionFromResult(result, options) {
|
|
1103
|
-
|
|
1104
|
-
|
|
1155
|
+
// Same wait, same generation check, same reasons as StartRealtimeSession.
|
|
1156
|
+
const generation = this.startGeneration;
|
|
1157
|
+
if (this.teardownInFlight) {
|
|
1158
|
+
await this.teardownInFlight;
|
|
1159
|
+
if (this.startGeneration !== generation) {
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1105
1162
|
}
|
|
1163
|
+
this.claimSessionSlot();
|
|
1106
1164
|
const effectiveOptions = options ?? {};
|
|
1107
1165
|
if (!this.hostCanUseProvider(result.Provider)) {
|
|
1108
1166
|
await this.abortUnusableSession(result);
|
|
1109
1167
|
return;
|
|
1110
1168
|
}
|
|
1111
1169
|
const consent = this.beginSessionStart(effectiveOptions);
|
|
1112
|
-
await this.runMintedSession(result, effectiveOptions.conversationId ?? null, consent, false);
|
|
1170
|
+
await this.runMintedSession(result, effectiveOptions.conversationId ?? null, consent, false, generation);
|
|
1113
1171
|
}
|
|
1114
1172
|
/**
|
|
1115
1173
|
* Start prologue shared by both entry points: clear the last start's failure, bind the app layer,
|
|
1116
1174
|
* publish the agent name, reset per-session state, and flip the session live (which is ALSO what
|
|
1117
|
-
* makes
|
|
1118
|
-
* runs before minting, not after). Returns the resolved recording consent, which the mint half
|
|
1175
|
+
* makes {@link claimSessionSlot} refuse a second start with {@link RealtimeSessionAlreadyActiveError}
|
|
1176
|
+
* while the mint is still in flight — hence it runs before minting, not after). Returns the resolved recording consent, which the mint half
|
|
1119
1177
|
* reports to the server and the run half uses to decide whether to record.
|
|
1120
1178
|
*/
|
|
1121
1179
|
beginSessionStart(options) {
|
|
@@ -1155,10 +1213,10 @@ export class RealtimeSessionRuntime {
|
|
|
1155
1213
|
* the provider connection, and go live. `inputConversationId` is the conversation the START asked
|
|
1156
1214
|
* for (null ⇒ "server, make me one") — the result alone can't distinguish the two. `cameraCheck` is
|
|
1157
1215
|
* whether the host shows a camera check ({@link RealtimeSessionStartOptions.CameraCheck}).
|
|
1216
|
+
* `generation` is the {@link startGeneration} the entry point captured before its first await; every
|
|
1217
|
+
* await below is a window in which the host can end the session, and so was every await before this.
|
|
1158
1218
|
*/
|
|
1159
|
-
async runMintedSession(session, inputConversationId, consent, cameraCheck) {
|
|
1160
|
-
// Captured up front: every await below is a window in which the host can end the session.
|
|
1161
|
-
const generation = this.startGeneration;
|
|
1219
|
+
async runMintedSession(session, inputConversationId, consent, cameraCheck, generation) {
|
|
1162
1220
|
try {
|
|
1163
1221
|
// Mount the channels the resolved policy puts in the session. Deliberately BEFORE the session
|
|
1164
1222
|
// id is adopted: `ActiveChannels$` consumers (the sessions adapter) treat an emission that
|
|
@@ -1207,6 +1265,13 @@ export class RealtimeSessionRuntime {
|
|
|
1207
1265
|
if (connectError) {
|
|
1208
1266
|
throw connectError;
|
|
1209
1267
|
}
|
|
1268
|
+
// A Fatal the driver sent during this start (the fatal handler leaves it to the start, see
|
|
1269
|
+
// callWentLive) fails the start even when Connect resolved: the real drivers keep awaiting mic
|
|
1270
|
+
// capture after their connect-failure channel settles, then report 'listening' and resolve on a
|
|
1271
|
+
// socket that already closed. lastSessionError is reset per start and set only by the current client.
|
|
1272
|
+
if (this.lastSessionError?.Fatal) {
|
|
1273
|
+
throw new Error(this.lastSessionError.Message);
|
|
1274
|
+
}
|
|
1210
1275
|
// Tracks are negotiated now, so a capture can tell whether the model takes video, and the call whether it shows
|
|
1211
1276
|
// the avatar its agent asked for. The driver holds the microphone now, so the user may switch it.
|
|
1212
1277
|
this.openCaptures(client, cameraCheck);
|
|
@@ -1258,6 +1323,7 @@ export class RealtimeSessionRuntime {
|
|
|
1258
1323
|
// Same place, same reason: the session is connected and its id is known, which is exactly
|
|
1259
1324
|
// the window in which the server needs to be told it is alive.
|
|
1260
1325
|
this.startLivenessPulse();
|
|
1326
|
+
this.callWentLive = true;
|
|
1261
1327
|
}
|
|
1262
1328
|
catch (error) {
|
|
1263
1329
|
await this.failSessionStart(error);
|
|
@@ -1319,7 +1385,7 @@ export class RealtimeSessionRuntime {
|
|
|
1319
1385
|
// session, and clearing the id would leave that teardown no session to report.
|
|
1320
1386
|
if (session.AgentSessionId && this.agentSessionId === session.AgentSessionId && !this.teardownInFlight) {
|
|
1321
1387
|
this.agentSessionId = null;
|
|
1322
|
-
await this.closeServerSession(session.AgentSessionId);
|
|
1388
|
+
await this.closeServerSession(session.AgentSessionId, 'Explicit');
|
|
1323
1389
|
}
|
|
1324
1390
|
}
|
|
1325
1391
|
/**
|
|
@@ -1630,8 +1696,20 @@ export class RealtimeSessionRuntime {
|
|
|
1630
1696
|
async failSessionStart(error) {
|
|
1631
1697
|
console.error('[RealtimeSession] Failed to start session:', error);
|
|
1632
1698
|
this.lastStartError = error instanceof Error ? error : new Error(String(error));
|
|
1699
|
+
await this.endOnFailure();
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Ends the call over a failure: a failed start, an unusable provider, or a fatal provider error.
|
|
1703
|
+
*
|
|
1704
|
+
* The teardown is claimed BEFORE `'error'` is published. `ConnectionState$` emits synchronously, and a
|
|
1705
|
+
* host may end the call the moment it sees `'error'` (MobileApp's voice screen does); claimed first, that
|
|
1706
|
+
* host's End joins this teardown instead of starting its own as `'explicit'`, so the row still closes as
|
|
1707
|
+
* `Error` and `SessionEnded$` still says `'error'`. Teardown keeps a terminal `'error'` state.
|
|
1708
|
+
*/
|
|
1709
|
+
endOnFailure() {
|
|
1710
|
+
const ending = this.teardown('error');
|
|
1633
1711
|
this.setConnectionState('error');
|
|
1634
|
-
|
|
1712
|
+
return ending;
|
|
1635
1713
|
}
|
|
1636
1714
|
/**
|
|
1637
1715
|
* End the active session: stop the mic, tear down the provider connection, and close
|
|
@@ -1641,7 +1719,7 @@ export class RealtimeSessionRuntime {
|
|
|
1641
1719
|
if (!this.IsActive && !this.agentSessionId) {
|
|
1642
1720
|
return;
|
|
1643
1721
|
}
|
|
1644
|
-
await this.teardown(
|
|
1722
|
+
await this.teardown('explicit');
|
|
1645
1723
|
}
|
|
1646
1724
|
/**
|
|
1647
1725
|
* Inject a typed message into the live session as a user turn.
|
|
@@ -1732,7 +1810,17 @@ export class RealtimeSessionRuntime {
|
|
|
1732
1810
|
if (trimmed.length === 0 || !this.client || !this.isSessionLive()) {
|
|
1733
1811
|
return false;
|
|
1734
1812
|
}
|
|
1735
|
-
|
|
1813
|
+
// The opening is part of the conversation, so it goes out as a spoken TURN: captioned, relayed
|
|
1814
|
+
// and persisted like any turn the model takes on its own. It used to ride the delegation
|
|
1815
|
+
// narration channel, whose replies are disposable by contract, so the greeting the participant
|
|
1816
|
+
// heard was never in the transcript (MJ#4591). A driver from before the capability existed
|
|
1817
|
+
// falls back to that channel rather than leaving the session silent.
|
|
1818
|
+
if (this.client.RequestSpokenTurn) {
|
|
1819
|
+
this.client.RequestSpokenTurn(trimmed);
|
|
1820
|
+
}
|
|
1821
|
+
else {
|
|
1822
|
+
this.client.RequestSpokenUpdate(trimmed);
|
|
1823
|
+
}
|
|
1736
1824
|
return true;
|
|
1737
1825
|
}
|
|
1738
1826
|
/**
|
|
@@ -1814,6 +1902,7 @@ export class RealtimeSessionRuntime {
|
|
|
1814
1902
|
this.turnAudioStartCaptured = false;
|
|
1815
1903
|
this.userSpeechStartMs = null;
|
|
1816
1904
|
this.userSpeechStartSuperseded = false;
|
|
1905
|
+
this.userSpeechStartByItemId.clear();
|
|
1817
1906
|
if (this.recorder) {
|
|
1818
1907
|
// WebRTC drivers (OpenAI): the agent's track usually lands AFTER Connect() resolves, so
|
|
1819
1908
|
// `remoteStream` above is null here and this handler attaches it later. PCM-playback
|
|
@@ -1948,17 +2037,19 @@ export class RealtimeSessionRuntime {
|
|
|
1948
2037
|
// Closes over this recording's session id and transport — never the runtime's current ones.
|
|
1949
2038
|
Queue: new RecordingShardQueue(async (shard) => {
|
|
1950
2039
|
if (!transport) {
|
|
1951
|
-
return
|
|
2040
|
+
return 'Retry';
|
|
1952
2041
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
2042
|
+
// The outcome goes to the queue unchanged: it logs a different reason for each, and stops
|
|
2043
|
+
// retrying a `Refused` shard at once (#6012).
|
|
2044
|
+
const outcome = await transport.UploadShard(sessionId, shard);
|
|
2045
|
+
if (outcome === 'Acked') {
|
|
1955
2046
|
await this.deleteStoredShard(sessionId, shard.Index);
|
|
1956
2047
|
if (shardsGivenUp) {
|
|
1957
2048
|
shardsGivenUp = false;
|
|
1958
2049
|
this.recoverStoredShardsAfterOutage(sessionId);
|
|
1959
2050
|
}
|
|
1960
2051
|
}
|
|
1961
|
-
return
|
|
2052
|
+
return outcome;
|
|
1962
2053
|
}, () => {
|
|
1963
2054
|
shardsGivenUp = true;
|
|
1964
2055
|
}),
|
|
@@ -2292,6 +2383,7 @@ export class RealtimeSessionRuntime {
|
|
|
2292
2383
|
this.turnAudioStartCaptured = false;
|
|
2293
2384
|
this.userSpeechStartMs = null;
|
|
2294
2385
|
this.userSpeechStartSuperseded = false;
|
|
2386
|
+
this.userSpeechStartByItemId.clear();
|
|
2295
2387
|
if (!recorder || !pipeline) {
|
|
2296
2388
|
this.recorder = null;
|
|
2297
2389
|
this.recordingShards = null;
|
|
@@ -3384,6 +3476,27 @@ export class RealtimeSessionRuntime {
|
|
|
3384
3476
|
console.error('[RealtimeSession] Provider error event:', JSON.stringify(error), error);
|
|
3385
3477
|
if (error.Fatal && this.client === client) {
|
|
3386
3478
|
this.lastSessionError = error;
|
|
3479
|
+
// While the start is still running, its own failure path reports the error and ends the call
|
|
3480
|
+
// (runMintedSession throws on this recorded Fatal after Connect, even if Connect resolves):
|
|
3481
|
+
// ending it here would bump startGeneration, so the start would read as ended by the host and
|
|
3482
|
+
// never set LastStartError.
|
|
3483
|
+
if (!this.callWentLive) {
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
// An explicit teardown is already ending the call and the client stays current until its
|
|
3487
|
+
// finally: a driver reporting its own close as Fatal from inside Disconnect() is part of that
|
|
3488
|
+
// clean End, and must not flip it to 'error' or start a second teardown.
|
|
3489
|
+
if (this.teardownInFlight) {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
// The driver is dead on the wire; nothing resumes after it says Fatal. End the call the way a
|
|
3493
|
+
// failed start ends: the row closes as Error, the microphone is released, the host is told
|
|
3494
|
+
// through SessionEnded$ (MJ#4629). A host's own End afterwards finds nothing to do.
|
|
3495
|
+
// Captured now: teardown nulls agentSessionId, and the failure it reports must name the session.
|
|
3496
|
+
const failedSessionId = this.agentSessionId;
|
|
3497
|
+
void this.endOnFailure().catch((teardownError) => {
|
|
3498
|
+
console.error(`[RealtimeSession] Teardown after a fatal provider error failed for session ${failedSessionId}:`, teardownError);
|
|
3499
|
+
});
|
|
3387
3500
|
}
|
|
3388
3501
|
});
|
|
3389
3502
|
// Usage telemetry: accumulate the driver's per-response token DELTAS and relay them to
|
|
@@ -3403,9 +3516,10 @@ export class RealtimeSessionRuntime {
|
|
|
3403
3516
|
});
|
|
3404
3517
|
// The provider VAD saw the user start speaking. Final-only user-transcript drivers (OpenAI,
|
|
3405
3518
|
// HuggingFace, xAI) emit no user interim, so this is their only real user-turn start. It goes
|
|
3406
|
-
// to its own slot, not the shared turn slot:
|
|
3407
|
-
// assistant's reply may already be streaming
|
|
3408
|
-
|
|
3519
|
+
// to its own slot (or item-keyed map entry when `itemId` is present), not the shared turn slot:
|
|
3520
|
+
// the user transcript arrives asynchronously, so the assistant's reply may already be streaming
|
|
3521
|
+
// and must keep its own start.
|
|
3522
|
+
client.OnUserSpeechStart((itemId) => this.markUserSpeechStart(itemId));
|
|
3409
3523
|
client.OnRemoteVideo((video) => this.onAgentVideo(client, video));
|
|
3410
3524
|
}
|
|
3411
3525
|
/**
|
|
@@ -3511,7 +3625,7 @@ export class RealtimeSessionRuntime {
|
|
|
3511
3625
|
if (transcript.Role === 'User' && transcript.Text.trim().length === 0) {
|
|
3512
3626
|
// An empty final user transcript (a VAD false positive, an empty transcription) never reaches
|
|
3513
3627
|
// relayTranscript, so release its onset here: it must not become the next real turn's start.
|
|
3514
|
-
this.releaseUserSpeechStart();
|
|
3628
|
+
this.releaseUserSpeechStart(transcript.ItemId);
|
|
3515
3629
|
}
|
|
3516
3630
|
if (transcript.Role === 'Assistant') {
|
|
3517
3631
|
this.hasActiveInterimUserCaption = false;
|
|
@@ -3555,7 +3669,7 @@ export class RealtimeSessionRuntime {
|
|
|
3555
3669
|
if (this.firstUserTranscript === null) {
|
|
3556
3670
|
this.firstUserTranscript = transcript.Text;
|
|
3557
3671
|
}
|
|
3558
|
-
await this.relayTranscript('user', transcript.Text);
|
|
3672
|
+
await this.relayTranscript('user', transcript.Text, false, 'speech', transcript.ItemId);
|
|
3559
3673
|
}
|
|
3560
3674
|
else if (transcript.ReplacesPrevious) {
|
|
3561
3675
|
// STREAMING user transcription: providers like Grok and OpenAI Live emit the growing utterance as repeated
|
|
@@ -3566,10 +3680,10 @@ export class RealtimeSessionRuntime {
|
|
|
3566
3680
|
return;
|
|
3567
3681
|
}
|
|
3568
3682
|
this.replaceLastCaption('User', transcript.Text);
|
|
3569
|
-
await this.relayTranscript('user', transcript.Text, true);
|
|
3683
|
+
await this.relayTranscript('user', transcript.Text, true, 'speech', transcript.ItemId);
|
|
3570
3684
|
}
|
|
3571
3685
|
else {
|
|
3572
|
-
await this.onUserTranscript(transcript.Text);
|
|
3686
|
+
await this.onUserTranscript(transcript.Text, 'speech', transcript.ItemId);
|
|
3573
3687
|
}
|
|
3574
3688
|
}
|
|
3575
3689
|
/**
|
|
@@ -3604,13 +3718,24 @@ export class RealtimeSessionRuntime {
|
|
|
3604
3718
|
this.turnAudioStartCaptured = true;
|
|
3605
3719
|
}
|
|
3606
3720
|
/**
|
|
3607
|
-
* Stamps
|
|
3608
|
-
*
|
|
3609
|
-
* mid-sentence pause
|
|
3610
|
-
*
|
|
3611
|
-
*
|
|
3721
|
+
* Stamps the user speech onset when the client signals that the user began to speak. When the
|
|
3722
|
+
* driver supplies a conversation `itemId`, records the FIRST onset for that item in
|
|
3723
|
+
* {@link userSpeechStartByItemId} so a mid-sentence pause that re-fires with the same id keeps the
|
|
3724
|
+
* original start. When no `itemId` is supplied, stamps the single-slot {@link userSpeechStartMs}
|
|
3725
|
+
* fallback (keeping the first onset unless {@link userSpeechStartSuperseded} is set).
|
|
3612
3726
|
*/
|
|
3613
|
-
markUserSpeechStart() {
|
|
3727
|
+
markUserSpeechStart(itemId) {
|
|
3728
|
+
if (itemId !== undefined && itemId.length > 0) {
|
|
3729
|
+
if (this.userSpeechStartByItemId.has(itemId)) {
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
const offset = this.nowTurnOffsetMs();
|
|
3733
|
+
if (offset === null) {
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
this.userSpeechStartByItemId.set(itemId, offset);
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3614
3739
|
if (this.userSpeechStartMs !== null && !this.userSpeechStartSuperseded) {
|
|
3615
3740
|
return;
|
|
3616
3741
|
}
|
|
@@ -3621,11 +3746,36 @@ export class RealtimeSessionRuntime {
|
|
|
3621
3746
|
this.userSpeechStartMs = offset;
|
|
3622
3747
|
this.userSpeechStartSuperseded = false;
|
|
3623
3748
|
}
|
|
3624
|
-
/**
|
|
3625
|
-
|
|
3749
|
+
/**
|
|
3750
|
+
* Ends the held user onset: a final user transcript (empty or not) closed the utterance it began.
|
|
3751
|
+
* When `itemId` is supplied, deletes only that item's entry in {@link userSpeechStartByItemId};
|
|
3752
|
+
* otherwise clears the unkeyed single slot.
|
|
3753
|
+
*/
|
|
3754
|
+
releaseUserSpeechStart(itemId) {
|
|
3755
|
+
if (itemId !== undefined && itemId.length > 0) {
|
|
3756
|
+
this.userSpeechStartByItemId.delete(itemId);
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3626
3759
|
this.userSpeechStartMs = null;
|
|
3627
3760
|
this.userSpeechStartSuperseded = false;
|
|
3628
3761
|
}
|
|
3762
|
+
/**
|
|
3763
|
+
* Consumes and deletes the onset recorded for `itemId` in {@link userSpeechStartByItemId}. When a
|
|
3764
|
+
* matching onset is found, also deletes any remaining map entry with an earlier offset so a VAD
|
|
3765
|
+
* false positive whose transcription never arrived cannot accumulate in the map.
|
|
3766
|
+
*/
|
|
3767
|
+
takeKeyedUserSpeechStart(itemId) {
|
|
3768
|
+
const onset = this.userSpeechStartByItemId.get(itemId) ?? null;
|
|
3769
|
+
this.userSpeechStartByItemId.delete(itemId);
|
|
3770
|
+
if (onset !== null) {
|
|
3771
|
+
for (const [otherId, otherOnset] of this.userSpeechStartByItemId) {
|
|
3772
|
+
if (otherOnset < onset) {
|
|
3773
|
+
this.userSpeechStartByItemId.delete(otherId);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
return onset;
|
|
3778
|
+
}
|
|
3629
3779
|
/**
|
|
3630
3780
|
* The current per-turn offset in ms — the RECORDER's clock when one runs (an offset into a
|
|
3631
3781
|
* seekable file), else the SESSION clock (#3832: orderable and displayable, not seekable),
|
|
@@ -3664,8 +3814,9 @@ export class RealtimeSessionRuntime {
|
|
|
3664
3814
|
*
|
|
3665
3815
|
* @param source `'typed'` for a {@link SendText} turn: it has no speech onset, so it neither takes
|
|
3666
3816
|
* nor clears the held one (a spoken transcript may still be pending for it).
|
|
3817
|
+
* @param itemId Provider conversation item id (`RealtimeClientTranscript.ItemId`) when present.
|
|
3667
3818
|
*/
|
|
3668
|
-
async onUserTranscript(transcript, source = 'speech') {
|
|
3819
|
+
async onUserTranscript(transcript, source = 'speech', itemId) {
|
|
3669
3820
|
if (transcript.trim().length === 0) {
|
|
3670
3821
|
return;
|
|
3671
3822
|
}
|
|
@@ -3674,7 +3825,7 @@ export class RealtimeSessionRuntime {
|
|
|
3674
3825
|
this.firstUserTranscript = transcript;
|
|
3675
3826
|
}
|
|
3676
3827
|
this.appendCaption({ Role: 'User', Text: transcript });
|
|
3677
|
-
await this.relayTranscript('user', transcript, false, source);
|
|
3828
|
+
await this.relayTranscript('user', transcript, false, source, itemId);
|
|
3678
3829
|
}
|
|
3679
3830
|
// ── Tool calling ───────────────────────────────────────────────────────────
|
|
3680
3831
|
/**
|
|
@@ -4022,16 +4173,23 @@ export class RealtimeSessionRuntime {
|
|
|
4022
4173
|
* recent persisted turn of this role IN PLACE instead of appending (e.g. ElevenLabs'
|
|
4023
4174
|
* post-barge-in `agent_response_correction`).
|
|
4024
4175
|
* @param source `'typed'` for a user turn sent with {@link SendText}: it does not read or clear
|
|
4025
|
-
* {@link userSpeechStartMs}, which
|
|
4176
|
+
* {@link userSpeechStartMs} or {@link userSpeechStartByItemId}, which belong to the user's speech.
|
|
4177
|
+
* @param itemId Provider conversation item id (`RealtimeClientTranscript.ItemId`) when present.
|
|
4026
4178
|
*/
|
|
4027
|
-
async relayTranscript(role, text, replacesPrevious = false, source = 'speech') {
|
|
4179
|
+
async relayTranscript(role, text, replacesPrevious = false, source = 'speech', itemId) {
|
|
4028
4180
|
if (!this.agentSessionId) {
|
|
4029
4181
|
return;
|
|
4030
4182
|
}
|
|
4031
4183
|
// Every final user transcript closes the signalled onset — a correction too, since it ends the
|
|
4032
4184
|
// same utterance — so a later onset (an xAI mid-sentence resume) can't leak into the next turn.
|
|
4185
|
+
// When the transcript carries a provider itemId, consume its keyed onset from the map without
|
|
4186
|
+
// touching the unkeyed single slot or falling back to currentTurnStartMs.
|
|
4187
|
+
const hasItemId = role === 'user' && source === 'speech' && itemId !== undefined && itemId.length > 0;
|
|
4033
4188
|
let userSpeechStartMs = null;
|
|
4034
|
-
if (
|
|
4189
|
+
if (hasItemId) {
|
|
4190
|
+
userSpeechStartMs = this.takeKeyedUserSpeechStart(itemId);
|
|
4191
|
+
}
|
|
4192
|
+
else if (role === 'user' && source === 'speech') {
|
|
4035
4193
|
userSpeechStartMs = this.userSpeechStartMs;
|
|
4036
4194
|
this.releaseUserSpeechStart();
|
|
4037
4195
|
}
|
|
@@ -4042,9 +4200,10 @@ export class RealtimeSessionRuntime {
|
|
|
4042
4200
|
const utteranceEndMs = this.nowTurnOffsetMs();
|
|
4043
4201
|
const opensTurn = utteranceEndMs !== null && !replacesPrevious;
|
|
4044
4202
|
let utteranceStartMs = null;
|
|
4045
|
-
if (opensTurn && userSpeechStartMs !== null) {
|
|
4046
|
-
// A user turn with a signalled onset
|
|
4047
|
-
//
|
|
4203
|
+
if (opensTurn && (hasItemId || userSpeechStartMs !== null)) {
|
|
4204
|
+
// A user turn with a signalled onset (or a keyed user turn whose itemId was not in the map,
|
|
4205
|
+
// which relays null). Leave the shared slot alone: the user transcript arrives asynchronously,
|
|
4206
|
+
// so it may already hold the in-flight assistant reply's start.
|
|
4048
4207
|
utteranceStartMs = userSpeechStartMs;
|
|
4049
4208
|
if (!this.turnAudioStartCaptured) {
|
|
4050
4209
|
// No turn has captured a start, so the slot holds only the recording-start seed (0). This
|
|
@@ -4460,10 +4619,18 @@ export class RealtimeSessionRuntime {
|
|
|
4460
4619
|
}
|
|
4461
4620
|
// ── Teardown ───────────────────────────────────────────────────────────────
|
|
4462
4621
|
/**
|
|
4463
|
-
* Tears down all client resources and
|
|
4464
|
-
*
|
|
4622
|
+
* Tears down all client resources and closes the server session when one was minted.
|
|
4623
|
+
*
|
|
4624
|
+
* The server row is closed whichever way the call ended: a start that failed after the mint used to
|
|
4625
|
+
* leave its row `Active` until the janitor's staleness sweep, 15 minutes later, so every host that
|
|
4626
|
+
* counts live sessions saw a phantom call (MJ#6013). The janitor remains the fallback for a client
|
|
4627
|
+
* that dies before it can say anything; it is never the plan.
|
|
4628
|
+
*
|
|
4629
|
+
* @param end why the call ended — `'explicit'` when the host or user ended it, `'error'` when the
|
|
4630
|
+
* runtime ended it because the start or the call failed. Reported on `SessionEnded$` and stamped on
|
|
4631
|
+
* the server row as its `CloseReason` (`Explicit` / `Error`).
|
|
4465
4632
|
*/
|
|
4466
|
-
async teardown(
|
|
4633
|
+
async teardown(end) {
|
|
4467
4634
|
// Invalidate any start still in flight BEFORE anything else, so it abandons itself at its next
|
|
4468
4635
|
// await rather than opening a microphone behind a session that is being ended.
|
|
4469
4636
|
this.startGeneration++;
|
|
@@ -4473,7 +4640,7 @@ export class RealtimeSessionRuntime {
|
|
|
4473
4640
|
await this.teardownInFlight;
|
|
4474
4641
|
return;
|
|
4475
4642
|
}
|
|
4476
|
-
this.teardownInFlight = this.runTeardown(
|
|
4643
|
+
this.teardownInFlight = this.runTeardown(end).finally(() => {
|
|
4477
4644
|
if (this._savingRecording$.value) {
|
|
4478
4645
|
this._savingRecording$.next(false);
|
|
4479
4646
|
}
|
|
@@ -4482,11 +4649,22 @@ export class RealtimeSessionRuntime {
|
|
|
4482
4649
|
await this.teardownInFlight;
|
|
4483
4650
|
}
|
|
4484
4651
|
/** The body of {@link teardown}; never called concurrently with itself. */
|
|
4485
|
-
async runTeardown(
|
|
4652
|
+
async runTeardown(end) {
|
|
4486
4653
|
// Arm BEFORE any await so a tab closed in the first milliseconds of teardown is still guarded.
|
|
4487
4654
|
if (this.recorder) {
|
|
4488
4655
|
this._savingRecording$.next(true);
|
|
4489
4656
|
}
|
|
4657
|
+
// Whatever a step below does, the slot is released: a teardown that threw halfway used to leave
|
|
4658
|
+
// `IsActive` set for good, so every later start was refused (MJ#4629).
|
|
4659
|
+
try {
|
|
4660
|
+
await this.releaseCallResources(end);
|
|
4661
|
+
}
|
|
4662
|
+
finally {
|
|
4663
|
+
this.releaseSessionSlot(end);
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
/** The teardown's work: stop the call, hand back what it held, and close the server session. */
|
|
4667
|
+
async releaseCallResources(end) {
|
|
4490
4668
|
// First: stop asserting liveness. A pulse racing the close would re-stamp LastActiveAt on a
|
|
4491
4669
|
// session we are deliberately ending, leaving an Idle row the janitor then has to age out.
|
|
4492
4670
|
this.stopLivenessPulse();
|
|
@@ -4546,8 +4724,8 @@ export class RealtimeSessionRuntime {
|
|
|
4546
4724
|
}
|
|
4547
4725
|
await this.flushPendingUsage(this.agentSessionId);
|
|
4548
4726
|
this.resetUsageRelay();
|
|
4549
|
-
if (
|
|
4550
|
-
await this.closeServerSession(this.agentSessionId);
|
|
4727
|
+
if (this.agentSessionId) {
|
|
4728
|
+
await this.closeServerSession(this.agentSessionId, end === 'error' ? 'Error' : 'Explicit');
|
|
4551
4729
|
}
|
|
4552
4730
|
// Wait for the terminal's ack only AFTER the close: nothing after an aborted await ever runs, so
|
|
4553
4731
|
// the small, critical close must never queue behind the recording (#5195). Bounded by
|
|
@@ -4555,11 +4733,15 @@ export class RealtimeSessionRuntime {
|
|
|
4555
4733
|
if (recordingTerminal) {
|
|
4556
4734
|
await this.awaitRecordingTerminal(recordingTerminal);
|
|
4557
4735
|
}
|
|
4736
|
+
}
|
|
4737
|
+
/** Clears the slot and tells the host the call ended; runs even when {@link releaseCallResources} threw. */
|
|
4738
|
+
releaseSessionSlot(end) {
|
|
4558
4739
|
// Capture the session id BEFORE we null it so the lifecycle emit carries it.
|
|
4559
4740
|
// Skip emitting when there was no live session (defensive — teardown is safe
|
|
4560
4741
|
// to call without an active session).
|
|
4561
4742
|
const closedSessionId = this.agentSessionId;
|
|
4562
4743
|
this.agentSessionId = null;
|
|
4744
|
+
this.callWentLive = false;
|
|
4563
4745
|
this.sessionConversationId = null;
|
|
4564
4746
|
this.narrationTemplate = null;
|
|
4565
4747
|
this.clientToolHandlers.clear();
|
|
@@ -4572,14 +4754,9 @@ export class RealtimeSessionRuntime {
|
|
|
4572
4754
|
if (this._connectionState$.value !== 'error') {
|
|
4573
4755
|
this.setConnectionState('closed');
|
|
4574
4756
|
}
|
|
4575
|
-
// Surface generic session-ended for the conversations runtime bridge.
|
|
4576
|
-
// `closeServerSession=true` means the user explicitly called EndRealtimeSession;
|
|
4577
|
-
// `false` means teardown ran from a catch block (start path error path).
|
|
4757
|
+
// Surface generic session-ended for the conversations runtime bridge, with why it ended.
|
|
4578
4758
|
if (closedSessionId) {
|
|
4579
|
-
this._sessionEnded$.next({
|
|
4580
|
-
sessionId: closedSessionId,
|
|
4581
|
-
reason: closeServerSession ? 'explicit' : 'error',
|
|
4582
|
-
});
|
|
4759
|
+
this._sessionEnded$.next({ sessionId: closedSessionId, reason: end });
|
|
4583
4760
|
}
|
|
4584
4761
|
}
|
|
4585
4762
|
/**
|
|
@@ -4594,18 +4771,22 @@ export class RealtimeSessionRuntime {
|
|
|
4594
4771
|
console.error('[RealtimeSession] Media host failed to release the microphone:', error);
|
|
4595
4772
|
}
|
|
4596
4773
|
}
|
|
4597
|
-
/**
|
|
4598
|
-
|
|
4774
|
+
/**
|
|
4775
|
+
* Calls the `CloseAgentSession` mutation (provisioned in P4b) with why the client ended the call, which
|
|
4776
|
+
* the server stamps as the row's `CloseReason`. Requires a server that accepts the `closeReason`
|
|
4777
|
+
* argument (MJ#6013); the runtime and the server ship together.
|
|
4778
|
+
*/
|
|
4779
|
+
async closeServerSession(agentSessionId, closeReason) {
|
|
4599
4780
|
try {
|
|
4600
4781
|
const mutation = `
|
|
4601
|
-
mutation CloseAgentSession($agentSessionId: String
|
|
4602
|
-
CloseAgentSession(agentSessionId: $agentSessionId)
|
|
4782
|
+
mutation CloseAgentSession($agentSessionId: String!, $closeReason: String) {
|
|
4783
|
+
CloseAgentSession(agentSessionId: $agentSessionId, closeReason: $closeReason)
|
|
4603
4784
|
}
|
|
4604
4785
|
`;
|
|
4605
|
-
await this.gql().ExecuteGQL(mutation, { agentSessionId });
|
|
4786
|
+
await this.gql().ExecuteGQL(mutation, { agentSessionId, closeReason });
|
|
4606
4787
|
}
|
|
4607
4788
|
catch (error) {
|
|
4608
|
-
console.error(
|
|
4789
|
+
console.error(`[RealtimeSession] Failed to close server session ${agentSessionId} (closeReason ${closeReason}):`, error);
|
|
4609
4790
|
}
|
|
4610
4791
|
}
|
|
4611
4792
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -4629,6 +4810,7 @@ export class RealtimeSessionRuntime {
|
|
|
4629
4810
|
this.turnAudioStartCaptured = false;
|
|
4630
4811
|
this.userSpeechStartMs = null;
|
|
4631
4812
|
this.userSpeechStartSuperseded = false;
|
|
4813
|
+
this.userSpeechStartByItemId.clear();
|
|
4632
4814
|
this.usedChannelNames.clear();
|
|
4633
4815
|
this.microphoneMuted = false;
|
|
4634
4816
|
}
|