@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.
@@ -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 teardown ran from a catch
366
- * block. Server-side close paths (janitor, shutdown) do NOT propagate here —
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
- if (this.IsActive) {
1004
- return; // a session is already running — ignore duplicate starts
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.setConnectionState('error');
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
- if (this.IsActive) {
1104
- return; // a session is already running — ignore duplicate starts
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 the `IsActive` guard suppress duplicate starts while the mint is still in flight — hence it
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
- await this.teardown(false);
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(true);
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
- this.requestChannelSpokenResponse(trimmed);
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 false;
2040
+ return 'Retry';
1952
2041
  }
1953
- const acked = (await transport.UploadShard(sessionId, shard)) === 'Acked';
1954
- if (acked) {
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 acked;
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: the user transcript arrives asynchronously, so the
3407
- // assistant's reply may already be streaming and must keep its own start.
3408
- client.OnUserSpeechStart(() => this.markUserSpeechStart());
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 {@link userSpeechStartMs} when the client signals that the user began to speak. Keeps
3608
- * the FIRST onset since the last final user transcript: the VAD fires again after a
3609
- * mid-sentence pause, and that second onset is not where the utterance began. An onset that an
3610
- * assistant turn which began after it has since superseded ({@link userSpeechStartSuperseded}) is
3611
- * replaced instead.
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
- /** Ends the held user onset: a final user transcript (empty or not) closed the utterance it began. */
3625
- releaseUserSpeechStart() {
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 belongs to the user's speech.
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 (role === 'user' && source === 'speech') {
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. Leave the shared slot alone: the user transcript
4047
- // arrives asynchronously, so it may already hold the in-flight assistant reply's start.
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 (optionally) closes the server session.
4464
- * @param closeServerSession when true, calls `CloseAgentSession` on the server.
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(closeServerSession) {
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(closeServerSession).finally(() => {
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(closeServerSession) {
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 (closeServerSession && this.agentSessionId) {
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
- /** Calls the `CloseAgentSession` mutation (provisioned in P4b). */
4598
- async closeServerSession(agentSessionId) {
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('[RealtimeSession] Failed to close server session:', 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
  }