@memberjunction/realtime-runtime 6.2.0-edge.5 → 6.2.0-edge.7

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,20 @@ function microphoneStartError(reason, message) {
169
170
  error.name = MICROPHONE_ERROR_NAMES[reason];
170
171
  return error;
171
172
  }
173
+ /** The one mapping from how a call ended to the `CloseReason` the client claims for its row; a new end reason is a compile error here. */
174
+ const CLOSE_REASON_BY_END = {
175
+ explicit: 'Explicit',
176
+ error: 'Error',
177
+ deadline: 'TimeLimit',
178
+ };
179
+ /** Thrown by a start while a call is live: the runtime holds one driver, and never replaces a live one. */
180
+ export class RealtimeSessionAlreadyActiveError extends Error {
181
+ constructor(ActiveAgentSessionId) {
182
+ super(`A realtime session is already active${ActiveAgentSessionId ? ` (${ActiveAgentSessionId})` : ''}; end it before starting another.`);
183
+ this.ActiveAgentSessionId = ActiveAgentSessionId;
184
+ this.name = 'RealtimeSessionAlreadyActiveError';
185
+ }
186
+ }
172
187
  /**
173
188
  * Drives a **client-direct** real-time voice session: the browser mints an ephemeral
174
189
  * token from the MJ server, then connects DIRECTLY to the realtime provider. Audio
@@ -362,8 +377,9 @@ export class RealtimeSessionRuntime {
362
377
  * Fired EXACTLY ONCE per session as teardown begins, with the prior
363
378
  * `agentSessionId` (so subscribers can correlate against `SessionStarted$`'s
364
379
  * 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 —
380
+ * user called `EndRealtimeSession`, `'error'` when the runtime ended the call over a failure: a
381
+ * failed start, or a fatal error from the provider mid-call; `'deadline'` when the session deadline ended it
382
+ * (the runtime's auto-end, or `EndRealtimeSession('deadline')`). Server-side close paths (janitor, shutdown) do NOT propagate here —
367
383
  * they happen out-of-process and have no client push channel today.
368
384
  */
369
385
  this.SessionEnded$ = this._sessionEnded$.asObservable();
