@kaltura-sdk/avatars 1.15.5 → 1.16.0

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/index.esm.js CHANGED
@@ -6226,6 +6226,9 @@ const agentRawTextBaseDeltaSchema = z$1.object({
6226
6226
  role: z$1.optional(z$1.string()),
6227
6227
  type: z$1.optional(z$1.string()),
6228
6228
  content: z$1.optional(z$1.union([z$1.string(), z$1.record(z$1.string(), z$1.unknown())])),
6229
+ // present on the bootstrap 'think' segment - the user's turn text, echoed
6230
+ // there instead of a dedicated 'human' segment type.
6231
+ human: z$1.optional(z$1.string()),
6229
6232
  segmentNumber: z$1.optional(z$1.number()),
6230
6233
  et: z$1.optional(z$1.number()),
6231
6234
  segmentStart: z$1.optional(z$1.boolean()),
@@ -7025,13 +7028,13 @@ const agentRecoveryMonitorState = {
7025
7028
  actions: 'resetAgentRecoveryRetryCount'
7026
7029
  }, {
7027
7030
  guard: 'hasMoreRecoveryRetries',
7028
- actions: ['incrementAgentRecoveryRetryCount', 'sendReprocessTurn']
7031
+ actions: ['removeStaleChatEntriesForRetry', 'incrementAgentRecoveryRetryCount', 'sendReprocessTurn']
7029
7032
  }, {
7030
7033
  guard: 'justExceededRecoveryRetries',
7031
7034
  actions: ['incrementAgentRecoveryRetryCount', 'showAgentRecoveryExhaustedNotification']
7032
7035
  }],
7033
7036
  [AvatarConnectionMachineEventTypes.RequestAgentTurnRecovery]: {
7034
- actions: 'sendReprocessTurn'
7037
+ actions: ['removeStaleChatEntriesForRetry', 'sendReprocessTurn']
7035
7038
  }
7036
7039
  },
