@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/session/RealtimeSessionRuntime.d.ts +179 -21
- package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
- package/dist/session/RealtimeSessionRuntime.js +392 -105
- package/dist/session/RealtimeSessionRuntime.js.map +1 -1
- package/dist/session/RecordingShardQueue.d.ts +13 -4
- package/dist/session/RecordingShardQueue.d.ts.map +1 -1
- package/dist/session/RecordingShardQueue.js +22 -4
- package/dist/session/RecordingShardQueue.js.map +1 -1
- package/dist/session/RecordingTransport.d.ts +2 -1
- package/dist/session/RecordingTransport.d.ts.map +1 -1
- package/dist/session/RecordingTransport.js.map +1 -1
- package/dist/session/channel-session-scope.d.ts +6 -0
- package/dist/session/channel-session-scope.d.ts.map +1 -1
- package/dist/session/channel-session-scope.js.map +1 -1
- package/dist/session/client-session-deadline.d.ts +58 -9
- package/dist/session/client-session-deadline.d.ts.map +1 -1
- package/dist/session/client-session-deadline.js +111 -13
- package/dist/session/client-session-deadline.js.map +1 -1
- package/package.json +9 -9
|
@@ -6,7 +6,7 @@ import { GraphQLRealtimeSessionClient } from '@memberjunction/graphql-dataprovid
|
|
|
6
6
|
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
7
7
|
import { DEFAULT_REALTIME_AUDIO_TRACKS, ParseRealtimeAvatarStatus, ParseRealtimeClientTransport } from '@memberjunction/ai';
|
|
8
8
|
import { CompareExposure, DeclaresNativeTools, IsIdentityVerifiedEventPayload, NormalizeChannelKey, ParseRealtimeSessionClientPolicy, RECORDING_TERMINAL_MAX_BYTES, ResolveClientTools, SelectNativeChannelTools, UserExposureReason } from '@memberjunction/ai-core-plus';
|
|
9
|
-
import { BaseRealtimeClient, LoadAssemblyAIRealtimeClient, LoadElevenLabsRealtimeClient, LoadGeminiEnterpriseRealtimeClient, LoadGeminiRealtimeClient, LoadHuggingFaceRealtimeClient, LoadOpenAIRealtimeClient, LoadxAIRealtimeClient, VideoSourceArbiter, REQUESTED_TRACKS_SESSION_KEY } from '@memberjunction/ai-realtime-client';
|
|
9
|
+
import { BaseRealtimeClient, LoadAssemblyAIRealtimeClient, LoadElevenLabsRealtimeClient, LoadElevenRealtimeClient, LoadGeminiEnterpriseRealtimeClient, LoadGeminiRealtimeClient, LoadHuggingFaceRealtimeClient, LoadOpenAIRealtimeClient, LoadxAIRealtimeClient, VideoSourceArbiter, REQUESTED_TRACKS_SESSION_KEY } from '@memberjunction/ai-realtime-client';
|
|
10
10
|
import { DefaultRealtimeSessionLauncher, HasClientCredential } from './session-launcher.js';
|
|
11
11
|
import { RequestsAgentVideo, ResolveAvatarNotice } from './avatar-notice.js';
|
|
12
12
|
import { ClientSessionDeadline } from './client-session-deadline.js';
|
|
@@ -72,6 +72,7 @@ LoadGeminiEnterpriseRealtimeClient();
|
|
|
72
72
|
LoadElevenLabsRealtimeClient();
|
|
73
73
|
LoadAssemblyAIRealtimeClient();
|
|
74
74
|
LoadxAIRealtimeClient();
|
|
75
|
+
LoadElevenRealtimeClient();
|
|
75
76
|
LoadHuggingFaceRealtimeClient();
|
|
76
77
|
/**
|
|
77
78
|
* A registry row's surface placement. A row whose `UIConfig` is not valid JSON places its surface as a row without one
|
|
@@ -169,6 +170,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
|
|
366
|
-
*
|
|
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
|
-
/**
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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.
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2157
|
+
return 'Retry';
|
|
1952
2158
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
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
|
|
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:
|
|
3407
|
-
// assistant's reply may already be streaming
|
|
3408
|
-
|
|
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
|
|
3608
|
-
*
|
|
3609
|
-
* mid-sentence pause
|
|
3610
|
-
*
|
|
3611
|
-
*
|
|
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
|
-
/**
|
|
3625
|
-
|
|
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
|
|
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 (
|
|
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
|
|
4047
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
4518
|
+
const progress = this.parseProgress(parsed);
|
|
4236
4519
|
if (progress) {
|
|
4237
4520
|
this.dispatchProgress(progress);
|
|
4238
4521
|
}
|
|
4239
4522
|
}
|
|
4240
4523
|
/**
|
|
4241
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
4464
|
-
*
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
/**
|
|
4598
|
-
|
|
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(
|
|
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
|
}
|