@@ -515,6 +531,13 @@ export class RealtimeSessionRuntime {
515
531
  * final. An EMPTY user transcript releases the onset outright (see onClientTranscript).
516
532
  */
517
533
  this.userSpeechStartSuperseded = false;
534
+ /**
535
+ * Recording-relative ms offsets of user speech onsets keyed by the provider's conversation item id
536
+ * (`input_audio_buffer.speech_started.item_id`), paired with the matching final user transcript
537
+ * (`RealtimeClientTranscript.ItemId`) so a late user transcript never claims a later turn's onset
538
+ * (MJ#5902). Unkeyed drivers continue to use {@link userSpeechStartMs}.
539
+ */
540
+ this.userSpeechStartByItemId = new Map();
518
541
  /**
519
542
  * Wall-anchor of the SESSION clock (#3832): `performance.now()` at the moment the call went
520
543
  * live, or `null` before any call has. Read only through {@link nowTurnOffsetMs}.
@@ -599,6 +622,15 @@ export class RealtimeSessionRuntime {
599
622
  * two `SessionEnded$` emissions for one session.
600
623
  */
601
624
  this.teardownInFlight = null;
625
+ /**
626
+ * True from the moment a start finishes (connected, liveness pulse running) until the slot is
627
+ * released. A driver's Fatal ends the call only while this is set: during a start, drivers emit
628
+ * Fatal from their socket handlers while Connect is still pending, and the start's own failure path
629
+ * ({@link failSessionStart}) is what reports that error, whether Connect then rejects or resolves
630
+ * (runMintedSession checks {@link lastSessionError} after Connect). Not {@link isSessionLive}: drivers move the
631
+ * state to 'listening' from inside Connect, and a live call that is resuming reads 'connecting'.
632
+ */
633
+ this.callWentLive = false;
602
634
  /**
603
635
  * The latest start's opening of the host's microphone, until {@link IRealtimeMediaHost.ReleaseMicrophone} hands it
604
636
  * back; `null` when no opening is owed a release, so a session that ends before asking for the microphone releases
@@ -676,6 +708,26 @@ export class RealtimeSessionRuntime {
676
708
  */
677
709
  this.IdentityVerifiedSpokenResponse = null;
678
710
  this.clientDeadline = new ClientSessionDeadline();
711
+ /**
712
+ * The deadline the clock reached while no session id existed yet (the host set a deadline that passed, or one nearer
713
+ * than the mint takes), or `null`. The mint path consumes it as soon as the session id is adopted, so the call still
714
+ * ends at its deadline; Set, Extend and session teardown drop it, since each replaces or forgets that deadline.
715
+ */
716
+ this.pendingDeadlineReached = null;
717
+ this._sessionDeadlineWarning$ = new Subject();
718
+ this._sessionDeadlineReached$ = new Subject();
719
+ /**
720
+ * Fires once per offset the host passed in `warnBeforeMs` to {@link SetSessionDeadline}, as that moment arrives, with
721
+ * the live session's id. `remainingMs` is the offset. Emits only while a session is live.
722
+ */
723
+ this.SessionDeadlineWarning$ = this._sessionDeadlineWarning$.asObservable();
724
+ /**
725
+ * Fires once when the session deadline is reached, with the live session's id — BEFORE the runtime's own auto-end, so
726
+ * a host that ends the call here (`autoEnd: false`, or a goodbye of its own) is the end that counts; a second end is
727
+ * coalesced onto the first. Emits only while a session is live; a deadline reached while the session is still being
728
+ * minted fires (and auto-ends) as soon as the mint adopts the session id.
729
+ */
730
+ this.SessionDeadlineReached$ = this._sessionDeadlineReached$.asObservable();
679
731
  // ── Interactive channels (registry-driven + host-declared plugins) ─────────
680
732
  //
681
733
  // A session's channels come from two places — the `MJ: AI Agent Channels` registry and the channels
@@ -721,6 +773,7 @@ export class RealtimeSessionRuntime {
721
773
  * Owner-keyed — see {@link AppClientToolRegistry}.
722
774
  */
723
775
  this.appToolRegistry = new AppClientToolRegistry();
776
+ this.wireSessionDeadline();
724
777
  // Re-upload shards a previous, interrupted call left in the local store. Deferred a tick so a
725
778
  // host can assign `Provider` right after construction; when it still has no transport the drain
726
779
  // defers itself and session start tries again. Unref'd so it never holds a Node process open.
@@ -873,9 +926,83 @@ export class RealtimeSessionRuntime {
873
926
  get SessionDeadline() {
874
927
  return this.clientDeadline.Value;
875
928
  }
876
- /** Sets the baseline deadline a host knows at session start (`null` clears). See {@link SessionDeadline$}. */
877
- SetSessionDeadline(deadline) {
878
- this.clientDeadline.Set(deadline);
929
+ /**
930
+ * Sets the baseline deadline a host knows at session start (`null` clears) and arms the alarm. See
931
+ * {@link SessionDeadline$}. By default the runtime ends the call with reason `'deadline'` when it is reached; pass
932
+ * `{ autoEnd: false }` to own the hang-up (then end with `EndRealtimeSession('deadline')`, which claims `TimeLimit`: the
933
+ * server keeps it only against the session's stored deadline, so a deadline set here alone records `Explicit`), and
934
+ * `warnBeforeMs` for {@link SessionDeadlineWarning$}. The server's verified deadline (`identity.verified`) also arms the
935
+ * alarm with the current options (default `autoEnd: true`); a host that owns the hang-up calls `SetSessionDeadline(d, { autoEnd: false })`.
936
+ * The runtime never arms this by itself.
937
+ */
938
+ SetSessionDeadline(deadline, options) {
939
+ this.pendingDeadlineReached = null;
940
+ this.clientDeadline.Set(deadline, options);
941
+ }
942
+ /**
943
+ * Re-publishes the deadline clock's warnings and reach with the live session id, and runs the auto-end. Subscribed
944
+ * once, from the constructor; the clock is owned by this runtime and shares its lifetime, and the runtime has no
945
+ * dispose step, so the subscriptions are never torn down separately. Session teardown clears the clock, which
946
+ * cancels every pending timer.
947
+ */
948
+ wireSessionDeadline() {
949
+ this.clientDeadline.Warning$.subscribe((warning) => {
950
+ const sessionId = this.liveDeadlineSessionId();
951
+ if (sessionId) {
952
+ this._sessionDeadlineWarning$.next({ sessionId, deadline: warning.deadline, remainingMs: warning.remainingMs });
953
+ }
954
+ });
955
+ this.clientDeadline.Reached$.subscribe((reached) => this.onSessionDeadlineReached(reached.deadline));
956
+ }
957
+ /**
958
+ * Tells the host first, then auto-ends unless the host opted out, already ended the call, or moved the deadline from
959
+ * inside the event. Reached before the call went live (before the mint, or while the microphone and connect are still
960
+ * in flight) and with no teardown running, the reach is kept in {@link pendingDeadlineReached} for the start to
961
+ * replay once `SessionStarted$` has fired, so a host never hears of a deadline for a call it was not told started.
962
+ */
963
+ onSessionDeadlineReached(deadline) {
964
+ const sessionId = this.liveDeadlineSessionId();
965
+ if (!sessionId) {
966
+ if (!this.teardownInFlight && this.holdsDeadline(deadline)) {
967
+ this.pendingDeadlineReached = deadline;
968
+ }
969
+ return;
970
+ }
971
+ this._sessionDeadlineReached$.next({ sessionId, deadline });
972
+ if (this.clientDeadline.Options.autoEnd && this.liveDeadlineSessionId() === sessionId && this.holdsDeadline(deadline)) {
973
+ this.teardown('deadline').catch((error) => {
974
+ console.error(`[RealtimeSession] Ending session ${sessionId} at its deadline failed:`, error);
975
+ });
976
+ }
977
+ }
978
+ /** Whether the clock still holds `deadline`: a host that set or extended it (even from inside `Reached$`) has replaced it. */
979
+ holdsDeadline(deadline) {
980
+ return this.clientDeadline.Value?.getTime() === deadline.getTime();
981
+ }
982
+ /**
983
+ * Replays a deadline reached before the call went live, once it is live. On the next tick, so the host's
984
+ * `SessionDeadlineReached$` handler runs outside the start, after the start has finished.
985
+ */
986
+ replayPendingDeadlineReached() {
987
+ const pending = this.pendingDeadlineReached;
988
+ if (!pending) {
989
+ return;
990
+ }
991
+ this.pendingDeadlineReached = null;
992
+ setTimeout(() => {
993
+ if (this.holdsDeadline(pending)) {
994
+ this.onSessionDeadlineReached(pending);
995
+ }
996
+ }, 0);
997
+ }
998
+ /**
999
+ * The session a deadline event belongs to: the session once its call went live, unless it is already being torn
1000
+ * down. A start still opening the microphone or connecting has none: a host that owns the hang-up must be able to
1001
+ * speak its goodbye when it hears the deadline. {@link callWentLive}, not {@link isSessionLive}: a call that is
1002
+ * resuming reads `'connecting'`, and its deadline still holds. Warnings due before the call went live are dropped.
1003
+ */
1004
+ liveDeadlineSessionId() {
1005
+ return this.teardownInFlight || !this.callWentLive ? null : this.agentSessionId;
879
1006
  }
880
1007
  /**
881
1008
  * Builds the transport that reads the session's events. The default rides the session's GraphQL
@@ -935,7 +1062,9 @@ export class RealtimeSessionRuntime {
935
1062
  return;
936
1063
  }
937
1064
  const payload = event.Payload;
938
- this.clientDeadline.Extend(payload.MaxSessionDeadlineIso);
1065
+ if (this.clientDeadline.Extend(payload.MaxSessionDeadlineIso)) {
1066
+ this.pendingDeadlineReached = null;
1067
+ }
939
1068
  this.SendContextNote(`[identity] verified ${JSON.stringify({ email: payload.VerifiedEmail, name: payload.VerifiedName, method: payload.Method })} ` +
940
1069
  '(background context — the user has proven they control this email address; treat them as verified. Do not read this note aloud.)');
941
1070
  const spoken = typeof this.IdentityVerifiedSpokenResponse === 'function' ? this.IdentityVerifiedSpokenResponse(payload) : this.IdentityVerifiedSpokenResponse;
@@ -954,6 +1083,25 @@ export class RealtimeSessionRuntime {
954
1083
  IsActiveFor(conversationId) {
955
1084
  return this.IsActive && conversationId != null && UUIDsEqual(this.sessionConversationId, conversationId);
956
1085
  }
1086
+ /**
1087
+ * Takes the runtime's one driver slot for a new start; the two starts call it after awaiting any
1088
+ * teardown in flight, and only when no End arrived during that wait (a changed {@link startGeneration}).
1089
+ *
1090
+ * A teardown still in flight is awaited rather than read as "busy": ending a call and starting the
1091
+ * next one back to back is normal host behaviour (a retry, a re-mounted surface), and the flag it
1092
+ * used to read flips only at the END of teardown, so the start was dropped with nothing to show
1093
+ * for it — a bare `undefined`, which hosts had to guess at (MJ#4629). A call that is genuinely
1094
+ * live is refused with an error the host can show; it is never silently ignored, and never
1095
+ * overwritten, since a driver no slot points at can never be stopped again.
1096
+ *
1097
+ * Deliberately synchronous: the start's state must be set in the tick the host called it in, or an
1098
+ * End issued straight after the Start (a user tapping back during the mint) finds nothing to abandon.
1099
+ */
1100
+ claimSessionSlot() {
1101
+ if (this.IsActive) {
1102
+ throw new RealtimeSessionAlreadyActiveError(this.agentSessionId);
1103
+ }
1104
+ }
957
1105
  /**
958
1106
  * Start a client-direct voice session fronting `targetAgentId`.
959
1107
  *
@@ -1000,13 +1148,27 @@ export class RealtimeSessionRuntime {
1000
1148
  * ({@link RealtimeSessionStartOptions.ConversationBranchID}; omitted is Main, where its transcript is written).
1001
1149
  */
1002
1150
  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
1151
+ // Captured BEFORE any wait: a teardown from here on bumps it, and the checks below (and in
1152
+ // runMintedSession) abandon the start. Re-capturing after the wait would absorb an End issued during it.
1153
+ const generation = this.startGeneration;
1154
+ // Synchronous unless a teardown is in flight: a start must take the slot in the same tick it was
1155
+ // called in, so a host's End issued right after it finds a start to abandon (see claimSessionSlot).
1156
+ if (this.teardownInFlight) {
1157
+ await this.teardownInFlight;
1158
+ // An End issued during the wait found IsActive still true (the last call was closing), so it only
1159
+ // joined that teardown and bumped the generation. Nothing is acquired yet, so nothing to release.
1160
+ if (this.startGeneration !== generation) {
1161
+ return;
1162
+ }
1163
+ }
1164
+ this.claimSessionSlot();
1165
+ // Armed only now that the slot is ours: set before the claim, a refused start would leave its deadline to end
1166
+ // the call that holds the runtime.
1167
+ if (options?.SessionDeadline) {
1168
+ this.SetSessionDeadline(options.SessionDeadline);
1005
1169
  }
1006
1170
  const consent = this.beginSessionStart({ agentName, recordingConsent, applicationId, appContext });
1007
1171
  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
1172
  // Captured BEFORE the channels are prepared so the mint carries exactly the snapshot the prologue
1011
1173
  // resolved, whatever a channel plugin may push in the meantime.
1012
1174
  const effectiveAppContext = this._appContext$.value;
@@ -1032,14 +1194,14 @@ export class RealtimeSessionRuntime {
1032
1194
  // The host ended the start while the mint ran. That teardown had no session id to close, so
1033
1195
  // close the minted row here, and touch no shared state: a newer start may already own it.
1034
1196
  if (this.startGeneration !== generation) {
1035
- await this.closeServerSession(session.AgentSessionId);
1197
+ await this.closeServerSession(session.AgentSessionId, 'Explicit');
1036
1198
  return;
1037
1199
  }
1038
1200
  if (!this.hostCanUseProvider(session.Provider)) {
1039
1201
  await this.abortUnusableSession(session);
1040
1202
  return;
1041
1203
  }
1042
- await this.runMintedSession(session, conversationId ?? null, consent, options?.CameraCheck === true);
1204
+ await this.runMintedSession(session, conversationId ?? null, consent, options?.CameraCheck === true, generation);
1043
1205
  }
1044
1206
  /**
1045
1207
  * Declares which provider keys this host can actually carry audio for.
@@ -1075,8 +1237,7 @@ export class RealtimeSessionRuntime {
1075
1237
  // published them on ActiveChannels$; skipping teardown would leave them undisposed, their tool
1076
1238
  // handlers registered, and their subscriptions live until the next start replaced them.
1077
1239
  this.agentSessionId = session.AgentSessionId ?? this.agentSessionId;
1078
- this.setConnectionState('error');
1079
- await this.teardown(true);
1240
+ await this.endOnFailure();
1080
1241
  }
1081
1242
  /**
1082
1243
  * Run a session the HOST has already minted itself — the second half of
@@ -1100,22 +1261,28 @@ export class RealtimeSessionRuntime {
1100
1261
  * Every field defaults exactly as its {@link StartRealtimeSession} counterpart does.
1101
1262
  */
1102
1263
  async StartRealtimeSessionFromResult(result, options) {
1103
- if (this.IsActive) {
1104
- return; // a session is already running — ignore duplicate starts
1264
+ // Same wait, same generation check, same reasons as StartRealtimeSession.
1265
+ const generation = this.startGeneration;
1266
+ if (this.teardownInFlight) {
1267
+ await this.teardownInFlight;
1268
+ if (this.startGeneration !== generation) {
1269
+ return;
1270
+ }
1105
1271
  }
1272
+ this.claimSessionSlot();
1106
1273
  const effectiveOptions = options ?? {};
1107
1274
  if (!this.hostCanUseProvider(result.Provider)) {
1108
1275
  await this.abortUnusableSession(result);
1109
1276
  return;
1110
1277
  }
1111
1278
  const consent = this.beginSessionStart(effectiveOptions);
1112
- await this.runMintedSession(result, effectiveOptions.conversationId ?? null, consent, false);
1279
+ await this.runMintedSession(result, effectiveOptions.conversationId ?? null, consent, false, generation);
1113
1280
  }
1114
1281
  /**
1115
1282
  * Start prologue shared by both entry points: clear the last start's failure, bind the app layer,
1116
1283
  * 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
1284
+ * makes {@link claimSessionSlot} refuse a second start with {@link RealtimeSessionAlreadyActiveError}
1285
+ * while the mint is still in flight — hence it runs before minting, not after). Returns the resolved recording consent, which the mint half
1119
1286
  * reports to the server and the run half uses to decide whether to record.
1120
1287
  */
1121
1288
  beginSessionStart(options) {
@@ -1155,10 +1322,10 @@ export class RealtimeSessionRuntime {
1155
1322
  * the provider connection, and go live. `inputConversationId` is the conversation the START asked
1156
1323
  * for (null ⇒ "server, make me one") — the result alone can't distinguish the two. `cameraCheck` is
1157
1324
  * whether the host shows a camera check ({@link RealtimeSessionStartOptions.CameraCheck}).
1325
+ * `generation` is the {@link startGeneration} the entry point captured before its first await; every
1326
+ * await below is a window in which the host can end the session, and so was every await before this.
1158
1327
  */
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;
1328
+ async runMintedSession(session, inputConversationId, consent, cameraCheck, generation) {
1162
1329
  try {
1163
1330
  // Mount the channels the resolved policy puts in the session. Deliberately BEFORE the session
1164
1331
  // id is adopted: `ActiveChannels$` consumers (the sessions adapter) treat an emission that
@@ -1207,6 +1374,13 @@ export class RealtimeSessionRuntime {
1207
1374
  if (connectError) {
1208
1375
  throw connectError;
1209
1376
  }
1377
+ // A Fatal the driver sent during this start (the fatal handler leaves it to the start, see
1378
+ // callWentLive) fails the start even when Connect resolved: the real drivers keep awaiting mic
1379
+ // capture after their connect-failure channel settles, then report 'listening' and resolve on a
1380
+ // socket that already closed. lastSessionError is reset per start and set only by the current client.
1381
+ if (this.lastSessionError?.Fatal) {
1382
+ throw new Error(this.lastSessionError.Message);
1383
+ }
1210
1384
  // Tracks are negotiated now, so a capture can tell whether the model takes video, and the call whether it shows
1211
1385
  // the avatar its agent asked for. The driver holds the microphone now, so the user may switch it.
1212
1386
  this.openCaptures(client, cameraCheck);
@@ -1258,6 +1432,8 @@ export class RealtimeSessionRuntime {
1258
1432
  // Same place, same reason: the session is connected and its id is known, which is exactly
1259
1433
  // the window in which the server needs to be told it is alive.
1260
1434
  this.startLivenessPulse();
1435
+ this.callWentLive = true;
1436
+ this.replayPendingDeadlineReached();
1261
1437
  }
1262
1438
  catch (error) {
1263
1439
  await this.failSessionStart(error);
@@ -1319,7 +1495,7 @@ export class RealtimeSessionRuntime {
1319
1495
  // session, and clearing the id would leave that teardown no session to report.
1320
1496
  if (session.AgentSessionId && this.agentSessionId === session.AgentSessionId && !this.teardownInFlight) {
1321
1497
  this.agentSessionId = null;
1322
- await this.closeServerSession(session.AgentSessionId);
1498
+ await this.closeServerSession(session.AgentSessionId, 'Explicit');
1323
1499
  }
1324
1500
  }
1325
1501
  /**
@@ -1630,18 +1806,37 @@ export class RealtimeSessionRuntime {
1630
1806
  async failSessionStart(error) {
1631
1807
  console.error('[RealtimeSession] Failed to start session:', error);
1632
1808
  this.lastStartError = error instanceof Error ? error : new Error(String(error));
1809
+ await this.endOnFailure();
1810
+ }
1811
+ /**
1812
+ * Ends the call over a failure: a failed start, an unusable provider, or a fatal provider error.
1813
+ *
1814
+ * The teardown is claimed BEFORE `'error'` is published. `ConnectionState$` emits synchronously, and a
1815
+ * host may end the call the moment it sees `'error'` (MobileApp's voice screen does); claimed first, that
1816
+ * host's End joins this teardown instead of starting its own as `'explicit'`, so the row still closes as
1817
+ * `Error` and `SessionEnded$` still says `'error'`. Teardown keeps a terminal `'error'` state.
1818
+ */
1819
+ endOnFailure() {
1820
+ const ending = this.teardown('error');
1633
1821
  this.setConnectionState('error');
1634
- await this.teardown(false);
1822
+ return ending;
1635
1823
  }
1636
1824
  /**
1637
1825
  * End the active session: stop the mic, tear down the provider connection, and close
1638
1826
  * the server-side agent session. Safe to call when no session is active.
1827
+ *
1828
+ * @param reason `'deadline'` for a host that owns the hang-up at its deadline (`autoEnd: false`): the end is reported
1829
+ * as `'deadline'` and the row is claimed as `TimeLimit`. The server keeps `TimeLimit` only when the session's own
1830
+ * stored deadline (`Config_.maxSessionDeadlineIso`, stamped at mint, e.g. a widget guest's voice cap) has passed or is
1831
+ * within a minute; a deadline known only to this browser is recorded as `Explicit`. Default `'explicit'`.
1639
1832
  */
1640
- async EndRealtimeSession() {
1833
+ async EndRealtimeSession(reason = 'explicit') {
1641
1834
  if (!this.IsActive && !this.agentSessionId) {
1642
1835
  return;
1643
1836
  }
1644
- await this.teardown(true);
1837
+ // A JS caller wiring this straight to a click handler passes the event object; anything but 'deadline' is explicit.
1838
+ const end = reason === 'deadline' ? 'deadline' : 'explicit';
1839
+ await this.teardown(end);
1645
1840
  }
1646
1841
  /**
1647
1842
  * Inject a typed message into the live session as a user turn.
@@ -1732,7 +1927,17 @@ export class RealtimeSessionRuntime {
1732
1927
  if (trimmed.length === 0 || !this.client || !this.isSessionLive()) {
1733
1928
  return false;
1734
1929
  }
1735
- this.requestChannelSpokenResponse(trimmed);
1930
+ // The opening is part of the conversation, so it goes out as a spoken TURN: captioned, relayed
1931
+ // and persisted like any turn the model takes on its own. It used to ride the delegation
1932
+ // narration channel, whose replies are disposable by contract, so the greeting the participant
1933
+ // heard was never in the transcript (MJ#4591). A driver from before the capability existed
1934
+ // falls back to that channel rather than leaving the session silent.
1935
+ if (this.client.RequestSpokenTurn) {
1936
+ this.client.RequestSpokenTurn(trimmed);
1937
+ }
1938
+ else {
1939
+ this.client.RequestSpokenUpdate(trimmed);
1940
+ }
1736
1941
  return true;
1737
1942
  }
1738
1943
  /**
@@ -1814,6 +2019,7 @@ export class RealtimeSessionRuntime {
1814
2019
  this.turnAudioStartCaptured = false;
1815
2020
  this.userSpeechStartMs = null;
1816
2021
  this.userSpeechStartSuperseded = false;
2022
+ this.userSpeechStartByItemId.clear();
1817
2023
  if (this.recorder) {
1818
2024
  // WebRTC drivers (OpenAI): the agent's track usually lands AFTER Connect() resolves, so
1819
2025
  // `remoteStream` above is null here and this handler attaches it later. PCM-playback
@@ -1948,17 +2154,19 @@ export class RealtimeSessionRuntime {
1948
2154
  // Closes over this recording's session id and transport — never the runtime's current ones.
1949
2155
  Queue: new RecordingShardQueue(async (shard) => {
1950
2156
  if (!transport) {
1951
- return false;
2157
+ return 'Retry';
1952
2158
  }
1953
- const acked = (await transport.UploadShard(sessionId, shard)) === 'Acked';
1954
- if (acked) {
2159
+ // The outcome goes to the queue unchanged: it logs a different reason for each, and stops
2160
+ // retrying a `Refused` shard at once (#6012).
2161
+ const outcome = await transport.UploadShard(sessionId, shard);
2162
+ if (outcome === 'Acked') {
1955
2163
  await this.deleteStoredShard(sessionId, shard.Index);
1956
2164
  if (shardsGivenUp) {
1957
2165
  shardsGivenUp = false;
1958
2166
  this.recoverStoredShardsAfterOutage(sessionId);
1959
2167
  }
1960
2168
  }
1961
- return acked;
2169
+ return outcome;
1962
2170
  }, () => {
1963
2171
  shardsGivenUp = true;
1964
2172
  }),
@@ -2292,6 +2500,7 @@ export class RealtimeSessionRuntime {
2292
2500
  this.turnAudioStartCaptured = false;
2293
2501
  this.userSpeechStartMs = null;
2294
2502
  this.userSpeechStartSuperseded = false;
2503
+ this.userSpeechStartByItemId.clear();
2295
2504
  if (!recorder || !pipeline) {
2296
2505
  this.recorder = null;
2297
2506
  this.recordingShards = null;
@@ -3384,6 +3593,27 @@ export class RealtimeSessionRuntime {
3384
3593
  console.error('[RealtimeSession] Provider error event:', JSON.stringify(error), error);
3385
3594
  if (error.Fatal && this.client === client) {
3386
3595
  this.lastSessionError = error;
3596
+ // While the start is still running, its own failure path reports the error and ends the call
3597
+ // (runMintedSession throws on this recorded Fatal after Connect, even if Connect resolves):
3598
+ // ending it here would bump startGeneration, so the start would read as ended by the host and
3599
+ // never set LastStartError.
3600
+ if (!this.callWentLive) {
3601
+ return;
3602
+ }
3603
+ // An explicit teardown is already ending the call and the client stays current until its
3604
+ // finally: a driver reporting its own close as Fatal from inside Disconnect() is part of that
3605
+ // clean End, and must not flip it to 'error' or start a second teardown.
3606
+ if (this.teardownInFlight) {
3607
+ return;
3608
+ }
3609
+ // The driver is dead on the wire; nothing resumes after it says Fatal. End the call the way a
3610
+ // failed start ends: the row closes as Error, the microphone is released, the host is told
3611
+ // through SessionEnded$ (MJ#4629). A host's own End afterwards finds nothing to do.
3612
+ // Captured now: teardown nulls agentSessionId, and the failure it reports must name the session.
3613
+ const failedSessionId = this.agentSessionId;
3614
+ void this.endOnFailure().catch((teardownError) => {
3615
+ console.error(`[RealtimeSession] Teardown after a fatal provider error failed for session ${failedSessionId}:`, teardownError);
3616
+ });
3387
3617
  }
3388
3618
  });
3389
3619
  // Usage telemetry: accumulate the driver's per-response token DELTAS and relay them to
@@ -3403,9 +3633,10 @@ export class RealtimeSessionRuntime {
3403
3633
  });
3404
3634
  // The provider VAD saw the user start speaking. Final-only user-transcript drivers (OpenAI,
3405
3635
  // 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());
3636
+ // to its own slot (or item-keyed map entry when `itemId` is present), not the shared turn slot:
3637
+ // the user transcript arrives asynchronously, so the assistant's reply may already be streaming
3638
+ // and must keep its own start.
3639
+ client.OnUserSpeechStart((itemId) => this.markUserSpeechStart(itemId));
3409
3640
  client.OnRemoteVideo((video) => this.onAgentVideo(client, video));
3410
3641
  }
3411
3642
  /**
@@ -3511,7 +3742,7 @@ export class RealtimeSessionRuntime {
3511
3742
  if (transcript.Role === 'User' && transcript.Text.trim().length === 0) {
3512
3743
  // An empty final user transcript (a VAD false positive, an empty transcription) never reaches
3513
3744
  // relayTranscript, so release its onset here: it must not become the next real turn's start.
3514
- this.releaseUserSpeechStart();
3745
+ this.releaseUserSpeechStart(transcript.ItemId);
3515
3746
  }
3516
3747
  if (transcript.Role === 'Assistant') {
3517
3748
  this.hasActiveInterimUserCaption = false;
@@ -3555,7 +3786,7 @@ export class RealtimeSessionRuntime {
3555
3786
  if (this.firstUserTranscript === null) {
3556
3787
  this.firstUserTranscript = transcript.Text;
3557
3788
  }
3558
- await this.relayTranscript('user', transcript.Text);
3789
+ await this.relayTranscript('user', transcript.Text, false, 'speech', transcript.ItemId);
3559
3790
  }
3560
3791
  else if (transcript.ReplacesPrevious) {
3561
3792
  // STREAMING user transcription: providers like Grok and OpenAI Live emit the growing utterance as repeated
@@ -3566,10 +3797,10 @@ export class RealtimeSessionRuntime {
3566
3797
  return;
3567
3798
  }
3568
3799
  this.replaceLastCaption('User', transcript.Text);
3569
- await this.relayTranscript('user', transcript.Text, true);
3800
+ await this.relayTranscript('user', transcript.Text, true, 'speech', transcript.ItemId);
3570
3801
  }
3571
3802
  else {
3572
- await this.onUserTranscript(transcript.Text);
3803
+ await this.onUserTranscript(transcript.Text, 'speech', transcript.ItemId);
3573
3804
  }
3574
3805
  }
3575
3806
  /**
@@ -3604,13 +3835,24 @@ export class RealtimeSessionRuntime {
3604
3835
  this.turnAudioStartCaptured = true;
3605
3836
  }
3606
3837
  /**
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.
3838
+ * Stamps the user speech onset when the client signals that the user began to speak. When the
3839
+ * driver supplies a conversation `itemId`, records the FIRST onset for that item in
3840
+ * {@link userSpeechStartByItemId} so a mid-sentence pause that re-fires with the same id keeps the
3841
+ * original start. When no `itemId` is supplied, stamps the single-slot {@link userSpeechStartMs}
3842
+ * fallback (keeping the first onset unless {@link userSpeechStartSuperseded} is set).
3612
3843
  */
3613
- markUserSpeechStart() {
3844
+ markUserSpeechStart(itemId) {
3845
+ if (itemId !== undefined && itemId.length > 0) {
3846
+ if (this.userSpeechStartByItemId.has(itemId)) {
3847
+ return;
3848
+ }
3849
+ const offset = this.nowTurnOffsetMs();
3850
+ if (offset === null) {
3851
+ return;
3852
+ }
3853
+ this.userSpeechStartByItemId.set(itemId, offset);
3854
+ return;
3855
+ }
3614
3856
  if (this.userSpeechStartMs !== null && !this.userSpeechStartSuperseded) {
3615
3857
  return;
3616
3858
  }
@@ -3621,11 +3863,36 @@ export class RealtimeSessionRuntime {
3621
3863
  this.userSpeechStartMs = offset;
3622
3864
  this.userSpeechStartSuperseded = false;
3623
3865
  }
3624
- /** Ends the held user onset: a final user transcript (empty or not) closed the utterance it began. */
3625
- releaseUserSpeechStart() {
3866
+ /**
3867
+ * Ends the held user onset: a final user transcript (empty or not) closed the utterance it began.
3868
+ * When `itemId` is supplied, deletes only that item's entry in {@link userSpeechStartByItemId};
3869
+ * otherwise clears the unkeyed single slot.
3870
+ */
3871
+ releaseUserSpeechStart(itemId) {
3872
+ if (itemId !== undefined && itemId.length > 0) {
3873
+ this.userSpeechStartByItemId.delete(itemId);
3874
+ return;
3875
+ }
3626
3876
  this.userSpeechStartMs = null;
3627
3877
  this.userSpeechStartSuperseded = false;
3628
3878
  }
3879
+ /**
3880
+ * Consumes and deletes the onset recorded for `itemId` in {@link userSpeechStartByItemId}. When a
3881
+ * matching onset is found, also deletes any remaining map entry with an earlier offset so a VAD
3882
+ * false positive whose transcription never arrived cannot accumulate in the map.
3883
+ */
3884
+ takeKeyedUserSpeechStart(itemId) {
3885
+ const onset = this.userSpeechStartByItemId.get(itemId) ?? null;
3886
+ this.userSpeechStartByItemId.delete(itemId);
3887
+ if (onset !== null) {
3888
+ for (const [otherId, otherOnset] of this.userSpeechStartByItemId) {
3889
+ if (otherOnset < onset) {
3890
+ this.userSpeechStartByItemId.delete(otherId);
3891
+ }
3892
+ }
3893
+ }
3894
+ return onset;
3895
+ }
3629
3896
  /**
3630
3897
  * The current per-turn offset in ms — the RECORDER's clock when one runs (an offset into a
3631
3898
  * seekable file), else the SESSION clock (#3832: orderable and displayable, not seekable),
@@ -3664,8 +3931,9 @@ export class RealtimeSessionRuntime {
3664
3931
  *
3665
3932
  * @param source `'typed'` for a {@link SendText} turn: it has no speech onset, so it neither takes
3666
3933
  * nor clears the held one (a spoken transcript may still be pending for it).
3934
+ * @param itemId Provider conversation item id (`RealtimeClientTranscript.ItemId`) when present.
3667
3935
  */
3668
- async onUserTranscript(transcript, source = 'speech') {
3936
+ async onUserTranscript(transcript, source = 'speech', itemId) {
3669
3937
  if (transcript.trim().length === 0) {
3670
3938
  return;
3671
3939
  }
@@ -3674,7 +3942,7 @@ export class RealtimeSessionRuntime {
3674
3942
  this.firstUserTranscript = transcript;
3675
3943
  }
3676
3944
  this.appendCaption({ Role: 'User', Text: transcript });
3677
- await this.relayTranscript('user', transcript, false, source);
3945
+ await this.relayTranscript('user', transcript, false, source, itemId);
3678
3946
  }
3679
3947
  // ── Tool calling ───────────────────────────────────────────────────────────
3680
3948
  /**
@@ -4022,16 +4290,23 @@ export class RealtimeSessionRuntime {
4022
4290
  * recent persisted turn of this role IN PLACE instead of appending (e.g. ElevenLabs'
4023
4291
  * post-barge-in `agent_response_correction`).
4024
4292
  * @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.
4293
+ * {@link userSpeechStartMs} or {@link userSpeechStartByItemId}, which belong to the user's speech.
4294
+ * @param itemId Provider conversation item id (`RealtimeClientTranscript.ItemId`) when present.
4026
4295
  */
4027
- async relayTranscript(role, text, replacesPrevious = false, source = 'speech') {
4296
+ async relayTranscript(role, text, replacesPrevious = false, source = 'speech', itemId) {
4028
4297
  if (!this.agentSessionId) {
4029
4298
  return;
4030
4299
  }
4031
4300
  // Every final user transcript closes the signalled onset — a correction too, since it ends the
4032
4301
  // same utterance — so a later onset (an xAI mid-sentence resume) can't leak into the next turn.
4302
+ // When the transcript carries a provider itemId, consume its keyed onset from the map without
4303
+ // touching the unkeyed single slot or falling back to currentTurnStartMs.
4304
+ const hasItemId = role === 'user' && source === 'speech' && itemId !== undefined && itemId.length > 0;
4033
4305
  let userSpeechStartMs = null;
4034
- if (role === 'user' && source === 'speech') {
4306
+ if (hasItemId) {
4307
+ userSpeechStartMs = this.takeKeyedUserSpeechStart(itemId);
4308
+ }
4309
+ else if (role === 'user' && source === 'speech') {
4035
4310
  userSpeechStartMs = this.userSpeechStartMs;
4036
4311
  this.releaseUserSpeechStart();
4037
4312
  }
@@ -4042,9 +4317,10 @@ export class RealtimeSessionRuntime {
4042
4317
  const utteranceEndMs = this.nowTurnOffsetMs();
4043
4318
  const opensTurn = utteranceEndMs !== null && !replacesPrevious;
4044
4319
  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.
4320
+ if (opensTurn && (hasItemId || userSpeechStartMs !== null)) {
4321
+ // A user turn with a signalled onset (or a keyed user turn whose itemId was not in the map,
4322
+ // which relays null). Leave the shared slot alone: the user transcript arrives asynchronously,
4323
+ // so it may already hold the in-flight assistant reply's start.
4048
4324
  utteranceStartMs = userSpeechStartMs;
4049
4325
  if (!this.turnAudioStartCaptured) {
4050
4326
  // No turn has captured a start, so the slot holds only the recording-start seed (0). This
@@ -4222,34 +4498,34 @@ export class RealtimeSessionRuntime {
4222
4498
  * delegation path is untouched.
4223
4499
  */
4224
4500
  onDelegationStatusMessage(raw) {
4225
- const frame = this.parseScreencastFrame(raw);
4501
+ let parsed;
4502
+ try {
4503
+ parsed = JSON.parse(raw);
4504
+ }
4505
+ catch {
4506
+ return;
4507
+ }
4508
+ const frame = this.parseScreencastFrame(parsed);
4226
4509
  if (frame) {
4227
4510
  this.routeScreencastFrame(frame);
4228
4511
  return;
4229
4512
  }
4230
- const audio = this.parseAudioChunk(raw);
4513
+ const audio = this.parseAudioChunk(parsed);
4231
4514
  if (audio) {
4232
4515
  this.routeAudioChunk(audio);
4233
4516
  return;
4234
4517
  }
4235
- const progress = this.parseProgress(raw);
4518
+ const progress = this.parseProgress(parsed);
4236
4519
  if (progress) {
4237
4520
  this.dispatchProgress(progress);
4238
4521
  }
4239
4522
  }
4240
4523
  /**
4241
- * Parses a push-status message and returns it only when it's a Remote Browser screencast frame for the
4524
+ * Matches a parsed push-status payload and returns it only when it's a Remote Browser screencast frame for the
4242
4525
  * active session — otherwise `null` (ignored, so delegation progress falls through). Matched by
4243
4526
  * `resolver` + `type`, then scoped to THIS session by `agentSessionID`.
4244
4527
  */
4245
- parseScreencastFrame(raw) {
4246
- let payload;
4247
- try {
4248
- payload = JSON.parse(raw);
4249
- }
4250
- catch {
4251
- return null;
4252
- }
4528
+ parseScreencastFrame(payload) {
4253
4529
  const matches = payload?.resolver === 'RemoteBrowserActionResolver' &&
4254
4530
  payload?.type === 'RemoteBrowserScreencastFrame' &&
4255
4531
  payload?.agentSessionID === this.agentSessionId &&
@@ -4276,18 +4552,11 @@ export class RealtimeSessionRuntime {
4276
4552
  return typeof channel.OnScreencastFrame === 'function';
4277
4553
  }
4278
4554
  /**
4279
- * Parses a push-status message and returns it only when it's a Remote Browser audio chunk for the active
4555
+ * Matches a parsed push-status payload and returns it only when it's a Remote Browser audio chunk for the active
4280
4556
  * session — otherwise `null` (ignored). Matched by `resolver` + `type`, then scoped to THIS session by
4281
4557
  * `agentSessionID`.
4282
4558
  */
4283
- parseAudioChunk(raw) {
4284
- let payload;
4285
- try {
4286
- payload = JSON.parse(raw);
4287
- }
4288
- catch {
4289
- return null;
4290
- }
4559
+ parseAudioChunk(payload) {
4291
4560
  const matches = payload?.resolver === 'RemoteBrowserActionResolver' &&
4292
4561
  payload?.type === 'RemoteBrowserAudioChunk' &&
4293
4562
  payload?.agentSessionID === this.agentSessionId &&
@@ -4318,17 +4587,10 @@ export class RealtimeSessionRuntime {
4318
4587
  return typeof channel.OnAudioChunk === 'function';
4319
4588
  }
4320
4589
  /**
4321
- * Parses a push-status message and returns it only when it's a delegation
4590
+ * Matches a parsed push-status payload and returns it only when it's a delegation
4322
4591
  * progress event for the active voice session — otherwise `null` (ignored).
4323
4592
  */
4324
- parseProgress(raw) {
4325
- let payload;
4326
- try {
4327
- payload = JSON.parse(raw);
4328
- }
4329
- catch {
4330
- return null; // non-JSON or unrelated frame
4331
- }
4593
+ parseProgress(payload) {
4332
4594
  const matches = payload?.resolver === 'RealtimeClientSessionResolver' &&
4333
4595
  payload?.type === 'RealtimeDelegationProgress' &&
4334
4596
  payload?.agentSessionID === this.agentSessionId;
@@ -4460,10 +4722,19 @@ export class RealtimeSessionRuntime {
4460
4722
  }
4461
4723
  // ── Teardown ───────────────────────────────────────────────────────────────
4462
4724
  /**
4463
- * Tears down all client resources and (optionally) closes the server session.
4464
- * @param closeServerSession when true, calls `CloseAgentSession` on the server.
4725
+ * Tears down all client resources and closes the server session when one was minted.
4726
+ *
4727
+ * The server row is closed whichever way the call ended: a start that failed after the mint used to
4728
+ * leave its row `Active` until the janitor's staleness sweep, 15 minutes later, so every host that
4729
+ * counts live sessions saw a phantom call (MJ#6013). The janitor remains the fallback for a client
4730
+ * that dies before it can say anything; it is never the plan.
4731
+ *
4732
+ * @param end why the call ended — `'explicit'` when the host or user ended it, `'error'` when the
4733
+ * runtime ended it because the start or the call failed, `'deadline'` when the session deadline ended it.
4734
+ * Reported on `SessionEnded$` and stamped on the server row as its `CloseReason` through
4735
+ * {@link CLOSE_REASON_BY_END} (`Explicit` / `Error` / `TimeLimit`).
4465
4736
  */
4466
- async teardown(closeServerSession) {
4737
+ async teardown(end) {
4467
4738
  // Invalidate any start still in flight BEFORE anything else, so it abandons itself at its next
4468
4739
  // await rather than opening a microphone behind a session that is being ended.
4469
4740
  this.startGeneration++;
@@ -4473,7 +4744,7 @@ export class RealtimeSessionRuntime {
4473
4744
  await this.teardownInFlight;
4474
4745
  return;
4475
4746
  }
4476
- this.teardownInFlight = this.runTeardown(closeServerSession).finally(() => {
4747
+ this.teardownInFlight = this.runTeardown(end).finally(() => {
4477
4748
  if (this._savingRecording$.value) {
4478
4749
  this._savingRecording$.next(false);
4479
4750
  }
@@ -4482,17 +4753,29 @@ export class RealtimeSessionRuntime {
4482
4753
  await this.teardownInFlight;
4483
4754
  }
4484
4755
  /** The body of {@link teardown}; never called concurrently with itself. */
4485
- async runTeardown(closeServerSession) {
4756
+ async runTeardown(end) {
4486
4757
  // Arm BEFORE any await so a tab closed in the first milliseconds of teardown is still guarded.
4487
4758
  if (this.recorder) {
4488
4759
  this._savingRecording$.next(true);
4489
4760
  }
4761
+ // Whatever a step below does, the slot is released: a teardown that threw halfway used to leave
4762
+ // `IsActive` set for good, so every later start was refused (MJ#4629).
4763
+ try {
4764
+ await this.releaseCallResources(end);
4765
+ }
4766
+ finally {
4767
+ this.releaseSessionSlot(end);
4768
+ }
4769
+ }
4770
+ /** The teardown's work: stop the call, hand back what it held, and close the server session. */
4771
+ async releaseCallResources(end) {
4490
4772
  // First: stop asserting liveness. A pulse racing the close would re-stamp LastActiveAt on a
4491
4773
  // session we are deliberately ending, leaving an Idle row the janitor then has to age out.
4492
4774
  this.stopLivenessPulse();
4493
4775
  this.teardownDelegationProgress();
4494
4776
  this.stopSessionEvents();
4495
4777
  this.clientDeadline.Clear();
4778
+ this.pendingDeadlineReached = null;
4496
4779
  // Channels first: flush any unsaved channel state WHILE the live session id is still
4497
4780
  // set (the captured per-save id covers the race anyway), then dispose the plugins.
4498
4781
  this.flushAllChannelSaves();
@@ -4546,8 +4829,8 @@ export class RealtimeSessionRuntime {
4546
4829
  }
4547
4830
  await this.flushPendingUsage(this.agentSessionId);
4548
4831
  this.resetUsageRelay();
4549
- if (closeServerSession && this.agentSessionId) {
4550
- await this.closeServerSession(this.agentSessionId);
4832
+ if (this.agentSessionId) {
4833
+ await this.closeServerSession(this.agentSessionId, CLOSE_REASON_BY_END[end]);
4551
4834
  }
4552
4835
  // Wait for the terminal's ack only AFTER the close: nothing after an aborted await ever runs, so
4553
4836
  // the small, critical close must never queue behind the recording (#5195). Bounded by
@@ -4555,11 +4838,15 @@ export class RealtimeSessionRuntime {
4555
4838
  if (recordingTerminal) {
4556
4839
  await this.awaitRecordingTerminal(recordingTerminal);
4557
4840
  }
4841
+ }
4842
+ /** Clears the slot and tells the host the call ended; runs even when {@link releaseCallResources} threw. */
4843
+ releaseSessionSlot(end) {
4558
4844
  // Capture the session id BEFORE we null it so the lifecycle emit carries it.
4559
4845
  // Skip emitting when there was no live session (defensive — teardown is safe
4560
4846
  // to call without an active session).
4561
4847
  const closedSessionId = this.agentSessionId;
4562
4848
  this.agentSessionId = null;
4849
+ this.callWentLive = false;
4563
4850
  this.sessionConversationId = null;
4564
4851
  this.narrationTemplate = null;
4565
4852
  this.clientToolHandlers.clear();
@@ -4572,14 +4859,9 @@ export class RealtimeSessionRuntime {
4572
4859
  if (this._connectionState$.value !== 'error') {
4573
4860
  this.setConnectionState('closed');
4574
4861
  }
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).
4862
+ // Surface generic session-ended for the conversations runtime bridge, with why it ended.
4578
4863
  if (closedSessionId) {
4579
- this._sessionEnded$.next({
4580
- sessionId: closedSessionId,
4581
- reason: closeServerSession ? 'explicit' : 'error',
4582
- });
4864
+ this._sessionEnded$.next({ sessionId: closedSessionId, reason: end });
4583
4865
  }
4584
4866
  }
4585
4867
  /**
@@ -4594,18 +4876,22 @@ export class RealtimeSessionRuntime {
4594
4876
  console.error('[RealtimeSession] Media host failed to release the microphone:', error);
4595
4877
  }
4596
4878
  }
4597
- /** Calls the `CloseAgentSession` mutation (provisioned in P4b). */
4598
- async closeServerSession(agentSessionId) {
4879
+ /**
4880
+ * Calls the `CloseAgentSession` mutation (provisioned in P4b) with why the client ended the call, which
4881
+ * the server stamps as the row's `CloseReason`. Requires a server that accepts the `closeReason`
4882
+ * argument (MJ#6013); the runtime and the server ship together.
4883
+ */
4884
+ async closeServerSession(agentSessionId, closeReason) {
4599
4885
  try {
4600
4886
  const mutation = `
4601
- mutation CloseAgentSession($agentSessionId: String!) {
4602
- CloseAgentSession(agentSessionId: $agentSessionId)
4887
+ mutation CloseAgentSession($agentSessionId: String!, $closeReason: String) {
4888
+ CloseAgentSession(agentSessionId: $agentSessionId, closeReason: $closeReason)
4603
4889
  }
4604
4890
  `;
4605
- await this.gql().ExecuteGQL(mutation, { agentSessionId });
4891
+ await this.gql().ExecuteGQL(mutation, { agentSessionId, closeReason });
4606
4892
  }
4607
4893
  catch (error) {
4608
- console.error('[RealtimeSession] Failed to close server session:', error);
4894
+ console.error(`[RealtimeSession] Failed to close server session ${agentSessionId} (closeReason ${closeReason}):`, error);
4609
4895
  }
4610
4896
  }
4611
4897
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -4629,6 +4915,7 @@ export class RealtimeSessionRuntime {
4629
4915
  this.turnAudioStartCaptured = false;
4630
4916
  this.userSpeechStartMs = null;
4631
4917
  this.userSpeechStartSuperseded = false;
4918
+ this.userSpeechStartByItemId.clear();
4632
4919
  this.usedChannelNames.clear();
4633
4920
  this.microphoneMuted = false;
4634
4921
  }