7037
7040
  invoke: {
@@ -20620,9 +20623,51 @@ const appendAvatarChatEntry = assign$1(({
20620
20623
  context,
20621
20624
  event
20622
20625
  }) => {
20626
+ var _a, _b;
20623
20627
  assertEvent(event, AvatarConnectionMachineEventTypes.AvatarChatEntryReceived);
20628
+ // The avatar's FINAL segment responding means the human phase for this
20629
+ // turn is done - a pendingTextInput still anchored to this turnId (see
20630
+ // appendUserChatEntry) has nothing left to merge into; clear it so the
20631
+ // next askViaText starts a fresh entry instead of merging into a turn
20632
+ // that's already been answered. A non-final segment (avatar-filler,
20633
+ // spoken ahead of the main answer during a tool call) must NOT clear it
20634
+ // - the real turn is still open server-side and more sends can still
20635
+ // legitimately merge into it; closing early here caused a real bug: a
20636
+ // later send got a fresh local entry, but the server's own confirmation
20637
+ // for it still carried the still-open turn's turnId, landing two chat
20638
+ // entries on the same id - every later merge (by id) then grew both.
20639
+ //
20640
+ // Compares against `turnId`, NOT `id` - a real capture showed the
20641
+ // server can reuse the same turnId across multiple, already-answered
20642
+ // exchanges (e.g. repeated ambiguous input like "="), so `id` alone is
20643
+ // not a safe proxy for "this is the turn that's still open" once it's
20644
+ // also being used as a collision-safe chatLog entry id (see
20645
+ // on-chat-log.ts / appendUserChatEntry).
20646
+ const isFinalSegment = (_a = event.isFinalSegment) !== null && _a !== void 0 ? _a : true;
20647
+ const pendingTurnId = (_b = context.pendingTextInput) === null || _b === void 0 ? void 0 : _b.turnId;
20648
+ const pendingTextInput = isFinalSegment && pendingTurnId != null && pendingTurnId === event.entry.turnId ? null : context.pendingTextInput;
20649
+ return {
20650
+ chatLog: [...context.chatLog, event.entry],
20651
+ pendingTextInput
20652
+ };
20653
+ });
20654
+
20655
+ // A retry re-runs the same turn (same userInput -> same turnId) under a new
20656
+ // speechId/messageId, re-emitting both the user's `human` segment and the
20657
+ // avatar answer — both go stale, not just the avatar one.
20658
+ //
20659
+ // Matched on messageId, not turnId: the server can reuse the same turnId
20660
+ // across distinct, already-answered exchanges (e.g. repeated "="), so
20661
+ // turnId would wipe an unrelated earlier exchange too. messageId is
20662
+ // constant per exchange and never reused (confirmed against
20663
+ // kaltura_agent_executer.py), so it removes exactly the retried exchange.
20664
+ const removeStaleChatEntriesForRetry = assign$1(({
20665
+ context,
20666
+ event
20667
+ }) => {
20668
+ assertEvent(event, [AvatarConnectionMachineEventTypes.RequestAgentTurnRecovery, AvatarConnectionMachineEventTypes.AgentRecoveryTurnCompleted]);
20624
20669
  return {
20625
- chatLog: [...context.chatLog, event.entry]
20670
+ chatLog: context.chatLog.filter(entry => entry.messageId !== event.messageId)
20626
20671
  };
20627
20672
  });
20628
20673
 
@@ -20630,18 +20675,39 @@ const appendUserChatEntry = assign$1(({
20630
20675
  context,
20631
20676
  event
20632
20677
  }) => {
20678
+ var _a;
20633
20679
  assertEvent(event, AvatarConnectionMachineEventTypes.UserVoiceTurnReceived);
20634
- const idx = context.pendingTextInputs.indexOf(event.entry.text);
20635
- if (idx !== -1) {
20636
- // Already added optimistically via text input — consume the pending entry and skip
20637
- const pendingTextInputs = [...context.pendingTextInputs];
20638
- pendingTextInputs.splice(idx, 1);
20680
+ const pending = context.pendingTextInput;
20681
+ if (!pending) {
20639
20682
  return {
20640
- pendingTextInputs
20683
+ chatLog: [...context.chatLog, event.entry]
20641
20684
  };
20642
20685
  }
20686
+ // Only one send can be in flight at a time - appendUserTextEntry merges
20687
+ // any further sends into it instead of starting a second one - so this
20688
+ // turn is unambiguously that pending entry's confirmation. The server is
20689
+ // authoritative for what actually gets saved as message history, so its
20690
+ // entry always wins here, whether or not our locally-accumulated text
20691
+ // happens to match it exactly.
20692
+ const chatLog = pending.id ? context.chatLog.map(entry => entry.id === pending.id ? event.entry : entry) : [...context.chatLog, event.entry];
20693
+ // Stay anchored to this turn via its now-confirmed id instead of
20694
+ // clearing to null - the server re-sends this same "human" echo once per
20695
+ // rapid send under one still-open turnId, each carrying the full
20696
+ // cumulative text (see on-chat-log.ts), so a further send before the
20697
+ // avatar responds must keep merging into this same entry. What actually
20698
+ // closes this out is the avatar responding - see appendAvatarChatEntry.
20699
+ // `turnId` is tracked alongside `id` (not derived from it) - a real
20700
+ // capture showed the server can reuse the same turnId across multiple,
20701
+ // already-answered exchanges, so `event.entry.id` must stay collision-safe
20702
+ // (see on-chat-log.ts) while `turnId` is what appendAvatarChatEntry
20703
+ // actually compares against to decide its turn just closed.
20643
20704
  return {
20644
- chatLog: [...context.chatLog, event.entry]
20705
+ chatLog,
20706
+ pendingTextInput: {
20707
+ id: event.entry.id,
20708
+ turnId: (_a = event.entry.turnId) !== null && _a !== void 0 ? _a : null,
20709
+ text: event.entry.text
20710
+ }
20645
20711
  };
20646
20712
  });
20647
20713
 
@@ -20654,12 +20720,70 @@ const appendUserTextEntry = assign$1(({
20654
20720
  text,
20655
20721
  internal
20656
20722
  } = event;
20723
+ if (context.pendingTextInput) {
20724
+ // A send while the previous turn is still open mirrors the server's
20725
+ // own behavior: it merges rapid back-to-back input into one turn, so
20726
+ // append into the same pending message instead of starting a new one.
20727
+ // The server re-confirms this same turn once per send (see
20728
+ // appendUserChatEntry) rather than clearing pendingTextInput, so
20729
+ // further sends keep landing here until the avatar actually answers
20730
+ // (see appendAvatarChatEntry, which is what closes it out) or the turn
20731
+ // is interrupted.
20732
+ const {
20733
+ id,
20734
+ turnId,
20735
+ text: pendingText
20736
+ } = context.pendingTextInput;
20737
+ const mergedText = `${pendingText} ${text}`;
20738
+ if (id) {
20739
+ return {
20740
+ pendingTextInput: {
20741
+ id,
20742
+ turnId,
20743
+ text: mergedText
20744
+ },
20745
+ chatLog: context.chatLog.map(entry => entry.id === id ? Object.assign(Object.assign({}, entry), {
20746
+ text: mergedText
20747
+ }) : entry)
20748
+ };
20749
+ }
20750
+ if (internal) {
20751
+ // Still an internal-only accumulation - stays out of the chat log.
20752
+ return {
20753
+ pendingTextInput: {
20754
+ id: null,
20755
+ turnId: null,
20756
+ text: mergedText
20757
+ }
20758
+ };
20759
+ }
20760
+ // The pending message so far was internal (invisible) but this
20761
+ // follow-up isn't - it becomes visible now, under a fresh entry.
20762
+ const _entry = {
20763
+ id: crypto.randomUUID(),
20764
+ role: 'user',
20765
+ text: mergedText
20766
+ };
20767
+ return {
20768
+ chatLog: [...context.chatLog, _entry],
20769
+ pendingTextInput: {
20770
+ id: _entry.id,
20771
+ turnId: null,
20772
+ text: mergedText
20773
+ }
20774
+ };
20775
+ }
20657
20776
  // raiseAskViaTextKickoff marks its auto-sent ask `internal` — it's a bootstrap prompt,
20658
- // not something the user said, so keep it out of the visible chat log. Still record it
20659
- // in pendingTextInputs so the ASR echo of it (agentTurnToTalk) is deduped the same way.
20777
+ // not something the user said, so keep it out of the visible chat log. Still tracked
20778
+ // in pendingTextInput so the server's turn echo is deduped the same way. No chatLog
20779
+ // entry backs it, so `id: null`.
20660
20780
  if (internal) {
20661
20781
  return {
20662
- pendingTextInputs: [...context.pendingTextInputs, text]
20782
+ pendingTextInput: {
20783
+ id: null,
20784
+ turnId: null,
20785
+ text
20786
+ }
20663
20787
  };
20664
20788
  }
20665
20789
  const entry = {
@@ -20669,7 +20793,11 @@ const appendUserTextEntry = assign$1(({
20669
20793
  };
20670
20794
  return {
20671
20795
  chatLog: [...context.chatLog, entry],
20672
- pendingTextInputs: [...context.pendingTextInputs, text]
20796
+ pendingTextInput: {
20797
+ id: entry.id,
20798
+ turnId: null,
20799
+ text
20800
+ }
20673
20801
  };
20674
20802
  });
20675
20803
 
@@ -20720,135 +20848,64 @@ const source$a = createTinyPubsubEventsSource(websocketMessage, {
20720
20848
  agent_raw_text: {
20721
20849
  schema: agentRawTextSchema
20722
20850
  },
20723
- agentTurnToTalk: {
20724
- schema: agentTurnToTalkSchema
20725
- },
20726
20851
  generatingSpeech: {
20727
20852
  schema: generatingSpeechSchema
20853
+ },
20854
+ agentTurnToTalk: {
20855
+ schema: agentTurnToTalkSchema
20728
20856
  }
20729
20857
  }, {
20730
20858
  queueWhilePending: true
20731
20859
  });
20732
- // FLOW OVERVIEW
20733
- // =============
20734
- // Builds the alternating user/avatar chat log from two independent, unordered
20735
- // websocket event streams that share no common id:
20736
- // - agentTurnToTalk: fires once per USER turn, carries the ASR transcript
20737
- // (userTranscription). Empty userTranscription means a proactive/opening
20738
- // turn (avatar speaks first, no preceding user input).
20739
- // - agent_raw_text: streams the AVATAR's reply for a turn as chunked deltas
20740
- // keyed by speechId, terminated by a delta with segmentEnd=true.
20741
- // delta.type === 'avatar' or 'avatar-filler' chunks are chat text (filler
20742
- // is spoken content too, e.g. "Let me check that for you..." streamed
20743
- // ahead of the main answer while a tool call runs); 'think'/'tool'/'share'
20744
- // chunks are filtered out here.
20860
+ // Builds alternating user/avatar chat log from agent_raw_text, keyed by turnId.
20745
20861
  //
20746
- // The two streams race: for a given turn, agentTurnToTalk can arrive before,
20747
- // after, or interleaved with that turn's agent_raw_text segmentEnd. Neither
20748
- // event carries a shared id to correlate by (agentTurnToTalk has no speechId;
20749
- // see the pendingUserTurnCount comment below), so pairing is done by arrival
20750
- // order via a queue + counter:
20751
- // - awaitingUserTurnQueue: avatar answers that finished (segmentEnd) before
20752
- // their turn's agentTurnToTalk showed up. FIFO — first queued avatar
20753
- // answer is paired with the next agentTurnToTalk that needs one.
20754
- // - pendingUserTurnCount: agentTurnToTalk arrivals still waiting on their
20755
- // avatar answer to finish. Incremented when a talk event arrives with an
20756
- // empty queue; decremented when an avatar segmentEnd arrives and directly
20757
- // emits instead of queueing.
20758
- // - resolvedTurnIds: guards against a single turn producing more than one
20759
- // avatar segmentEnd — either multiple segments under one speechId (e.g. a
20760
- // tool call splits the reply into a chunk before and a chunk after the
20761
- // tool result), or multiple speechIds sharing one turnId (a reprocessTurn
20762
- // retry regenerates the whole answer under a new speechId but keeps the
20763
- // same turnId). Only the FIRST resolved segment/attempt for a turnId
20764
- // goes through the queue/counter above; later ones are known to already
20765
- // belong to a claimed turn, so they're emitted immediately without
20766
- // consuming another slot.
20862
+ // delta.type:
20863
+ // - 'think' with `human` — user's turn text, one-shot on the bootstrap
20864
+ // "preparing to answer..." segment (precedes the turn's avatar segments).
20865
+ // No `human` field = real thinking tokens, ignored (like 'tool'/'share').
20866
+ // Deduped per turnId:speechId. Entry id is that composite, not bare
20867
+ // turnId — server can reuse one turnId across several already-answered
20868
+ // exchanges (e.g. repeated "="), so speechId keeps entries from colliding.
20869
+ // - 'avatar' / 'avatar-filler' — chat text, chunked by speechId, closed by
20870
+ // segmentEnd=true. Filler is spoken content too (e.g. tool-call "let me
20871
+ // check..." lines).
20872
+ // - 'user-interruption' — turn aborted server-side (no literal 'abort'
20873
+ // type exists). Flushes whatever partial avatar text accumulated and
20874
+ // evicts that turnId:speechId buffer only.
20767
20875
  //
20768
- // Per real turn, the emitted order ends up being: UserVoiceTurnReceived then
20769
- // AvatarChatEntryReceived (see appendUserChatEntry/appendAvatarChatEntry,
20770
- // which just push onto context.chatLog in the order events arrive here).
20876
+ // A retry reuses the same turnId under a new speechId; each speechId's
20877
+ // segmentEnd/user-interruption is tracked/flushed independently.
20771
20878
  //
20772
- // Separately, generatingSpeech + agentTurnToTalk-with-empty-transcription
20773
- // handle ONE special case: resumed-thread sessions where the avatar speaks an
20774
- // opening phrase with no prior user turn to pair against — that text is
20775
- // accumulated by speechId and flushed as its own avatar-only entry.
20879
+ // Every entry carries `messageId` (parsed.messageId) — constant for every
20880
+ // segment of one exchange, unique across exchanges even when turnId repeats
20881
+ // (confirmed against kaltura_agent_executer.py). remove-stale-chat-entries-
20882
+ // for-retry.ts matches on it to drop exactly one exchange on retry.
20776
20883
  //
20777
- // Known unresolved risk (backend-side, not fixable from here — see
20778
- // pendingUserTurnCount's comment): the queue/counter can permanently strand
20779
- // or over-count if either stream's event never fires for a turn.
20884
+ // Opening phrase (resumed thread, avatar speaks first): never sent as a
20885
+ // normal turnId-keyed segment. Server sends it via `init_response` (too
20886
+ // early — fires before this actor's setHandler(), so it's dropped by the
20887
+ // pubsub queue reset) and a mirrored `generatingSpeech` event, which we use
20888
+ // as the text source instead. `agentTurnToTalk` (userTranscription === '')
20889
+ // is the flush trigger — nothing else marks "greeting done, no real user
20890
+ // turn happened first". BLANK_TEXT_MARKER is the server's "no opening
20891
+ // phrase configured" sentinel, skipped.
20780
20892
  const onChatLog = fromCallback(({
20781
20893
  input,
20782
20894
  sendBack
20783
20895
  }) => {
20784
- // Accumulate chunks by speechId while avatar is speaking
20896
+ // Accumulate avatar text by `${turnId}:${speechId}` — each speechId
20897
+ // closes independently (e.g. a filler segment vs the main answer).
20785
20898
  const pending = new Map();
20786
- // speechId of the turn currently accumulating chunks.
20787
- // When a new speechId is seen, the previous turn's incomplete text is abandoned (interrupted).
20788
- let currentSpeechId = null;
20789
- // Avatar answers whose segmentEnd fired before their matching agentTurnToTalk arrived.
20790
- // Completed avatar answers waiting for their paired agentTurnToTalk.
20791
- const awaitingUserTurnQueue = [];
20792
- // agentTurnToTalk events (with transcription) whose matching avatar answer hasn't
20793
- // completed yet. Talk-vs-avatar-stream ordering isn't fixed — the ASR transcript can
20794
- // beat or lag the avatar's own text stream on a per-turn basis (races harder when the
20795
- // answer carries tool content) — so pairing is done via this queue+counter rather than
20796
- // a single slot, which a later turn's speechId change would otherwise clobber.
20797
- //
20798
- // Known risk (backend, not fixable here — agentTurnToTalk carries no turnId/speechId
20799
- // to correlate by id, only arrival order): both sides can drift out of 1:1 and desync
20800
- // pairing for a later, unrelated turn instead of the intended one.
20801
- // - Queue can strand an entry forever if its own agentTurnToTalk never fires — e.g.
20802
- // conversation-manager's ttv.sayTurn throwing on the first chunk, or the
20803
- // first-sentence punctuation gate in SpeechBuffer.append swallowing a short
20804
- // punctuation-less reply. Raw text still gets forwarded; the talk pipeline doesn't run.
20805
- // - Counter can stay inflated forever if agentTurnToTalk fires but its avatar segment
20806
- // never reaches segmentEnd — e.g. onUserInterrupted not aborting AgentAdapter's loop,
20807
- // dropping buffered avatar text without ever calling finish()/completeStream().
20808
- // Real fix is backend: always run finish()/completeStream() on error/interrupt, and
20809
- // populate turnId on agentTurnToTalk (schema already has the field, unused) so pairing
20810
- // can be done by id instead of arrival order.
20811
- let pendingUserTurnCount = 0;
20812
- // turnIds whose first segment already claimed a pairing slot (queued or emitted).
20813
- // Keyed by turnId, NOT speechId, because a single logical turn can produce more than
20814
- // one speechId:
20815
- // - Genie can stream a reply as multiple avatar segments under one speechId (e.g. a
20816
- // tool call splits the answer into a segment before and a segment after the tool
20817
- // result) — those segments share both speechId and turnId.
20818
- // - A reprocessTurn retry (sendReprocessTurn, triggered by agentRecoveryMonitor or a
20819
- // manual "retry" action) makes the backend regenerate the FULL answer under a NEW
20820
- // speechId while keeping the SAME turnId — confirmed in logs: two distinct speechIds
20821
- // sharing turnId "c83e031d-..." after a reprocessTurn. Guarding on speechId alone
20822
- // would treat the retry's answer as an unrelated new turn.
20823
- // Either way, the extra segment/retry belongs to a turn that's already paired, so it
20824
- // must bypass the queue/counter entirely; otherwise it would consume the next,
20825
- // unrelated user turn's pairing slot and desync every pairing for the rest of the
20826
- // conversation. Intentionally never cleared: a later segment/retry for the same turnId
20827
- // can arrive arbitrarily long after the first (slow tool call, slow retry, other turns
20828
- // in between), so "already claimed" must be remembered for the rest of the session,
20829
- // same lifetime as awaitingUserTurnQueue/pendingUserTurnCount above. Actor-scoped, dies
20830
- // with it.
20831
- const resolvedTurnIds = new Set();
20832
- // Opening phrase accumulation: for resumed-thread sessions where init_response is not sent,
20833
- // generatingSpeech chunks arrive before the first user turn — accumulate them keyed by speechId
20834
- // and flush on the first proactive agentTurnToTalk (empty userTranscription).
20835
- let openingPhraseSpeechId = null;
20836
- const openingPhraseChunks = new Map();
20837
- let openingPhraseEmitted = false;
20899
+ const seenHumanTurnKeys = new Set();
20838
20900
  let avatarEntrySequence = 0;
20839
20901
  const createAvatarEntryId = (messageId, speechId) => {
20840
20902
  avatarEntrySequence += 1;
20841
20903
  return `${messageId}:${speechId}:${avatarEntrySequence}`;
20842
20904
  };
20843
- const emitQueuedAvatarEntry = () => {
20844
- const entry = awaitingUserTurnQueue.shift();
20845
- if (entry) {
20846
- sendBack({
20847
- type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20848
- entry
20849
- });
20850
- }
20851
- };
20905
+ // Opening phrase accumulation — see top-of-file comment.
20906
+ let openingPhraseSpeechId = null;
20907
+ let openingPhraseText = '';
20908
+ let openingPhraseSettled = false;
20852
20909
  return createTinyPubsubEventsHandler({
20853
20910
  source: source$a,
20854
20911
  logger: input.logger,
@@ -20860,115 +20917,89 @@ const onChatLog = fromCallback(({
20860
20917
  const result = agentRawTextDeltaSchema.safeParse(delta);
20861
20918
  if (!result.success) return;
20862
20919
  const parsed = result.data;
20863
- // 'avatar-filler' is spoken content too (e.g. "Let me check that for you..." while
20864
- // a tool call runs) — Genie streams it as its own segment ahead of the 'avatar'
20865
- // segment. Without it here, part of what the avatar actually says never reaches
20866
- // the chat log.
20867
- if (parsed.type !== 'avatar' && parsed.type !== 'avatar-filler') return;
20868
- const speechId = payload.speechId;
20869
- // New speech started — abandon prior speech's incomplete accumulation.
20870
- // Does NOT touch awaitingUserTurnQueue/pendingUserTurnCount: those are completed,
20871
- // already-paired-or-waiting turns and must survive across speechId changes.
20872
- if (speechId !== currentSpeechId) {
20873
- pending.delete(currentSpeechId !== null && currentSpeechId !== void 0 ? currentSpeechId : '');
20874
- currentSpeechId = speechId;
20875
- }
20876
- if (typeof parsed.content === 'string' && parsed.content) {
20877
- pending.set(speechId, ((_a = pending.get(speechId)) !== null && _a !== void 0 ? _a : '') + parsed.content);
20878
- }
20879
- if (parsed.segmentEnd) {
20880
- const text = (_b = pending.get(speechId)) !== null && _b !== void 0 ? _b : '';
20881
- pending.delete(speechId);
20882
- if (!text) return;
20883
- const entry = {
20884
- id: createAvatarEntryId(parsed.messageId, speechId),
20885
- role: 'avatar',
20886
- text,
20887
- turnId: speechId
20888
- };
20889
- if (resolvedTurnIds.has(payload.turnId)) {
20890
- // Not this turn's first segment/attempt — its pairing slot is already claimed
20891
- // (multi-segment reply, or a reprocessTurn retry answering the same turnId).
20920
+ const key = `${payload.turnId}:${payload.speechId}`;
20921
+ if (parsed.type === 'think') {
20922
+ if (parsed.human && !seenHumanTurnKeys.has(key)) {
20923
+ seenHumanTurnKeys.add(key);
20892
20924
  sendBack({
20893
- type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20894
- entry
20925
+ type: AvatarConnectionMachineEventTypes.UserVoiceTurnReceived,
20926
+ entry: {
20927
+ id: key,
20928
+ role: 'user',
20929
+ text: parsed.human,
20930
+ turnId: payload.turnId,
20931
+ messageId: parsed.messageId
20932
+ }
20895
20933
  });
20896
- return;
20897
20934
  }
20898
- resolvedTurnIds.add(payload.turnId);
20899
- if (pendingUserTurnCount > 0) {
20900
- // agentTurnToTalk for this turn already fired — emit directly
20901
- pendingUserTurnCount -= 1;
20935
+ return;
20936
+ }
20937
+ if (parsed.type === 'user-interruption') {
20938
+ const text = pending.get(key);
20939
+ pending.delete(key);
20940
+ if (text) {
20902
20941
  sendBack({
20903
20942
  type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20904
- entry
20943
+ entry: {
20944
+ id: createAvatarEntryId(parsed.messageId, payload.speechId),
20945
+ role: 'avatar',
20946
+ text,
20947
+ turnId: payload.turnId,
20948
+ messageId: parsed.messageId
20949
+ }
20905
20950
  });
20906
- } else {
20907
- // agentTurnToTalk hasn't arrived yet — queue until it does
20908
- awaitingUserTurnQueue.push(entry);
20909
20951
  }
20952
+ return;
20953
+ }
20954
+ if (parsed.type !== 'avatar' && parsed.type !== 'avatar-filler') return;
20955
+ if (typeof parsed.content === 'string' && parsed.content) {
20956
+ pending.set(key, ((_a = pending.get(key)) !== null && _a !== void 0 ? _a : '') + parsed.content);
20957
+ }
20958
+ if (parsed.segmentEnd) {
20959
+ const text = (_b = pending.get(key)) !== null && _b !== void 0 ? _b : '';
20960
+ pending.delete(key);
20961
+ if (!text) return;
20962
+ sendBack({
20963
+ type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20964
+ entry: {
20965
+ id: createAvatarEntryId(parsed.messageId, payload.speechId),
20966
+ role: 'avatar',
20967
+ text,
20968
+ turnId: payload.turnId,
20969
+ messageId: parsed.messageId
20970
+ },
20971
+ // Filler doesn't close an open pendingTextInput (see append-avatar-chat-entry.ts).
20972
+ isFinalSegment: parsed.type !== 'avatar-filler'
20973
+ });
20910
20974
  }
20911
20975
  } catch (_c) {
20912
20976
  // invalid JSON delta — ignore
20913
20977
  }
20914
20978
  },
20915
20979
  generatingSpeech: payload => {
20916
- var _a;
20917
- if (openingPhraseEmitted) return;
20918
- // Server sends this literal sentinel instead of real speech when no opening phrase
20919
- // is configured — must not leak into the chat log as visible avatar text.
20980
+ if (openingPhraseSettled) return;
20981
+ // No opening phrase configured — must not leak into the chat log.
20920
20982
  if (payload.text === BLANK_TEXT_MARKER) return;
20921
- if (openingPhraseSpeechId !== null && openingPhraseSpeechId !== payload.speechId) return;
20983
+ if (openingPhraseSpeechId !== null && openingPhraseSpeechId !== payload.speechId) {
20984
+ return;
20985
+ }
20922
20986
  openingPhraseSpeechId = payload.speechId;
20923
- openingPhraseChunks.set(payload.speechId, ((_a = openingPhraseChunks.get(payload.speechId)) !== null && _a !== void 0 ? _a : '') + payload.text);
20987
+ openingPhraseText += payload.text;
20924
20988
  },
20925
20989
  agentTurnToTalk: payload => {
20926
- var _a;
20927
- const {
20928
- userTranscription,
20929
- turnId
20930
- } = payload;
20931
- if (!userTranscription) {
20932
- // Proactive turn — flush opening phrase built from generatingSpeech chunks if present
20933
- if (!openingPhraseEmitted && openingPhraseSpeechId) {
20934
- const text = (_a = openingPhraseChunks.get(openingPhraseSpeechId)) !== null && _a !== void 0 ? _a : '';
20935
- openingPhraseChunks.clear();
20936
- openingPhraseEmitted = true;
20937
- if (text) {
20938
- sendBack({
20939
- type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20940
- entry: {
20941
- id: openingPhraseSpeechId,
20942
- role: 'avatar',
20943
- text,
20944
- turnId: 'init_response'
20945
- }
20946
- });
20947
- }
20948
- }
20949
- // Also emit any queued avatar entry left over from the agent_raw_text path
20950
- emitQueuedAvatarEntry();
20951
- return;
20952
- }
20953
- openingPhraseEmitted = true;
20954
- openingPhraseChunks.clear();
20955
- // Emit user first, then pair with its avatar answer — whichever side finished
20956
- // streaming first (queue has it) or hasn't yet (bump the counter for agent_raw_text
20957
- // to consume once its segmentEnd arrives)
20990
+ // Only the first occurrence decides the opening — later turns also fire this.
20991
+ if (openingPhraseSettled) return;
20992
+ openingPhraseSettled = true;
20993
+ if (payload.userTranscription || !openingPhraseText) return;
20958
20994
  sendBack({
20959
- type: AvatarConnectionMachineEventTypes.UserVoiceTurnReceived,
20995
+ type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived,
20960
20996
  entry: {
20961
- id: turnId !== null && turnId !== void 0 ? turnId : crypto.randomUUID(),
20962
- role: 'user',
20963
- text: userTranscription,
20964
- turnId
20997
+ id: openingPhraseSpeechId !== null && openingPhraseSpeechId !== void 0 ? openingPhraseSpeechId : 'init_response',
20998
+ role: 'avatar',
20999
+ text: openingPhraseText,
21000
+ turnId: 'init_response'
20965
21001
  }
20966
21002
  });
20967
- if (awaitingUserTurnQueue.length > 0) {
20968
- emitQueuedAvatarEntry();
20969
- } else {
20970
- pendingUserTurnCount += 1;
20971
- }
20972
21003
  }
20973
21004
  }
20974
21005
  });
@@ -23290,6 +23321,7 @@ const avatarConnectionMachine = setup({
23290
23321
  logAgentClientToolCallResult,
23291
23322
  recordClientToolCallLogEntry,
23292
23323
  appendAvatarChatEntry,
23324
+ removeStaleChatEntriesForRetry,
23293
23325
  appendUserChatEntry,
23294
23326
  appendUserTextEntry,
23295
23327
  setScreenShareActive,
@@ -23463,7 +23495,7 @@ const avatarConnectionMachine = setup({
23463
23495
  content: null,
23464
23496
  chatLog: [],
23465
23497
  clientToolCallLog: [],
23466
- pendingTextInputs: [],
23498
+ pendingTextInput: null,
23467
23499
  userAgent: '',
23468
23500
  userAgentHints: null,
23469
23501
  kalturaAnalyticsService: input.kalturaAnalyticsService,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaltura-sdk/avatars",
3
- "version": "1.15.5",
3
+ "version": "1.16.0",
4
4
  "author": "kaltura",
5
5
  "license": "AGPL-3.0",
6
6
  "repository": {
@@ -13,7 +13,7 @@
13
13
  "@kaltura-sdk/rtc-core": "^1.25.21",
14
14
  "@local/genie-shared-utils": "^1.0.0",
15
15
  "@unisphere/core": "^1.99.0",
16
- "@unisphere/genie-types": "^1.39.1",
16
+ "@unisphere/genie-types": "^1.39.3",
17
17
  "@unisphere/notifications-core": "^1.25.0",
18
18
  "@unisphere/runtime": "^1.98.0",
19
19
  "@unisphere/runtime-js": "^1.96.0",
@@ -0,0 +1,2 @@
1
+ import { AvatarConnectionMachineAction } from '../../types';
2
+ export declare const removeStaleChatEntriesForRetry: AvatarConnectionMachineAction;
@@ -0,0 +1,4 @@
1
+ import type { ActionFunction, MachineContext } from 'xstate';
2
+ export declare function asHarnessAction<TContext extends MachineContext, TEvent extends {
3
+ type: string;
4
+ }>(action: unknown): ActionFunction<TContext, TEvent, TEvent, undefined, never, never, never, never, never>;
@@ -391,6 +391,9 @@ export declare const avatarConnectionMachine: import("xstate").StateMachine<Avat
391
391
  } | {
392
392
  type: "appendAvatarChatEntry";
393
393
  params: unknown;
394
+ } | {
395
+ type: "removeStaleChatEntriesForRetry";
396
+ params: unknown;
394
397
  } | {
395
398
  type: "appendUserChatEntry";
396
399
  params: unknown;
@@ -728,13 +728,13 @@ export declare const connectedState: {
728
728
  actions: "resetAgentRecoveryRetryCount";
729
729
  } | {
730
730
  guard: "hasMoreRecoveryRetries";
731
- actions: readonly ["incrementAgentRecoveryRetryCount", "sendReprocessTurn"];
731
+ actions: readonly ["removeStaleChatEntriesForRetry", "incrementAgentRecoveryRetryCount", "sendReprocessTurn"];
732
732
  } | {
733
733
  guard: "justExceededRecoveryRetries";
734
734
  actions: readonly ["incrementAgentRecoveryRetryCount", "showAgentRecoveryExhaustedNotification"];
735
735
  })[];
736
736
  RequestAgentTurnRecovery: {
737
- actions: "sendReprocessTurn";
737
+ actions: readonly ["removeStaleChatEntriesForRetry", "sendReprocessTurn"];
738
738
  };
739
739
  };
740
740
  invoke: {
@@ -6,13 +6,13 @@ export declare const agentRecoveryMonitorState: {
6
6
  actions: "resetAgentRecoveryRetryCount";
7
7
  } | {
8
8
  guard: "hasMoreRecoveryRetries";
9
- actions: readonly ["incrementAgentRecoveryRetryCount", "sendReprocessTurn"];
9
+ actions: readonly ["removeStaleChatEntriesForRetry", "incrementAgentRecoveryRetryCount", "sendReprocessTurn"];
10
10
  } | {
11
11
  guard: "justExceededRecoveryRetries";
12
12
  actions: readonly ["incrementAgentRecoveryRetryCount", "showAgentRecoveryExhaustedNotification"];
13
13
  })[];
14
14
  RequestAgentTurnRecovery: {
15
- actions: "sendReprocessTurn";
15
+ actions: readonly ["removeStaleChatEntriesForRetry", "sendReprocessTurn"];
16
16
  };
17
17
  };
18
18
  invoke: {
@@ -66,7 +66,11 @@ export interface AvatarConnectionMachineContext {
66
66
  content: UnisphereChatBlock | null;
67
67
  chatLog: ChatEntry[];
68
68
  clientToolCallLog: ClientToolCallLogEntry[];
69
- pendingTextInputs: string[];
69
+ pendingTextInput: {
70
+ id: string | null;
71
+ turnId: string | null;
72
+ text: string;
73
+ } | null;
70
74
  isAgentTalking: boolean;
71
75
  userAgent: string;
72
76
  userAgentHints: Record<string, string> | null;
@@ -164,6 +164,7 @@ export type AgentStartSpeechReceivedEvent = {
164
164
  export type AvatarChatEntryReceivedEvent = {
165
165
  type: AvatarConnectionMachineEventTypes.AvatarChatEntryReceived;
166
166
  entry: ChatEntry;
167
+ isFinalSegment?: boolean;
167
168
  };
168
169
  export type UserVoiceTurnReceivedEvent = {
169
170
  type: AvatarConnectionMachineEventTypes.UserVoiceTurnReceived;
@@ -69,6 +69,7 @@ export declare const agentRawTextDeltaSchema: z.ZodMiniObject<{
69
69
  role: z.ZodMiniOptional<z.ZodMiniString<string>>;
70
70
  type: z.ZodMiniOptional<z.ZodMiniString<string>>;
71
71
  content: z.ZodMiniOptional<z.ZodMiniUnion<readonly [z.ZodMiniString<string>, z.ZodMiniRecord<z.ZodMiniString<string>, z.ZodMiniUnknown>]>>;
72
+ human: z.ZodMiniOptional<z.ZodMiniString<string>>;
72
73
  segmentNumber: z.ZodMiniOptional<z.ZodMiniNumber<number>>;
73
74
  et: z.ZodMiniOptional<z.ZodMiniNumber<number>>;
74
75
  segmentStart: z.ZodMiniOptional<z.ZodMiniBoolean<boolean>>;
@@ -86,6 +87,7 @@ export declare const agentRawTextClientToolDeltaSchema: z.ZodMiniObject<{
86
87
  threadId: z.ZodMiniOptional<z.ZodMiniString<string>>;
87
88
  role: z.ZodMiniOptional<z.ZodMiniString<string>>;
88
89
  content: z.ZodMiniOptional<z.ZodMiniUnion<readonly [z.ZodMiniString<string>, z.ZodMiniRecord<z.ZodMiniString<string>, z.ZodMiniUnknown>]>>;
90
+ human: z.ZodMiniOptional<z.ZodMiniString<string>>;
89
91
  segmentNumber: z.ZodMiniOptional<z.ZodMiniNumber<number>>;
90
92
  et: z.ZodMiniOptional<z.ZodMiniNumber<number>>;
91
93
  segmentStart: z.ZodMiniOptional<z.ZodMiniBoolean<boolean>>;