@sendbird/ai-agent-messenger-react 1.32.0 → 1.34.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.
Files changed (4) hide show
  1. package/dist/index.cjs +184 -184
  2. package/dist/index.d.ts +1613 -98
  3. package/dist/index.js +8012 -5822
  4. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -331,7 +331,7 @@ declare abstract class AIAgentBaseStats {
331
331
  const existing = this.timers.get(key);
332
332
  if (existing?.startTime !== null && existing?.startTime !== undefined) return this;
333
333
 
334
- this.timers.set(key, { startTime: Date.now(), endTime: null });
334
+ this.timers.set(key, { startTime: defaultStatsClock.now(), endTime: null });
335
335
  return this;
336
336
  }
337
337
 
@@ -341,7 +341,7 @@ declare abstract class AIAgentBaseStats {
341
341
  const existing = this.timers.get(key);
342
342
  if (!existing || existing.startTime === null || existing.endTime !== null) return this;
343
343
 
344
- this.timers.set(key, { ...existing, endTime: Date.now() });
344
+ this.timers.set(key, { ...existing, endTime: defaultStatsClock.now() });
345
345
  return this;
346
346
  }
347
347
 
@@ -396,6 +396,12 @@ declare interface AIAgentClientHandlers {
396
396
  * `Linking.openURL` on React Native.
397
397
  */
398
398
  onClickLink?: (params: { url: string }) => void;
399
+ /**
400
+ * Called whenever the SDK emits a live metric — connection, conversation lifecycle,
401
+ * handoff, and message events. Use for real-time analytics. Implementations should be
402
+ * efficient and non-blocking, as this may be called frequently.
403
+ */
404
+ onLiveMetric?: (metric: LiveMetric) => void;
399
405
  }
400
406
 
401
407
  declare type AIAgentCommonIconName =
@@ -719,10 +725,11 @@ declare interface AIAgentInterface {
719
725
  /** @internal Agent version for conversation initialization */
720
726
  agentVersion?: number;
721
727
 
722
- /** @internal Stats trackers for performance monitoring */
723
- readonly statsTrackers: {
724
- initialRender: ConversationInitialRenderStatsTracker;
725
- };
728
+ /** @internal Schedules metrics work that should only start while the app is foregrounded. */
729
+ readonly scheduleForegroundTask?: ForegroundTaskScheduler;
730
+
731
+ /** @internal Stats trackers owned by this SDK instance. */
732
+ readonly statsTrackers: AIAgentStatsTrackers;
726
733
 
727
734
  /** Authenticates with the AI Agent server and establishes a session */
728
735
  authenticate: (sessionInfo: ManualSessionInfo | AnonymousSessionInfo) => Promise<MessengerSettingsResponse>;
@@ -754,6 +761,12 @@ declare interface AIAgentInterface {
754
761
  /** @internal Initializes a conversation on the channel if needed (AI Agent channel without conversation). */
755
762
  initConversationIfNeeded: (channel: ChatSDKGroupChannel) => Promise<void>;
756
763
 
764
+ /** @internal Live metric handler; bridged from {@link AIAgentClientHandlers.onLiveMetric}. */
765
+ onLiveMetric?: LiveMetricDispatch;
766
+
767
+ /** @internal Live metric manager owned by this SDK instance. */
768
+ readonly liveMetricManager: LiveMetricManager;
769
+
757
770
  /** Disposes all resources held by this instance (WebSocket, desk auth, event handlers). */
758
771
  dispose: () => void;
759
772
 
@@ -797,12 +810,6 @@ declare interface AIAgentMessengerSessionContextValue {
797
810
  deauthenticate: () => Promise<void>;
798
811
 
799
812
  createAttachmentRules: (params: { channel?: ChatSDKGroupChannel; uploadSizeLimit?: number }) => AttachmentRules;
800
- /**
801
- * @internal
802
- */
803
- statsTrackers: {
804
- initialRender: ConversationInitialRenderStatsTracker;
805
- };
806
813
  }
807
814
 
808
815
  declare interface AIAgentMessengerSessionRef {
@@ -1070,6 +1077,23 @@ declare interface AIAgentStatPayload {
1070
1077
  extra?: Record<string, unknown>;
1071
1078
  }
1072
1079
 
1080
+ declare type AIAgentStatsTrackerCollection = {
1081
+ conversationInitialRender: ConversationInitialRenderStatsTracker;
1082
+ conversationNotInteractiveTimeout: ConversationNotInteractiveTimeoutStatsTracker;
1083
+ recoveryNotInteractiveTimeout: RecoveryNotInteractiveTimeoutStatsTracker;
1084
+ conversationListInitialRender: ConversationListInitialRenderStatsTracker;
1085
+ aievAuthExpiredUnhandled: AIEVAuthExpiredUnhandledStatsTracker;
1086
+ messagePendingStuck: MessagePendingStuckStatsTracker;
1087
+ userPerceivedResponseTime: UserPerceivedResponseTimeStatsTracker;
1088
+ streamStalled: StreamStalledStatsTracker;
1089
+ typingIndicatorAbsentTimeout: TypingIndicatorAbsentTimeoutStatsTracker;
1090
+ renderedContentUnusable: RenderedContentUnusableStatsTracker;
1091
+ };
1092
+
1093
+ declare type AIAgentStatsTrackers = AIAgentStatsTrackerCollection & {
1094
+ clear: () => void;
1095
+ };
1096
+
1073
1097
  /**
1074
1098
  * Common string set interface shared between react and react-native packages
1075
1099
  * These are the base strings that both packages use
@@ -1254,6 +1278,33 @@ declare interface AIAgentStringSet {
1254
1278
  };
1255
1279
  }
1256
1280
 
1281
+ declare class AIEVAuthExpiredUnhandledStatsTracker {
1282
+ private readonly callback: StatsAppendCallback;
1283
+
1284
+ constructor(callback: StatsAppendCallback) {
1285
+ this.callback = callback;
1286
+ }
1287
+
1288
+ onPayloadParseFailed(params: { aiAgentId: string }): void {
1289
+ append(this.callback, METRIC_KEY_AIEV_AUTH_EXPIRED_UNHANDLED, 'payload_parse_failed', {
1290
+ extra: { agent_id: params.aiAgentId },
1291
+ });
1292
+ }
1293
+
1294
+ onCallbackNotFound(params: { aiAgentId: string }): void {
1295
+ append(this.callback, METRIC_KEY_AIEV_AUTH_EXPIRED_UNHANDLED, 'callback_not_found', {
1296
+ extra: { agent_id: params.aiAgentId },
1297
+ });
1298
+ }
1299
+
1300
+ clear(): void {}
1301
+ }
1302
+
1303
+ declare type AIResponseMessageMetadata = {
1304
+ isStreaming: boolean;
1305
+ userMessageId?: number;
1306
+ };
1307
+
1257
1308
  declare type AnnouncementQueueMode = 'latestOnly' | 'ordered';
1258
1309
 
1259
1310
  declare interface AnnounceOptions {
@@ -1526,13 +1577,20 @@ export declare const ConnectionDelayLayout: {
1526
1577
  }) => null;
1527
1578
  };
1528
1579
 
1580
+ declare type ConnectionEventDetail =
1581
+ | 'connected'
1582
+ | 'reconnectStarted'
1583
+ | 'reconnectSucceeded'
1584
+ | 'reconnectFailed'
1585
+ | 'disconnected';
1586
+
1529
1587
  declare interface ContextObject {
1530
1588
  language?: string;
1531
1589
  country?: string;
1532
1590
  context: Record<string, string>;
1533
1591
  }
1534
1592
 
1535
- export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, announcementsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1593
+ export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, announcementsEnabled, conversationStatsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1536
1594
 
1537
1595
  export declare const ConversationContext: Context<ConversationContextValue | null>;
1538
1596
 
@@ -1541,11 +1599,13 @@ declare interface ConversationContextProps {
1541
1599
  channelUrl?: string;
1542
1600
  onClearChannelUrl?: () => void;
1543
1601
  shouldMarkAsRead?: boolean;
1602
+ conversationStatsEnabled?: boolean;
1544
1603
  }
1545
1604
 
1546
1605
  export declare const ConversationContextProvider: ({ children, ...props }: PropsWithChildren<ConversationContextProps>) => JSX.Element;
1547
1606
 
1548
1607
  declare interface ConversationContextValue extends AIAgentConversationContextValue {
1608
+ conversationStatsEnabled: boolean;
1549
1609
  scrollSource: ConversationScrollContextValue;
1550
1610
  goToActiveConversation: () => Promise<void>;
1551
1611
  onNavigateToConversationList?: () => void;
@@ -1664,24 +1724,17 @@ declare class ConversationInitialRenderStats extends AIAgentBaseStats {
1664
1724
  const extra: Record<string, unknown> = {
1665
1725
  ...this.extraData,
1666
1726
  present_method: this.presentMethod,
1667
- ...(authDuration !== null && { auth_duration: authDuration }),
1668
- ...(getChannelDuration !== null && { get_channel_duration: getChannelDuration }),
1669
- ...(getMessagesDuration !== null && { get_messages_duration: getMessagesDuration }),
1670
- ...(totalDurationFromCache !== null && { total_duration_from_cache: totalDurationFromCache }),
1671
- };
1672
-
1673
- const payload: AIAgentStatPayload = {
1674
- key: this.getMetricKey(),
1675
- // Use "0" when totalDuration is null (error case)
1676
- value: totalDuration !== null ? String(totalDuration) : '0',
1727
+ ...(authDuration !== null && { [DurationKey.AUTH]: authDuration }),
1728
+ ...(getChannelDuration !== null && { [DurationKey.GET_CHANNEL]: getChannelDuration }),
1729
+ ...(getMessagesDuration !== null && { [DurationKey.GET_MESSAGES]: getMessagesDuration }),
1730
+ ...(totalDurationFromCache !== null && { [DurationKey.TOTAL_DURATION_FROM_CACHE]: totalDurationFromCache }),
1677
1731
  };
1678
1732
 
1679
- if (this.conversationId !== null) payload.conversation_id = this.conversationId;
1680
- if (this.errorCode !== null) payload.error_code = this.errorCode;
1681
- if (this.errorDescription !== null) payload.error_description = this.errorDescription;
1682
- if (Object.keys(extra).length > 0) payload.extra = extra;
1683
-
1684
- return payload;
1733
+ return createStatsPayload(this.getMetricKey(), totalDuration ?? 0, {
1734
+ conversationId: this.conversationId ?? undefined,
1735
+ error: this.errorCode !== null ? { code: this.errorCode, message: this.errorDescription ?? '' } : undefined,
1736
+ extra,
1737
+ });
1685
1738
  }
1686
1739
  }
1687
1740
 
@@ -1690,100 +1743,193 @@ declare class ConversationInitialRenderStats extends AIAgentBaseStats {
1690
1743
  */
1691
1744
  declare class ConversationInitialRenderStatsTracker {
1692
1745
  private readonly commitCallback: StatsAppendCallback;
1746
+ private apiResultReceived = false;
1747
+ private channelUrl?: string;
1748
+ private consumed = false;
1749
+ private consumedByAuthError = false;
1693
1750
  private presentMethod: PresentMethod = 'direct_present';
1751
+ private renderedAfterApiResult = false;
1694
1752
  private stats: ConversationInitialRenderStats | null = null;
1695
1753
 
1696
1754
  constructor(commitCallback: StatsAppendCallback) {
1697
1755
  this.commitCallback = commitCallback;
1698
1756
  }
1699
1757
 
1700
- private getOrCreateStats(): ConversationInitialRenderStats {
1701
- if (!this.stats || this.stats.isCommitted()) {
1758
+ private getOrCreateStats(): ConversationInitialRenderStats | null {
1759
+ if (this.consumed) return null;
1760
+ if (this.stats?.isCommitted()) {
1761
+ this.consumed = true;
1762
+ return null;
1763
+ }
1764
+ if (!this.stats) {
1702
1765
  this.stats = new ConversationInitialRenderStats()
1703
1766
  .setCommitCallback(this.commitCallback)
1704
1767
  .setPresentMethod(this.presentMethod);
1768
+ this.apiResultReceived = false;
1769
+ this.renderedAfterApiResult = false;
1705
1770
  }
1706
1771
  return this.stats;
1707
1772
  }
1708
1773
 
1774
+ private getActiveStats(): ConversationInitialRenderStats | null {
1775
+ if (this.consumed || !this.stats || this.stats.isCommitted()) return null;
1776
+ return this.stats;
1777
+ }
1778
+
1779
+ private matchesChannelUrl(channelUrl?: string): boolean {
1780
+ return channelUrl === undefined || this.channelUrl === undefined || channelUrl === this.channelUrl;
1781
+ }
1782
+
1783
+ private pinChannelUrl(channelUrl?: string): boolean {
1784
+ if (channelUrl === undefined) return true;
1785
+ if (!this.matchesChannelUrl(channelUrl)) return false;
1786
+
1787
+ this.channelUrl = channelUrl;
1788
+ this.stats?.setChannelUrl(channelUrl);
1789
+ return true;
1790
+ }
1791
+
1792
+ private consume(commitResult: boolean): boolean {
1793
+ if (commitResult) {
1794
+ this.consumed = true;
1795
+ }
1796
+ return commitResult;
1797
+ }
1798
+
1799
+ private commitIfReady(channelUrl?: string): boolean {
1800
+ if (this.consumed || !this.stats || this.stats.isCommitted()) return true;
1801
+ if (!this.matchesChannelUrl(channelUrl)) return false;
1802
+ if (!this.apiResultReceived || !this.renderedAfterApiResult) return false;
1803
+ return this.consume(this.stats.stopTimer(DurationKey.TOTAL_DURATION).commit());
1804
+ }
1805
+
1806
+ start(channelUrl?: string): void {
1807
+ const stats = this.getOrCreateStats();
1808
+ if (!stats || !this.pinChannelUrl(channelUrl)) return;
1809
+
1810
+ stats.startTimer(DurationKey.TOTAL_DURATION).startTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1811
+ }
1812
+
1709
1813
  onAuthStart(): void {
1710
- this.getOrCreateStats()
1711
- .startTimer(DurationKey.TOTAL_DURATION)
1712
- .startTimer(DurationKey.TOTAL_DURATION_FROM_CACHE)
1713
- .startTimer(DurationKey.AUTH);
1814
+ if (this.consumedByAuthError) {
1815
+ this.clear();
1816
+ }
1817
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1714
1818
  }
1715
1819
 
1716
1820
  onAuthComplete(): void {
1717
- this.stats?.stopTimer(DurationKey.AUTH);
1821
+ this.getActiveStats()?.stopTimer(DurationKey.AUTH);
1718
1822
  }
1719
1823
 
1720
1824
  onAuthError(error: Error): void {
1721
- if (this.stats && !this.stats.isCommitted()) {
1722
- this.stats
1825
+ const stats = this.getActiveStats();
1826
+ if (stats) {
1827
+ const committed = this.consume(
1828
+ stats
1723
1829
  .stopTimer(DurationKey.AUTH)
1724
1830
  .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1725
1831
  .stopTimer(DurationKey.TOTAL_DURATION)
1726
- .commit();
1832
+ .commit(),
1833
+ );
1834
+ if (committed) {
1835
+ this.consumedByAuthError = true;
1836
+ }
1727
1837
  }
1728
1838
  }
1729
1839
 
1730
- onGetChannelStart(): void {
1731
- this.stats?.startTimer(DurationKey.GET_CHANNEL);
1840
+ onGetChannelStart(channelUrl?: string): void {
1841
+ const stats = this.getActiveStats();
1842
+ if (!stats || !this.pinChannelUrl(channelUrl)) return;
1843
+
1844
+ stats.startTimer(DurationKey.GET_CHANNEL);
1732
1845
  }
1733
1846
 
1734
- onGetChannelComplete(conversationId?: number): void {
1735
- if (this.stats) {
1736
- this.stats.stopTimer(DurationKey.GET_CHANNEL);
1737
- if (conversationId !== undefined) {
1738
- this.stats.setConversationId(conversationId);
1739
- }
1740
- this.stats.startTimer(DurationKey.GET_MESSAGES);
1847
+ onGetChannelComplete(params?: number | GetChannelCompleteParams): void {
1848
+ const channelUrl = typeof params === 'object' ? params.channelUrl : undefined;
1849
+ const conversationId = typeof params === 'object' ? params.conversationId : params;
1850
+ const stats = this.getActiveStats();
1851
+ if (!stats || !this.pinChannelUrl(channelUrl)) return;
1852
+
1853
+ stats.stopTimer(DurationKey.GET_CHANNEL);
1854
+ if (conversationId !== undefined) {
1855
+ stats.setConversationId(conversationId);
1741
1856
  }
1857
+ stats.startTimer(DurationKey.GET_MESSAGES);
1742
1858
  }
1743
1859
 
1744
- onGetChannelError(error: Error): void {
1745
- if (this.stats && !this.stats.isCommitted()) {
1746
- this.stats
1860
+ onGetChannelError(params: Error | GetChannelErrorParams): void {
1861
+ const channelUrl = params instanceof Error ? undefined : params.channelUrl;
1862
+ const error = params instanceof Error ? params : params.error;
1863
+ const stats = this.getActiveStats();
1864
+ if (stats && this.pinChannelUrl(channelUrl)) {
1865
+ this.consume(
1866
+ stats
1747
1867
  .stopTimer(DurationKey.GET_CHANNEL)
1748
1868
  .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1749
1869
  .stopTimer(DurationKey.TOTAL_DURATION)
1750
- .commit();
1870
+ .commit(),
1871
+ );
1751
1872
  }
1752
1873
  }
1753
1874
 
1754
1875
  onCacheResult(error: Error | null): void {
1755
- if (this.stats && !this.stats.isCommitted()) {
1876
+ const stats = this.getActiveStats();
1877
+ if (stats) {
1756
1878
  if (error) {
1757
- this.stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1879
+ stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1758
1880
  }
1759
- this.stats.stopTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1881
+ stats.stopTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1760
1882
  }
1761
1883
  }
1762
1884
 
1763
1885
  onApiResult(error: Error | null): void {
1764
- if (this.stats && !this.stats.isCommitted()) {
1886
+ const stats = this.getActiveStats();
1887
+ if (stats) {
1765
1888
  if (error) {
1766
- this.stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1889
+ this.consume(
1890
+ stats
1891
+ .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1892
+ .stopTimer(DurationKey.TOTAL_DURATION)
1893
+ .stopTimer(DurationKey.GET_MESSAGES)
1894
+ .commit(),
1895
+ );
1896
+ return;
1767
1897
  }
1768
- this.stats.stopTimer(DurationKey.TOTAL_DURATION).stopTimer(DurationKey.GET_MESSAGES).commit();
1898
+ this.apiResultReceived = true;
1899
+ this.renderedAfterApiResult = false;
1900
+ stats.stopTimer(DurationKey.GET_MESSAGES);
1901
+ this.commitIfReady();
1769
1902
  }
1770
1903
  }
1771
1904
 
1772
1905
  setChannelUrl(url: string): void {
1773
- this.stats?.setChannelUrl(url);
1906
+ this.pinChannelUrl(url);
1774
1907
  }
1775
1908
 
1776
- cleanup(): void {
1777
- this.stats?.commit();
1909
+ commitPending(): boolean {
1910
+ const stats = this.getActiveStats();
1911
+ return stats ? this.consume(stats.stopTimer(DurationKey.TOTAL_DURATION).commit()) : true;
1912
+ }
1913
+
1914
+ markRendered(channelUrl?: string): boolean {
1915
+ if (!this.matchesChannelUrl(channelUrl)) return false;
1916
+
1917
+ this.renderedAfterApiResult = true;
1918
+ return this.commitIfReady(channelUrl);
1778
1919
  }
1779
1920
 
1780
1921
  setPresentMethod(method: PresentMethod): void {
1781
1922
  this.presentMethod = method;
1782
- this.stats?.setPresentMethod(method);
1923
+ this.getActiveStats()?.setPresentMethod(method);
1783
1924
  }
1784
1925
 
1785
1926
  clear(): void {
1786
1927
  this.stats = null;
1928
+ this.apiResultReceived = false;
1929
+ this.channelUrl = undefined;
1930
+ this.consumed = false;
1931
+ this.consumedByAuthError = false;
1932
+ this.renderedAfterApiResult = false;
1787
1933
  }
1788
1934
  }
1789
1935
 
@@ -1826,7 +1972,7 @@ export declare const ConversationLayout: {
1826
1972
  declare interface ConversationLayoutTemplateProps {
1827
1973
  }
1828
1974
 
1829
- export declare const ConversationList: ({ conversationListLimit, conversationListFilter, children, onOpenConversationView, announcementsEnabled, style, }: ConversationListProps) => JSX.Element;
1975
+ export declare const ConversationList: ({ conversationListLimit, conversationListFilter, children, onOpenConversationView, announcementsEnabled, conversationListStatsEnabled, style, }: ConversationListProps) => JSX.Element;
1830
1976
 
1831
1977
  /**
1832
1978
  * Public interface for ConversationListCollection.
@@ -1855,10 +2001,11 @@ export declare const ConversationListContext: Context<ConversationListContextVal
1855
2001
  declare interface ConversationListContextProps {
1856
2002
  conversationListLimit?: number;
1857
2003
  conversationListFilter?: Partial<AIAgentGroupChannelFilter>;
2004
+ conversationListStatsEnabled?: boolean;
1858
2005
  onOpenConversationView?: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
1859
2006
  }
1860
2007
 
1861
- export declare function ConversationListContextProvider({ conversationListLimit, conversationListFilter, onOpenConversationView, children, }: PropsWithChildren<ConversationListContextProps>): JSX.Element;
2008
+ export declare function ConversationListContextProvider({ conversationListLimit, conversationListFilter, conversationListStatsEnabled, onOpenConversationView, children, }: PropsWithChildren<ConversationListContextProps>): JSX.Element;
1862
2009
 
1863
2010
  declare interface ConversationListContextValue extends AIAgentConversationListContextValue {
1864
2011
  onOpenConversationView: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
@@ -1916,6 +2063,156 @@ declare interface ConversationListHeaderTemplateProps {
1916
2063
  titleAlign?: 'start' | 'center' | 'end';
1917
2064
  }
1918
2065
 
2066
+ declare class ConversationListInitialRenderStatsTracker {
2067
+ private readonly callback: StatsAppendCallback;
2068
+ private readonly clock: StatsClock;
2069
+ private measurement = createEmptyProgress();
2070
+ private renderedCommitted = false;
2071
+ private startedAt: number | null = null;
2072
+ private listStartedAt: number | null = null;
2073
+ private authStartedAt: number | null = null;
2074
+ private authDuration: number | null = null;
2075
+ private listDuration: number | null = null;
2076
+ private presentMethod: PresentMethod = 'direct_present';
2077
+ private committed = false;
2078
+
2079
+ constructor(callback: StatsAppendCallback, options?: TrackerOptions) {
2080
+ this.callback = callback;
2081
+ this.clock = getClock(options);
2082
+ }
2083
+
2084
+ onInitializeStart(): void {
2085
+ if (this.measurement.rendered) return;
2086
+ if (this.measurement.initialized) {
2087
+ this.clearMeasurementState();
2088
+ }
2089
+ this.renderedCommitted = false;
2090
+ this.startMeasurement();
2091
+ }
2092
+
2093
+ onListRequestStart(): void {
2094
+ if (this.measurement.listStarted && !this.measurement.listCompleted && !this.measurement.rendered) {
2095
+ this.clearMeasurementState();
2096
+ }
2097
+
2098
+ this.markListRequestStartOnce();
2099
+ }
2100
+
2101
+ onListRequestComplete(): void {
2102
+ this.markListRequestCompleteOnce();
2103
+ }
2104
+
2105
+ onRendered(): void {
2106
+ this.markRenderedOnce();
2107
+ }
2108
+
2109
+ setPresentMethod(method: PresentMethod): void {
2110
+ this.presentMethod = method;
2111
+ }
2112
+
2113
+ start(presentMethod?: PresentMethod): void {
2114
+ if (this.committed) return;
2115
+ this.startedAt = this.clock.now();
2116
+ if (presentMethod) this.presentMethod = presentMethod;
2117
+ }
2118
+
2119
+ onAuthStart(): void {
2120
+ if (this.committed) {
2121
+ this.clearMeasurementState();
2122
+ this.renderedCommitted = false;
2123
+ }
2124
+ this.authDuration = null;
2125
+ this.authStartedAt = this.clock.now();
2126
+ }
2127
+
2128
+ onAuthComplete(): void {
2129
+ if (this.committed || this.authDuration !== null) return;
2130
+ if (this.authStartedAt === null) {
2131
+ this.authDuration = 0;
2132
+ return;
2133
+ }
2134
+ this.authDuration = this.clock.now() - this.authStartedAt;
2135
+ this.authStartedAt = null;
2136
+ }
2137
+
2138
+ markListRequestStart(): void {
2139
+ if (this.committed) return;
2140
+ if (this.startedAt === null) this.start();
2141
+ this.listStartedAt = this.clock.now();
2142
+ }
2143
+
2144
+ markListRequestComplete(): void {
2145
+ if (this.committed || this.listStartedAt === null) return;
2146
+ this.listDuration = this.clock.now() - this.listStartedAt;
2147
+ }
2148
+
2149
+ markRendered(): void {
2150
+ if (this.committed || this.startedAt === null) return;
2151
+ this.committed = true;
2152
+ append(this.callback, METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER, this.clock.now() - this.startedAt, {
2153
+ extra: {
2154
+ ...(this.authDuration !== null && { auth_duration_ms: this.authDuration }),
2155
+ ...(this.listDuration !== null && { get_list_duration_ms: this.listDuration }),
2156
+ present_method: this.presentMethod,
2157
+ },
2158
+ });
2159
+ }
2160
+
2161
+ clear(): void {
2162
+ this.clearMeasurementState();
2163
+ this.clearAuthState();
2164
+ this.renderedCommitted = false;
2165
+ }
2166
+
2167
+ private clearMeasurementState(): void {
2168
+ this.startedAt = null;
2169
+ this.listStartedAt = null;
2170
+ this.listDuration = null;
2171
+ this.committed = false;
2172
+ this.measurement = createEmptyProgress();
2173
+ }
2174
+
2175
+ private clearAuthState(): void {
2176
+ this.authStartedAt = null;
2177
+ this.authDuration = null;
2178
+ }
2179
+
2180
+ private startMeasurement(): void {
2181
+ if (this.measurement.initialized) return;
2182
+
2183
+ this.clearMeasurementState();
2184
+ this.start();
2185
+ this.measurement.initialized = true;
2186
+ }
2187
+
2188
+ private markListRequestStartOnce(): void {
2189
+ this.startMeasurement();
2190
+ if (this.measurement.listStarted) return;
2191
+
2192
+ this.markListRequestStart();
2193
+ this.measurement.listStarted = true;
2194
+ }
2195
+
2196
+ private markListRequestCompleteOnce(): void {
2197
+ this.markListRequestStartOnce();
2198
+ if (this.measurement.listCompleted) return;
2199
+
2200
+ this.markListRequestComplete();
2201
+ this.measurement.listCompleted = true;
2202
+ }
2203
+
2204
+ private markRenderedOnce(): void {
2205
+ if (this.renderedCommitted) return;
2206
+
2207
+ this.startMeasurement();
2208
+ if (this.measurement.rendered) return;
2209
+
2210
+ this.markRendered();
2211
+ this.measurement.rendered = true;
2212
+ this.renderedCommitted = true;
2213
+ }
2214
+ }
2215
+
1919
2216
  export declare const ConversationListItemLayout: {
1920
2217
  (props: PropsWithChildren): ReactNode;
1921
2218
  defaults: {
@@ -1985,6 +2282,7 @@ export declare type ConversationListProps = PropsWithChildren<{
1985
2282
  conversationListFilter?: Partial<AIAgentGroupChannelFilter>;
1986
2283
  onOpenConversationView?: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
1987
2284
  announcementsEnabled?: boolean;
2285
+ conversationListStatsEnabled?: boolean;
1988
2286
  /** Custom styles for the conversation list container. */
1989
2287
  style?: CSSProperties;
1990
2288
  }>;
@@ -2008,12 +2306,138 @@ declare type ConversationMessageEventPayload =
2008
2306
  messages: ChatSDKBaseMessage[];
2009
2307
  };
2010
2308
 
2309
+ declare type ConversationNotInteractiveMeasurement = {
2310
+ timer: TimeoutId | null;
2311
+ startedAt: number;
2312
+ committed: boolean;
2313
+ inputEnabled: boolean;
2314
+ phaseReached: ConversationNotInteractivePhase;
2315
+ historyLoadResult: HistoryLoadResult;
2316
+ conversationId?: number;
2317
+ channelUrl?: string;
2318
+ };
2319
+
2320
+ declare type ConversationNotInteractivePhase = 'auth' | 'channel_join' | 'history_load' | 'input_enable' | 'render';
2321
+
2322
+ declare class ConversationNotInteractiveTimeoutStatsTracker {
2323
+ private readonly callback: StatsAppendCallback;
2324
+ private readonly clock: StatsClock;
2325
+ private readonly setTimeoutFn: typeof setTimeout;
2326
+ private readonly clearTimeoutFn: typeof clearTimeout;
2327
+ private readonly measurements = new Map<string, ConversationNotInteractiveMeasurement>();
2328
+
2329
+ constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
2330
+ this.callback = callback;
2331
+ this.clock = getClock(options);
2332
+ this.setTimeoutFn = getSetTimeout(options);
2333
+ this.clearTimeoutFn = getClearTimeout(options);
2334
+ }
2335
+
2336
+ start(params?: { channelUrl?: string; conversationId?: number }): void {
2337
+ const key = this.getKey(params?.channelUrl);
2338
+ this.clearKey(key);
2339
+
2340
+ const measurement = {
2341
+ startedAt: this.clock.now(),
2342
+ channelUrl: params?.channelUrl,
2343
+ conversationId: params?.conversationId,
2344
+ committed: false,
2345
+ inputEnabled: false,
2346
+ phaseReached: 'auth' as ConversationNotInteractivePhase,
2347
+ historyLoadResult: 'pending' as HistoryLoadResult,
2348
+ timer: null as TimeoutId | null,
2349
+ };
2350
+ measurement.timer = this.setTimeoutFn(() => this.commitTimeout(key), 10_000);
2351
+ this.measurements.set(key, measurement);
2352
+ }
2353
+
2354
+ markChannelJoined(conversationId?: number, channelUrl?: string): void {
2355
+ const measurement = this.getMeasurement(channelUrl);
2356
+ if (!measurement || measurement.committed) return;
2357
+ measurement.phaseReached = 'channel_join';
2358
+ if (conversationId !== undefined) measurement.conversationId = conversationId;
2359
+ }
2360
+
2361
+ markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2362
+ const measurement = this.getMeasurement(channelUrl);
2363
+ if (!measurement || measurement.committed) return;
2364
+ measurement.phaseReached = 'history_load';
2365
+ measurement.historyLoadResult = result;
2366
+ this.cancelIfReady(channelUrl);
2367
+ }
2368
+
2369
+ markInputEnabled(channelUrl?: string): void {
2370
+ const measurement = this.getMeasurement(channelUrl);
2371
+ if (!measurement || measurement.committed) return;
2372
+ measurement.phaseReached = 'input_enable';
2373
+ measurement.inputEnabled = true;
2374
+ this.cancelIfReady(channelUrl);
2375
+ }
2376
+
2377
+ markRendered(channelUrl?: string): void {
2378
+ const measurement = this.getMeasurement(channelUrl);
2379
+ if (!measurement || measurement.committed) return;
2380
+ measurement.phaseReached = 'render';
2381
+ }
2382
+
2383
+ clear(): void {
2384
+ this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
2385
+ this.measurements.clear();
2386
+ }
2387
+
2388
+ clearChannel(channelUrl?: string): void {
2389
+ if (channelUrl === undefined) return;
2390
+ this.clearKey(this.getKey(channelUrl));
2391
+ }
2392
+
2393
+ private clearKey(key: string): void {
2394
+ const measurement = this.measurements.get(key);
2395
+ if (!measurement) return;
2396
+ clearTimer(measurement.timer, this.clearTimeoutFn);
2397
+ this.measurements.delete(key);
2398
+ }
2399
+
2400
+ private cancelIfReady(channelUrl?: string): void {
2401
+ const measurement = this.getMeasurement(channelUrl);
2402
+ if (!measurement || !measurement.inputEnabled || measurement.historyLoadResult === 'pending') return;
2403
+ this.clearKey(this.getKey(measurement.channelUrl));
2404
+ }
2405
+
2406
+ private commitTimeout(key: string): void {
2407
+ const measurement = this.measurements.get(key);
2408
+ if (!measurement || measurement.committed) return;
2409
+ measurement.committed = true;
2410
+ measurement.timer = null;
2411
+ append(this.callback, METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT, this.clock.now() - measurement.startedAt, {
2412
+ conversationId: measurement.conversationId,
2413
+ error: { message: METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT },
2414
+ extra: {
2415
+ outcome: 'timeout',
2416
+ phase_reached: measurement.phaseReached,
2417
+ history_load_result: measurement.historyLoadResult,
2418
+ },
2419
+ });
2420
+ this.measurements.delete(key);
2421
+ }
2422
+
2423
+ private getMeasurement(channelUrl?: string): ConversationNotInteractiveMeasurement | undefined {
2424
+ if (channelUrl !== undefined) return this.measurements.get(this.getKey(channelUrl));
2425
+ if (this.measurements.size === 1) return Array.from(this.measurements.values())[0];
2426
+ return this.measurements.get(this.getKey());
2427
+ }
2428
+
2429
+ private getKey(channelUrl?: string): string {
2430
+ return channelUrl ?? '__default__';
2431
+ }
2432
+ }
2433
+
2011
2434
  export declare type ConversationProps = PropsWithChildren<{
2012
2435
  channelUrl?: string;
2013
2436
  onClearChannelUrl?: () => void;
2014
2437
  onNavigateToConversationList?: () => void;
2015
2438
  shouldMarkAsRead?: boolean;
2016
2439
  announcementsEnabled?: boolean;
2440
+ conversationStatsEnabled?: boolean;
2017
2441
  initialFocusTarget?: ConversationInitialFocusTarget;
2018
2442
  /** Custom styles for the conversation container. */
2019
2443
  style?: CSSProperties;
@@ -2209,17 +2633,31 @@ declare interface Dispatcher {
2209
2633
  ): void;
2210
2634
  }
2211
2635
 
2636
+ declare enum DurationKey {
2637
+ AUTH = 'auth_duration_ms',
2638
+ GET_CHANNEL = 'get_channel_duration_ms',
2639
+ GET_MESSAGES = 'get_messages_duration_ms',
2640
+ TOTAL_DURATION = 'total_duration_ms',
2641
+ TOTAL_DURATION_FROM_CACHE = 'total_duration_from_cache_ms',
2642
+ }
2643
+
2212
2644
  declare type ErrorAnnouncementParams =
2213
2645
  | { type: 'fileSizeExceeded'; maxSizeMB?: number }
2214
2646
  | { type: 'sendFailed' }
2215
2647
  | { type: 'csatFormError' }
2216
2648
  | { type: 'generic'; message: string };
2217
2649
 
2650
+ declare type ErrorLike = StatsPayloadError;
2651
+
2218
2652
  /**
2219
2653
  * For better understanding: https://sendbird.atlassian.net/wiki/spaces/AA/pages/3075014695/Extended+Message+Payload+Spec
2220
2654
  */
2221
2655
  export declare interface ExtendedMessagePayload {
2222
2656
  is_thinking: boolean;
2657
+ // Whether this message is the last bubble in a multi-bubble turn.
2658
+ // Sent as `true` only on the last bubble; omitted (undefined) on non-last bubbles.
2659
+ // Only reliable once the server stream has completed (data.stream = false).
2660
+ is_last_in_turn?: boolean;
2223
2661
  cta_button: {
2224
2662
  label: string;
2225
2663
  url: string;
@@ -2348,6 +2786,8 @@ declare interface FixedMessengerStyleProps {
2348
2786
  launcherSize?: number;
2349
2787
  }
2350
2788
 
2789
+ declare type ForegroundTaskScheduler = (start: () => void) => void | (() => void);
2790
+
2351
2791
  declare interface Form {
2352
2792
  key: string;
2353
2793
  version: number;
@@ -2377,6 +2817,16 @@ export declare interface FunctionCallsInfo {
2377
2817
  };
2378
2818
  }
2379
2819
 
2820
+ declare type GetChannelCompleteParams = {
2821
+ channelUrl?: string;
2822
+ conversationId?: number;
2823
+ };
2824
+
2825
+ declare type GetChannelErrorParams = {
2826
+ channelUrl?: string;
2827
+ error: Error;
2828
+ };
2829
+
2380
2830
  export declare interface GroundednessInfo {
2381
2831
  id: number;
2382
2832
  source_type:
@@ -2399,6 +2849,8 @@ export declare interface GroundednessInfo {
2399
2849
  */
2400
2850
  declare type HexColor = `#${string}`;
2401
2851
 
2852
+ declare type HistoryLoadResult = 'success' | 'verified_empty' | 'failed' | 'pending';
2853
+
2402
2854
  export declare type IconComponent = AIAgentIconComponent;
2403
2855
 
2404
2856
  export declare type IconComponentProps = AIAgentIconComponentProps;
@@ -2480,13 +2932,8 @@ export declare const IncomingMessageLayout: {
2480
2932
  };
2481
2933
  }) => ReactNode;
2482
2934
  SuggestedReplies: ({ extendedMessagePayload, onClickSuggestedReply, suggestedRepliesDirection, }: IncomingMessageProps) => ReactNode;
2483
- MessageTemplate: (props: {
2484
- onGetCachedMessageTemplate?: ((templateKey: string) => string | null) | undefined;
2485
- onRequestMessageTemplate?: ((templateKey: string) => Promise<string>) | undefined;
2486
- onHandleTemplateInternalAction?: ((action: Action) => void) | undefined;
2487
- messageTemplateErrorFallback?: ReactNode;
2488
- messageTemplateLoadingFallback?: ReactNode;
2489
- extendedMessagePayload?: Partial<ExtendedMessagePayload> | undefined;
2935
+ MessageTemplate: (props: Pick<IncomingMessageProps, "onGetCachedMessageTemplate" | "onRequestMessageTemplate" | "onHandleTemplateInternalAction" | "messageTemplateErrorFallback" | "messageTemplateLoadingFallback" | "extendedMessagePayload"> & {
2936
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onMessageTemplateError">;
2490
2937
  }) => ReactNode;
2491
2938
  CustomMessageTemplate: (_: ({
2492
2939
  messageType: "user";
@@ -2950,7 +3397,9 @@ export declare const IncomingMessageLayout: {
2950
3397
  })) => ReactNode;
2951
3398
  CTAButton: ({ extendedMessagePayload, onClickCTA, }: IncomingMessageProps) => ReactNode;
2952
3399
  Citation: ({ extendedMessagePayload, onClickCitation, }: IncomingMessageProps) => ReactNode;
2953
- Form: (props: IncomingMessageProps) => ReactNode;
3400
+ Form: (props: IncomingMessageProps & {
3401
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onFormRenderError">;
3402
+ }) => ReactNode;
2954
3403
  Feedback: ({ isBotMessage, isConversationClosed, isStreaming, isFeedbackEnabled, isFeedbackCommentEnabled, extendedMessagePayload, onFeedbackUpdate, }: IncomingMessageProps) => ReactNode;
2955
3404
  MessageLogs: (_: IncomingMessageProps) => ReactNode;
2956
3405
  };
@@ -2975,13 +3424,8 @@ export declare const IncomingMessageLayout: {
2975
3424
  };
2976
3425
  }) => ReactNode;
2977
3426
  SuggestedReplies: ({ extendedMessagePayload, onClickSuggestedReply, suggestedRepliesDirection, }: IncomingMessageProps) => ReactNode;
2978
- MessageTemplate: (props: {
2979
- onGetCachedMessageTemplate?: ((templateKey: string) => string | null) | undefined;
2980
- onRequestMessageTemplate?: ((templateKey: string) => Promise<string>) | undefined;
2981
- onHandleTemplateInternalAction?: ((action: Action) => void) | undefined;
2982
- messageTemplateErrorFallback?: ReactNode;
2983
- messageTemplateLoadingFallback?: ReactNode;
2984
- extendedMessagePayload?: Partial<ExtendedMessagePayload> | undefined;
3427
+ MessageTemplate: (props: Pick<IncomingMessageProps, "onGetCachedMessageTemplate" | "onRequestMessageTemplate" | "onHandleTemplateInternalAction" | "messageTemplateErrorFallback" | "messageTemplateLoadingFallback" | "extendedMessagePayload"> & {
3428
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onMessageTemplateError">;
2985
3429
  }) => ReactNode;
2986
3430
  CustomMessageTemplate: (_: ({
2987
3431
  messageType: "user";
@@ -3445,7 +3889,9 @@ export declare const IncomingMessageLayout: {
3445
3889
  })) => ReactNode;
3446
3890
  CTAButton: ({ extendedMessagePayload, onClickCTA, }: IncomingMessageProps) => ReactNode;
3447
3891
  Citation: ({ extendedMessagePayload, onClickCitation, }: IncomingMessageProps) => ReactNode;
3448
- Form: (props: IncomingMessageProps) => ReactNode;
3892
+ Form: (props: IncomingMessageProps & {
3893
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onFormRenderError">;
3894
+ }) => ReactNode;
3449
3895
  Feedback: ({ isBotMessage, isConversationClosed, isStreaming, isFeedbackEnabled, isFeedbackCommentEnabled, extendedMessagePayload, onFeedbackUpdate, }: IncomingMessageProps) => ReactNode;
3450
3896
  MessageLogs: (_: IncomingMessageProps) => ReactNode;
3451
3897
  }>>;
@@ -3465,13 +3911,8 @@ export declare const IncomingMessageLayout: {
3465
3911
  };
3466
3912
  }) => ReactNode;
3467
3913
  SuggestedReplies: ({ extendedMessagePayload, onClickSuggestedReply, suggestedRepliesDirection, }: IncomingMessageProps) => ReactNode;
3468
- MessageTemplate: (props: {
3469
- onGetCachedMessageTemplate?: ((templateKey: string) => string | null) | undefined;
3470
- onRequestMessageTemplate?: ((templateKey: string) => Promise<string>) | undefined;
3471
- onHandleTemplateInternalAction?: ((action: Action) => void) | undefined;
3472
- messageTemplateErrorFallback?: ReactNode;
3473
- messageTemplateLoadingFallback?: ReactNode;
3474
- extendedMessagePayload?: Partial<ExtendedMessagePayload> | undefined;
3914
+ MessageTemplate: (props: Pick<IncomingMessageProps, "onGetCachedMessageTemplate" | "onRequestMessageTemplate" | "onHandleTemplateInternalAction" | "messageTemplateErrorFallback" | "messageTemplateLoadingFallback" | "extendedMessagePayload"> & {
3915
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onMessageTemplateError">;
3475
3916
  }) => ReactNode;
3476
3917
  CustomMessageTemplate: (_: ({
3477
3918
  messageType: "user";
@@ -3935,19 +4376,16 @@ export declare const IncomingMessageLayout: {
3935
4376
  })) => ReactNode;
3936
4377
  CTAButton: ({ extendedMessagePayload, onClickCTA, }: IncomingMessageProps) => ReactNode;
3937
4378
  Citation: ({ extendedMessagePayload, onClickCitation, }: IncomingMessageProps) => ReactNode;
3938
- Form: (props: IncomingMessageProps) => ReactNode;
4379
+ Form: (props: IncomingMessageProps & {
4380
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onFormRenderError">;
4381
+ }) => ReactNode;
3939
4382
  Feedback: ({ isBotMessage, isConversationClosed, isStreaming, isFeedbackEnabled, isFeedbackCommentEnabled, extendedMessagePayload, onFeedbackUpdate, }: IncomingMessageProps) => ReactNode;
3940
4383
  MessageLogs: (_: IncomingMessageProps) => ReactNode;
3941
4384
  }>;
3942
4385
  } & {
3943
4386
  MessageTemplate: (props: {
3944
- component: (props: {
3945
- onGetCachedMessageTemplate?: ((templateKey: string) => string | null) | undefined;
3946
- onRequestMessageTemplate?: ((templateKey: string) => Promise<string>) | undefined;
3947
- onHandleTemplateInternalAction?: ((action: Action) => void) | undefined;
3948
- messageTemplateErrorFallback?: ReactNode;
3949
- messageTemplateLoadingFallback?: ReactNode;
3950
- extendedMessagePayload?: Partial<ExtendedMessagePayload> | undefined;
4387
+ component: (props: Pick<IncomingMessageProps, "onGetCachedMessageTemplate" | "onRequestMessageTemplate" | "onHandleTemplateInternalAction" | "messageTemplateErrorFallback" | "messageTemplateLoadingFallback" | "extendedMessagePayload"> & {
4388
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onMessageTemplateError">;
3951
4389
  }) => ReactNode;
3952
4390
  }) => null;
3953
4391
  SenderName: (props: {
@@ -4456,7 +4894,9 @@ export declare const IncomingMessageLayout: {
4456
4894
  component: ({ extendedMessagePayload, onClickCitation, }: IncomingMessageProps) => ReactNode;
4457
4895
  }) => null;
4458
4896
  Form: (props: {
4459
- component: (props: IncomingMessageProps) => ReactNode;
4897
+ component: (props: IncomingMessageProps & {
4898
+ statsCallbacks?: Pick<IncomingMessageStatsCallbacks, "onFormRenderError">;
4899
+ }) => ReactNode;
4460
4900
  }) => null;
4461
4901
  Feedback: (props: {
4462
4902
  component: ({ isBotMessage, isConversationClosed, isStreaming, isFeedbackEnabled, isFeedbackCommentEnabled, extendedMessagePayload, onFeedbackUpdate, }: IncomingMessageProps) => ReactNode;
@@ -4469,6 +4909,13 @@ export declare const IncomingMessageLayout: {
4469
4909
  export declare type IncomingMessageProps<T extends IncomingMessageUnion['messageType'] = IncomingMessageUnion['messageType']> =
4470
4910
  PickMessageProps<IncomingMessageUnion, T>;
4471
4911
 
4912
+ declare type IncomingMessageStatsCallbacks = {
4913
+ onVisibleTextProgress?: (displayedLength: number) => void;
4914
+ onRenderedTextProgress?: (displayedLength: number) => void;
4915
+ onMessageTemplateError?: (error: Error) => void;
4916
+ onFormRenderError?: (error: Error) => void;
4917
+ };
4918
+
4472
4919
  declare type IncomingMessageTemplateProps = IncomingMessageProps & Partial<InternalExtraProps>;
4473
4920
 
4474
4921
  declare type IncomingMessageUnion =
@@ -4508,7 +4955,7 @@ declare type InputState =
4508
4955
 
4509
4956
  declare type InternalExtraProps = {
4510
4957
  maxBodyWidth?: number;
4511
- testerMode?: boolean;
4958
+ statsCallbacks?: IncomingMessageStatsCallbacks;
4512
4959
  };
4513
4960
 
4514
4961
  declare type InternalExtraProps_2 = {
@@ -4562,6 +5009,110 @@ declare interface LayoutContextValue<Props, Components extends Record<string, Co
4562
5009
  updateComponent: <K extends keyof Components>(key: K, component: Components[K]) => void;
4563
5010
  }
4564
5011
 
5012
+ /**
5013
+ * Base contract for every metric emitted by the Live Metric module.
5014
+ * The injected `dispatch` boundary receives values of this type.
5015
+ */
5016
+ export declare interface LiveMetric {
5017
+ readonly category: MetricCategory;
5018
+ readonly metricKey: string;
5019
+ readonly data: Record<string, string>;
5020
+ }
5021
+
5022
+ /** Outward boundary: emitters dispatch built metrics through this callback. */
5023
+ declare type LiveMetricDispatch = (metric: LiveMetric) => void;
5024
+
5025
+ /**
5026
+ * Category emitter contract. Each emitter accepts its own source variant,
5027
+ * decides whether to emit based on its state cache, and dispatches metrics.
5028
+ */
5029
+ declare interface LiveMetricEmitter<S extends MetricSource = MetricSource> {
5030
+ handleEvent(source: S): void;
5031
+ }
5032
+
5033
+ /**
5034
+ * Central manager for the Live Metric module.
5035
+ *
5036
+ * Registers Sendbird SDK handlers, wraps raw callbacks into {@link MetricSource}s,
5037
+ * routes each source to its category emitter, and lets emitters dispatch public
5038
+ * metrics through the settable {@link LiveMetricManager.dispatch} sink. Outgoing message
5039
+ * and explicit conversation-initialized sources are injected via {@link LiveMetricManager.track}.
5040
+ */
5041
+ declare class LiveMetricManager {
5042
+ private readonly id = 'aiagent_livemetric';
5043
+
5044
+ /** Sink for emitted metrics. Set by the owner; metrics are dropped while unset. */
5045
+ dispatch?: LiveMetricDispatch;
5046
+
5047
+ // Project to the plain public shape: drops internal fields and resolves the
5048
+ // non-enumerable `data` getter so the payload survives JSON.stringify/spread.
5049
+ private readonly emit: LiveMetricDispatch = ({ category, metricKey, data }) => {
5050
+ this.dispatch?.({ category, metricKey, data });
5051
+ };
5052
+ private readonly emitters: Record<MetricSource['type'], LiveMetricEmitter>;
5053
+
5054
+ constructor(private readonly chatSDK: ChatSDKType) {
5055
+ const getCurrentUserId = () => this.chatSDK.currentUser?.userId;
5056
+ this.emitters = {
5057
+ connection: new ConnectionMetricEmitter(this.emit),
5058
+ conversation: new ConversationMetricEmitter(this.emit),
5059
+ handoff: new HandoffMetricEmitter(this.emit),
5060
+ incomingMessage: new IncomingMessageMetricEmitter(this.emit, getCurrentUserId),
5061
+ outgoingMessage: new OutgoingMessageMetricEmitter(this.emit, getCurrentUserId),
5062
+ };
5063
+ }
5064
+
5065
+ start(): void {
5066
+ const connectionHandler = new ConnectionHandler({
5067
+ onConnected: (userId) => this.trackConnection('connected', userId),
5068
+ onDisconnected: (userId) => this.trackConnection('disconnected', userId),
5069
+ onReconnectStarted: () => this.trackConnection('reconnectStarted'),
5070
+ onReconnectSucceeded: () => this.trackConnection('reconnectSucceeded'),
5071
+ onReconnectFailed: () => this.trackConnection('reconnectFailed'),
5072
+ });
5073
+
5074
+ const channelHandler = new GroupChannelHandler({
5075
+ onMessageReceived: (channel, message) => {
5076
+ if (!channel.isGroupChannel()) return;
5077
+ this.track({ type: 'incomingMessage', channel, message });
5078
+ },
5079
+ onChannelChanged: (channel) => {
5080
+ if (!channel.isGroupChannel()) return;
5081
+
5082
+ // Note: onChannelChanged can be triggered by various events (message received, user joined/left, etc.)
5083
+ this.track({ type: 'handoff', channel });
5084
+ this.track({ type: 'conversation', channel });
5085
+ },
5086
+ onUserJoined: (channel) => this.track({ type: 'handoff', channel }),
5087
+ onUserLeft: (channel) => this.track({ type: 'handoff', channel }),
5088
+ });
5089
+
5090
+ this.chatSDK.addConnectionHandler(this.id, connectionHandler);
5091
+ this.chatSDK.groupChannel.addGroupChannelHandler(this.id, channelHandler);
5092
+ }
5093
+
5094
+ /**
5095
+ * Routes a source to its emitter. Also the entry point for externally injected
5096
+ * sources (outgoing message status, explicit conversation-initialized).
5097
+ */
5098
+ track(source: MetricSource): void {
5099
+ try {
5100
+ this.emitters[source.type].handleEvent(source);
5101
+ } catch {
5102
+ // no-op
5103
+ }
5104
+ }
5105
+
5106
+ dispose(): void {
5107
+ this.chatSDK.removeConnectionHandler(this.id);
5108
+ this.chatSDK.groupChannel.removeGroupChannelHandler(this.id);
5109
+ }
5110
+
5111
+ private trackConnection(detail: ConnectionEventDetail, userId = this.chatSDK.currentUser?.userId): void {
5112
+ if (userId) this.track({ type: 'connection', detail, userId });
5113
+ }
5114
+ }
5115
+
4565
5116
  declare interface LocalizationContextValue extends LanguageSet {
4566
5117
  format: typeof format;
4567
5118
  defaultStringSet: StringSet;
@@ -4651,6 +5202,95 @@ export declare const MessageListUILayout: {
4651
5202
 
4652
5203
  export declare const MessageLogs: ({ actionbook, functionCalls, groundedness, agentMessageTemplates, flaggedTypes, onClickActionbook, onClickFunctionCall, onClickFunctionCallDetail, onClickGroundedness, onClickAgentMessageTemplate, topContent, bottomContent, renderCustomGroundednessIcon, style, }: Props_2) => JSX.Element;
4653
5204
 
5205
+ declare class MessagePendingStuckStatsTracker {
5206
+ private readonly callback: StatsAppendCallback;
5207
+ private readonly setTimeoutFn: typeof setTimeout;
5208
+ private readonly clearTimeoutFn: typeof clearTimeout;
5209
+ private readonly measurements = new Map<string, PendingMeasurement>();
5210
+
5211
+ constructor(callback: StatsAppendCallback, options?: PendingStuckTrackerOptions) {
5212
+ this.callback = callback;
5213
+ this.setTimeoutFn = getSetTimeout(options);
5214
+ this.clearTimeoutFn = getClearTimeout(options);
5215
+ }
5216
+
5217
+ start(params: {
5218
+ requestId: string;
5219
+ channelUrl?: string;
5220
+ messageType: PendingMessageType;
5221
+ tracksUploadProgress?: boolean;
5222
+ }): void {
5223
+ if (!params.requestId || this.measurements.has(params.requestId)) return;
5224
+
5225
+ const usesUploadProgress = params.messageType !== 'text' && params.tracksUploadProgress === true;
5226
+ const measurement: PendingMeasurement = {
5227
+ channelUrl: params.channelUrl,
5228
+ messageType: params.messageType,
5229
+ stuckReason: usesUploadProgress ? 'upload_no_progress' : 'pending_too_long',
5230
+ timer: null,
5231
+ };
5232
+ const timeoutMs = usesUploadProgress ? FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS : TEXT_PENDING_TIMEOUT_MS;
5233
+ measurement.timer = this.setTimeoutFn(() => this.detect(params.requestId), timeoutMs);
5234
+ this.measurements.set(params.requestId, measurement);
5235
+ }
5236
+
5237
+ resolve(params: { requestId: string }): void {
5238
+ const measurement = this.measurements.get(params.requestId);
5239
+ if (!measurement) return;
5240
+
5241
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5242
+ this.measurements.delete(params.requestId);
5243
+ }
5244
+
5245
+ disappear(params: { requestId: string }): void {
5246
+ const measurement = this.measurements.get(params.requestId);
5247
+ if (!measurement) return;
5248
+
5249
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5250
+ this.measurements.delete(params.requestId);
5251
+ }
5252
+
5253
+ markUploadProgress(requestId: string): void {
5254
+ const measurement = this.measurements.get(requestId);
5255
+ if (!measurement) return;
5256
+ if (measurement.stuckReason !== 'upload_no_progress') return;
5257
+
5258
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5259
+ measurement.timer = this.setTimeoutFn(() => this.detect(requestId), FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS);
5260
+ }
5261
+
5262
+ clear(): void {
5263
+ this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5264
+ this.measurements.clear();
5265
+ }
5266
+
5267
+ clearChannel(channelUrl?: string): void {
5268
+ if (!channelUrl) return;
5269
+ this.measurements.forEach((measurement, requestId) => {
5270
+ if (measurement.channelUrl !== channelUrl) return;
5271
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5272
+ this.measurements.delete(requestId);
5273
+ });
5274
+ }
5275
+
5276
+ private detect(requestId: string): void {
5277
+ const measurement = this.measurements.get(requestId);
5278
+ if (!measurement) return;
5279
+
5280
+ this.measurements.delete(requestId);
5281
+ this.commit(requestId, measurement);
5282
+ }
5283
+
5284
+ private commit(requestId: string, measurement: PendingMeasurement): void {
5285
+ append(this.callback, METRIC_KEY_MESSAGE_PENDING_STUCK, measurement.stuckReason, {
5286
+ extra: {
5287
+ message_type: measurement.messageType,
5288
+ request_id: requestId,
5289
+ },
5290
+ });
5291
+ }
5292
+ }
5293
+
4654
5294
  declare interface MessageTemplateCache {
4655
5295
  set(key: string, value: string): void;
4656
5296
  get(key: string): string | null;
@@ -4840,6 +5480,18 @@ declare type MessengerSettingsUpdatedParams =
4840
5480
  | { settings: MessengerSettingsResponse; source: 'server' }
4841
5481
  | { settings: CachedMessengerSettings; source: 'cache' };
4842
5482
 
5483
+ declare type MetricCategory = 'handoff' | 'conversation' | 'message' | 'connection';
5484
+
5485
+ /**
5486
+ * An event + action represented as a single value. Input to the emitters.
5487
+ */
5488
+ declare type MetricSource =
5489
+ | { type: 'connection'; detail: ConnectionEventDetail; userId: string }
5490
+ | { type: 'handoff'; channel: ChatSDKGroupChannel }
5491
+ | { type: 'conversation'; channel: ChatSDKGroupChannel }
5492
+ | { type: 'incomingMessage'; channel: ChatSDKGroupChannel; message: ChatSDKBaseMessage }
5493
+ | { type: 'outgoingMessage'; channel: ChatSDKGroupChannel; message: ChatSDKBaseMessage };
5494
+
4843
5495
  declare type MultiSelectField = {
4844
5496
  key: string;
4845
5497
  type: 'multi-select';
@@ -4915,6 +5567,7 @@ export declare const OutgoingMessageLayout: {
4915
5567
  MediaMessageBody: typeof OutgoingImageBody;
4916
5568
  FileMessageBody: typeof OutgoingFileBody;
4917
5569
  MultipleFilesMessageBody: ({ sendingStatus, files, metadata, onClickMedia, onClickMediaFiles, children, }: OutgoingMessageBodyProps<"multipleFiles">) => JSX.Element;
5570
+ MessageLogs: (_: OutgoingMessageProps) => ReactNode;
4918
5571
  };
4919
5572
  };
4920
5573
  Template: ({ template, children }: {
@@ -4929,6 +5582,7 @@ export declare const OutgoingMessageLayout: {
4929
5582
  MediaMessageBody: typeof OutgoingImageBody;
4930
5583
  FileMessageBody: typeof OutgoingFileBody;
4931
5584
  MultipleFilesMessageBody: ({ sendingStatus, files, metadata, onClickMedia, onClickMediaFiles, children, }: OutgoingMessageBodyProps<"multipleFiles">) => JSX.Element;
5585
+ MessageLogs: (_: OutgoingMessageProps) => ReactNode;
4932
5586
  }>>;
4933
5587
  useContext: () => LayoutContextValue<OutgoingMessageProps, {
4934
5588
  SendingStatus: ({ sendingStatus }: OutgoingMessageProps) => ReactNode;
@@ -4938,6 +5592,7 @@ export declare const OutgoingMessageLayout: {
4938
5592
  MediaMessageBody: typeof OutgoingImageBody;
4939
5593
  FileMessageBody: typeof OutgoingFileBody;
4940
5594
  MultipleFilesMessageBody: ({ sendingStatus, files, metadata, onClickMedia, onClickMediaFiles, children, }: OutgoingMessageBodyProps<"multipleFiles">) => JSX.Element;
5595
+ MessageLogs: (_: OutgoingMessageProps) => ReactNode;
4941
5596
  }>;
4942
5597
  } & {
4943
5598
  SentTime: (props: {
@@ -4958,6 +5613,9 @@ export declare const OutgoingMessageLayout: {
4958
5613
  MultipleFilesMessageBody: (props: {
4959
5614
  component: ({ sendingStatus, files, metadata, onClickMedia, onClickMediaFiles, children, }: OutgoingMessageBodyProps<"multipleFiles">) => JSX.Element;
4960
5615
  }) => null;
5616
+ MessageLogs: (props: {
5617
+ component: (_: OutgoingMessageProps) => ReactNode;
5618
+ }) => null;
4961
5619
  SendingStatus: (props: {
4962
5620
  component: ({ sendingStatus }: OutgoingMessageProps) => ReactNode;
4963
5621
  }) => null;
@@ -5035,6 +5693,31 @@ declare interface PaletteTextEmphasis {
5035
5693
  textDisabled: string;
5036
5694
  }
5037
5695
 
5696
+ declare type PendingAIResponseRaw = {
5697
+ messageId: number;
5698
+ channelUrl?: string;
5699
+ aiResponse: AIResponseMessageMetadata;
5700
+ firstChunkAt: number;
5701
+ };
5702
+
5703
+ declare type PendingAIResponseStart = {
5704
+ channelUrl?: string;
5705
+ startedAt: number;
5706
+ };
5707
+
5708
+ declare type PendingMeasurement = {
5709
+ channelUrl?: string;
5710
+ messageType: PendingMessageType;
5711
+ stuckReason: PendingStuckReason;
5712
+ timer: TimeoutId | null;
5713
+ };
5714
+
5715
+ declare type PendingMessageType = 'text' | 'file_image' | 'file_video' | 'file_document' | 'file_audio';
5716
+
5717
+ declare type PendingStuckReason = 'pending_too_long' | 'upload_no_progress';
5718
+
5719
+ declare type PendingStuckTrackerOptions = Pick<TimedTrackerOptions, 'setTimeout' | 'clearTimeout'>;
5720
+
5038
5721
  declare type PickMessageProps<
5039
5722
  Union,
5040
5723
  T extends Union extends { messageType: infer MT } ? MT : never = Union extends { messageType: infer MT } ? MT : never,
@@ -5115,7 +5798,7 @@ export declare type PositionHorizontal = 'start' | 'end';
5115
5798
 
5116
5799
  export declare type PositionVertical = 'top' | 'bottom';
5117
5800
 
5118
- declare type PresentMethod = 'launcher_toggle' | 'direct_present';
5801
+ declare type PresentMethod = 'launcher_toggle' | 'direct_present' | 'embedded';
5119
5802
 
5120
5803
  declare type Props = {
5121
5804
  children: ReactNode;
@@ -5172,6 +5855,120 @@ declare type Props_2 = {
5172
5855
 
5173
5856
  declare type ReactOnlyIconName = 'expand' | 'collapse' | 'chevron-right' | 'attach' | 'close-filled' | 'actionbook' | 'function' | 'confluence' | 'zendesk' | 'salesforce' | 'sprinklr' | 'website' | 'snippet' | 'template' | 'show' | 'mute' | 'activity';
5174
5857
 
5858
+ declare type RecoveryMeasurement = {
5859
+ timer: TimeoutId | null;
5860
+ startedAt: number | null;
5861
+ triggerType: RecoveryTriggerType | null;
5862
+ inputEnabled: boolean;
5863
+ };
5864
+
5865
+ declare class RecoveryNotInteractiveTimeoutStatsTracker {
5866
+ private readonly callback: StatsAppendCallback;
5867
+ private readonly clock: StatsClock;
5868
+ private readonly setTimeoutFn: typeof setTimeout;
5869
+ private readonly clearTimeoutFn: typeof clearTimeout;
5870
+ private readonly measurements = new Map<string, RecoveryMeasurement>();
5871
+
5872
+ constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
5873
+ this.callback = callback;
5874
+ this.clock = getClock(options);
5875
+ this.setTimeoutFn = getSetTimeout(options);
5876
+ this.clearTimeoutFn = getClearTimeout(options);
5877
+ }
5878
+
5879
+ start(triggerType: RecoveryTriggerType, channelUrl?: string): void {
5880
+ const key = this.getKey(channelUrl);
5881
+ let measurement = this.measurements.get(key);
5882
+ if (measurement && measurement.startedAt !== null) return;
5883
+ if (measurement?.inputEnabled) return;
5884
+
5885
+ measurement = {
5886
+ startedAt: this.clock.now(),
5887
+ triggerType,
5888
+ inputEnabled: measurement?.inputEnabled ?? false,
5889
+ timer: this.setTimeoutFn(() => this.commitTimeout(key), 10_000),
5890
+ };
5891
+ this.measurements.set(key, measurement);
5892
+ }
5893
+
5894
+ setInputEnabled(enabled: boolean, channelUrl?: string): void {
5895
+ const key = this.getKey(channelUrl);
5896
+ let measurement = this.measurements.get(key);
5897
+
5898
+ if (!measurement) {
5899
+ if (!enabled) return;
5900
+ measurement = {
5901
+ startedAt: null,
5902
+ triggerType: null,
5903
+ inputEnabled: enabled,
5904
+ timer: null,
5905
+ };
5906
+ this.measurements.set(key, measurement);
5907
+ }
5908
+
5909
+ measurement.inputEnabled = enabled;
5910
+ if (enabled) {
5911
+ this.cancelMeasurement(measurement);
5912
+ }
5913
+ }
5914
+
5915
+ cancelPending(channelUrl: string): void {
5916
+ const key = this.getKey(channelUrl);
5917
+ const measurement = this.measurements.get(key);
5918
+ if (!measurement) return;
5919
+ this.cancelMeasurement(measurement);
5920
+ if (!measurement.inputEnabled) this.measurements.delete(key);
5921
+ }
5922
+
5923
+ clear(): void {
5924
+ this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5925
+ this.measurements.clear();
5926
+ }
5927
+
5928
+ clearChannel(channelUrl?: string): void {
5929
+ if (channelUrl === undefined) return;
5930
+ this.clearKey(this.getKey(channelUrl));
5931
+ }
5932
+
5933
+ private clearKey(key: string): void {
5934
+ const measurement = this.measurements.get(key);
5935
+ if (!measurement) return;
5936
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5937
+ this.measurements.delete(key);
5938
+ }
5939
+
5940
+ private commitTimeout(key: string): void {
5941
+ const measurement = this.measurements.get(key);
5942
+ if (!measurement || measurement.startedAt === null || measurement.triggerType === null) return;
5943
+ if (measurement.inputEnabled) {
5944
+ this.cancelMeasurement(measurement);
5945
+ return;
5946
+ }
5947
+ const duration = this.clock.now() - measurement.startedAt;
5948
+ const triggerType = measurement.triggerType;
5949
+ // Drop the entry so a subsequent trigger can record a fresh cycle.
5950
+ this.measurements.delete(key);
5951
+ append(this.callback, METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT, duration, {
5952
+ extra: {
5953
+ trigger_type: triggerType,
5954
+ },
5955
+ });
5956
+ }
5957
+
5958
+ private cancelMeasurement(measurement: RecoveryMeasurement): void {
5959
+ clearTimer(measurement.timer, this.clearTimeoutFn);
5960
+ measurement.timer = null;
5961
+ measurement.startedAt = null;
5962
+ measurement.triggerType = null;
5963
+ }
5964
+
5965
+ private getKey(channelUrl?: string): string {
5966
+ return channelUrl ?? '__default__';
5967
+ }
5968
+ }
5969
+
5970
+ declare type RecoveryTriggerType = 'foreground_resume' | 'ws_reconnected';
5971
+
5175
5972
  declare type ReleaseDisabledByValue =
5176
5973
  | 'unavailable'
5177
5974
  | 'form_active'
@@ -5182,6 +5979,39 @@ declare type ReleaseDisabledByValue =
5182
5979
  | 'reconnecting'
5183
5980
  | 'handoff_pending';
5184
5981
 
5982
+ declare class RenderedContentUnusableStatsTracker {
5983
+ private readonly callback: StatsAppendCallback;
5984
+ private readonly committed = new Set<string>();
5985
+
5986
+ constructor(callback: StatsAppendCallback) {
5987
+ this.callback = callback;
5988
+ }
5989
+
5990
+ commit(params: {
5991
+ messageId: string | number;
5992
+ contentKind: 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
5993
+ errorDomain: 'empty_render' | 'template_error' | 'form_error';
5994
+ error?: ErrorLike;
5995
+ }): void {
5996
+ const key = `${params.messageId}:${params.contentKind}:${params.errorDomain}`;
5997
+ if (this.committed.has(key)) return;
5998
+ evictOldestIfAtLimit(this.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
5999
+ this.committed.add(key);
6000
+
6001
+ append(this.callback, METRIC_KEY_RENDERED_CONTENT_UNUSABLE, params.errorDomain, {
6002
+ error: params.error ? { code: params.error.code, message: params.errorDomain } : undefined,
6003
+ extra: {
6004
+ content_kind: params.contentKind,
6005
+ message_id: String(params.messageId),
6006
+ },
6007
+ });
6008
+ }
6009
+
6010
+ clear(): void {
6011
+ this.committed.clear();
6012
+ }
6013
+ }
6014
+
5185
6015
  declare interface ResolvedHandlers extends AgentClientHandlers {
5186
6016
  onClickLink: (params: {
5187
6017
  url: string;
@@ -5223,6 +6053,15 @@ declare type SingleSelectField = {
5223
6053
 
5224
6054
  declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
5225
6055
 
6056
+ declare interface StatsClock {
6057
+ now: () => number;
6058
+ }
6059
+
6060
+ declare type StatsPayloadError = {
6061
+ code?: number;
6062
+ message?: string;
6063
+ };
6064
+
5226
6065
  /** Per-event override for the platform `announceStatus` wrappers — same shape as `AnnounceOptions`. */
5227
6066
  declare type StatusAnnouncementOptions = AnnounceOptions;
5228
6067
 
@@ -5276,6 +6115,251 @@ declare enum StewardTaskStatus {
5276
6115
  CANCELED = 'CANCELED',
5277
6116
  }
5278
6117
 
6118
+ declare class StreamStalledStatsTracker {
6119
+ private readonly callback: StatsAppendCallback;
6120
+ private readonly clock: StatsClock;
6121
+ private readonly setTimeoutFn: typeof setTimeout;
6122
+ private readonly clearTimeoutFn: typeof clearTimeout;
6123
+ private readonly streams = new Map<number, StreamState>();
6124
+ private readonly emittedMessageChannels = new Map<number, string | undefined>();
6125
+
6126
+ constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6127
+ this.callback = callback;
6128
+ this.clock = getClock(options);
6129
+ this.setTimeoutFn = getSetTimeout(options);
6130
+ this.clearTimeoutFn = getClearTimeout(options);
6131
+ }
6132
+
6133
+ markRawProgress(params: {
6134
+ messageId: number;
6135
+ channelUrl?: string;
6136
+ textLength: number;
6137
+ aiResponse: AIResponseMessageMetadata;
6138
+ }): void {
6139
+ if (!params.aiResponse.isStreaming) {
6140
+ this.completeDelivery(params.messageId, params.textLength);
6141
+ return;
6142
+ }
6143
+
6144
+ if (this.emittedMessageChannels.has(params.messageId)) return;
6145
+
6146
+ const stream = this.getOrCreate(params.messageId, params.channelUrl);
6147
+
6148
+ if (params.textLength > stream.lastRawLength) {
6149
+ stream.chunksReceived += 1;
6150
+ stream.lastRawLength = params.textLength;
6151
+ stream.lastRawAt = this.clock.now();
6152
+
6153
+ clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6154
+ stream.deliveryTimer = null;
6155
+ this.scheduleDeliveryStallCheck(params.messageId, stream);
6156
+ } else {
6157
+ this.scheduleDeliveryStallCheck(params.messageId, stream);
6158
+ }
6159
+
6160
+ if (stream.lastRenderedLength < stream.lastRawLength && stream.renderTimer === null) {
6161
+ stream.lastRenderAt = this.clock.now();
6162
+ this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6163
+ }
6164
+ }
6165
+
6166
+ markRenderedProgress(params: { messageId: number; displayedLength: number }): void {
6167
+ const stream = this.streams.get(params.messageId);
6168
+ if (!stream || params.displayedLength <= stream.lastRenderedLength) return;
6169
+
6170
+ stream.lastRenderedLength = params.displayedLength;
6171
+ stream.lastRenderAt = this.clock.now();
6172
+
6173
+ if (stream.lastRenderedLength >= stream.lastRawLength) {
6174
+ if (stream.deliveryTimer === null) {
6175
+ this.clearStream(params.messageId, stream);
6176
+ return;
6177
+ }
6178
+
6179
+ clearTimer(stream.renderTimer, this.clearTimeoutFn);
6180
+ stream.renderTimer = null;
6181
+ return;
6182
+ }
6183
+
6184
+ this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6185
+ }
6186
+
6187
+ hasPendingStream(messageId: number): boolean {
6188
+ return this.streams.has(messageId);
6189
+ }
6190
+
6191
+ complete(messageId: number): void {
6192
+ const stream = this.streams.get(messageId);
6193
+ if (stream) this.clearStream(messageId, stream);
6194
+ }
6195
+
6196
+ // Final stream update: no more raw chunks are coming, so stop delivery-stall
6197
+ // tracking. The animation can still be catching up after streaming ends
6198
+ // (useStreamingText keeps animating while displayedLength < text.length), so
6199
+ // keep the render-stall watch alive until rendered progress reaches the last
6200
+ // raw length — otherwise a renderer that freezes mid-catch-up never emits a
6201
+ // 'render' stall.
6202
+ private completeDelivery(messageId: number, finalRawLength: number): void {
6203
+ const stream = this.streams.get(messageId);
6204
+ if (!stream) return;
6205
+
6206
+ // The final stream:false payload can carry text beyond the last streamed
6207
+ // chunk; fold it into the raw length first so the render watch targets the
6208
+ // full text the animation still has to reveal (otherwise the stream could be
6209
+ // cleared while a freeze on that trailing text would go unreported).
6210
+ if (finalRawLength > stream.lastRawLength) {
6211
+ stream.lastRawLength = finalRawLength;
6212
+ stream.lastRawAt = this.clock.now();
6213
+ }
6214
+
6215
+ clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6216
+ stream.deliveryTimer = null;
6217
+
6218
+ if (stream.lastRenderedLength >= stream.lastRawLength) {
6219
+ this.clearStream(messageId, stream);
6220
+ return;
6221
+ }
6222
+
6223
+ if (stream.renderTimer === null) {
6224
+ stream.lastRenderAt = this.clock.now();
6225
+ this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6226
+ }
6227
+ }
6228
+
6229
+ clearMessage(messageId: number): void {
6230
+ const stream = this.streams.get(messageId);
6231
+ if (stream) this.clearStream(messageId, stream);
6232
+ this.emittedMessageChannels.delete(messageId);
6233
+ }
6234
+
6235
+ clear(): void {
6236
+ this.streams.forEach((stream) => {
6237
+ clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6238
+ clearTimer(stream.renderTimer, this.clearTimeoutFn);
6239
+ });
6240
+ this.streams.clear();
6241
+ this.emittedMessageChannels.clear();
6242
+ }
6243
+
6244
+ clearChannel(channelUrl?: string): void {
6245
+ if (!channelUrl) return;
6246
+ this.streams.forEach((stream, messageId) => {
6247
+ if (stream.channelUrl !== channelUrl && stream.channelUrl !== undefined) return;
6248
+ this.clearStream(messageId, stream);
6249
+ });
6250
+ this.emittedMessageChannels.forEach((emittedChannelUrl, messageId) => {
6251
+ if (emittedChannelUrl === channelUrl || emittedChannelUrl === undefined)
6252
+ this.emittedMessageChannels.delete(messageId);
6253
+ });
6254
+ }
6255
+
6256
+ private detectDelivery(messageId: number): void {
6257
+ const stream = this.streams.get(messageId);
6258
+ if (!stream) return;
6259
+ stream.deliveryTimer = null;
6260
+ // Mutex: cancel any in-flight render stall check so delivery wins this turn.
6261
+ clearTimer(stream.renderTimer, this.clearTimeoutFn);
6262
+ stream.renderTimer = null;
6263
+ this.emit(messageId, stream, 'delivery');
6264
+ }
6265
+
6266
+ private detectRender(messageId: number): void {
6267
+ const stream = this.streams.get(messageId);
6268
+ if (!stream) return;
6269
+ stream.renderTimer = null;
6270
+ if (stream.lastRenderedLength >= stream.lastRawLength) return;
6271
+
6272
+ const elapsedSinceRender = this.clock.now() - stream.lastRenderAt;
6273
+ if (elapsedSinceRender < STREAM_RENDER_STALL_THRESHOLD_MS) {
6274
+ this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6275
+ return;
6276
+ }
6277
+
6278
+ // Mutex rule: when raw progress has been silent for at least the render threshold,
6279
+ // delivery is the root cause and will win. Defer the render emit until delivery has
6280
+ // had a chance to fire so we don't surface render as the symptom.
6281
+ const elapsedSinceRaw = this.clock.now() - stream.lastRawAt;
6282
+ if (elapsedSinceRaw >= STREAM_RENDER_STALL_THRESHOLD_MS && stream.deliveryTimer !== null) {
6283
+ const untilDelivery = STREAM_DELIVERY_STALL_THRESHOLD_MS - elapsedSinceRaw;
6284
+ if (untilDelivery > 0) {
6285
+ this.scheduleRenderStallCheck(messageId, stream, untilDelivery);
6286
+ return;
6287
+ }
6288
+ }
6289
+
6290
+ this.emit(messageId, stream, 'render');
6291
+ }
6292
+
6293
+ private scheduleRenderStallCheck(messageId: number, stream: StreamState, delayMs: number): void {
6294
+ if (stream.renderTimer !== null) return;
6295
+ stream.renderTimer = this.setTimeoutFn(() => this.detectRender(messageId), delayMs);
6296
+ }
6297
+
6298
+ private scheduleDeliveryStallCheck(messageId: number, stream: StreamState): void {
6299
+ if (stream.deliveryTimer !== null) return;
6300
+ stream.deliveryTimer = this.setTimeoutFn(() => this.detectDelivery(messageId), STREAM_DELIVERY_STALL_THRESHOLD_MS);
6301
+ }
6302
+
6303
+ private emit(messageId: number, stream: StreamState, stallKind: StreamStallKind): void {
6304
+ if (this.emittedMessageChannels.has(messageId)) return;
6305
+
6306
+ const stallStartedAt = stallKind === 'delivery' ? stream.lastRawAt : stream.lastRenderAt;
6307
+ append(this.callback, METRIC_KEY_STREAM_STALLED, this.clock.now() - stallStartedAt, {
6308
+ extra: {
6309
+ message_id: String(messageId),
6310
+ stall_kind: stallKind,
6311
+ },
6312
+ });
6313
+ this.rememberEmitted(messageId, stream.channelUrl);
6314
+ this.clearStream(messageId, stream);
6315
+ }
6316
+
6317
+ private getOrCreate(messageId: number, channelUrl: string | undefined): StreamState {
6318
+ const existing = this.streams.get(messageId);
6319
+ if (existing) return existing;
6320
+
6321
+ const now = this.clock.now();
6322
+ const stream: StreamState = {
6323
+ channelUrl,
6324
+ chunksReceived: 0,
6325
+ lastRawAt: now,
6326
+ lastRenderAt: now,
6327
+ lastRawLength: 0,
6328
+ lastRenderedLength: 0,
6329
+ deliveryTimer: null,
6330
+ renderTimer: null,
6331
+ };
6332
+ this.streams.set(messageId, stream);
6333
+ return stream;
6334
+ }
6335
+
6336
+ private rememberEmitted(messageId: number, channelUrl: string | undefined): void {
6337
+ if (!this.emittedMessageChannels.has(messageId)) {
6338
+ evictOldestIfAtLimit(this.emittedMessageChannels, STREAM_STALLED_EMITTED_LIMIT);
6339
+ }
6340
+ this.emittedMessageChannels.set(messageId, channelUrl);
6341
+ }
6342
+
6343
+ private clearStream(messageId: number, stream: StreamState): void {
6344
+ clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6345
+ clearTimer(stream.renderTimer, this.clearTimeoutFn);
6346
+ this.streams.delete(messageId);
6347
+ }
6348
+ }
6349
+
6350
+ declare type StreamStallKind = 'delivery' | 'render';
6351
+
6352
+ declare type StreamState = {
6353
+ channelUrl?: string;
6354
+ chunksReceived: number;
6355
+ lastRawAt: number;
6356
+ lastRenderAt: number;
6357
+ lastRawLength: number;
6358
+ lastRenderedLength: number;
6359
+ deliveryTimer: TimeoutId | null;
6360
+ renderTimer: TimeoutId | null;
6361
+ };
6362
+
5279
6363
  /**
5280
6364
  * StringSet type for React components
5281
6365
  * Uses SNAKE_CASE keys for backward compatibility
@@ -5550,11 +6634,248 @@ declare type TextField = {
5550
6634
  };
5551
6635
  };
5552
6636
 
6637
+ declare type TimedTrackerOptions = TrackerOptions & {
6638
+ setTimeout?: typeof setTimeout;
6639
+ clearTimeout?: typeof clearTimeout;
6640
+ };
6641
+
6642
+ declare type TimeoutId = ReturnType<typeof setTimeout>;
6643
+
5553
6644
  declare interface TimerData {
5554
6645
  startTime: number | null;
5555
6646
  endTime: number | null;
5556
6647
  }
5557
6648
 
6649
+ declare type TrackerOptions = {
6650
+ clock?: StatsClock;
6651
+ };
6652
+
6653
+ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6654
+ private readonly callback: StatsAppendCallback;
6655
+ private readonly clock: StatsClock;
6656
+ private readonly setTimeoutFn: typeof setTimeout;
6657
+ private readonly clearTimeoutFn: typeof clearTimeout;
6658
+ private readonly turns = new Map<string, TypingTurn>();
6659
+ private readonly pendingAIResponseStartsByUserMessageId = new Map<number, PendingAIResponseStart>();
6660
+
6661
+ constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6662
+ this.callback = callback;
6663
+ this.clock = getClock(options);
6664
+ this.setTimeoutFn = getSetTimeout(options);
6665
+ this.clearTimeoutFn = getClearTimeout(options);
6666
+ }
6667
+
6668
+ startTurn(params: { requestId: string; channelUrl?: string }): void {
6669
+ this.cancelTurn(params.requestId);
6670
+ const turn: TypingTurn = {
6671
+ requestId: params.requestId,
6672
+ channelUrl: params.channelUrl,
6673
+ beforeTpstStartedAt: this.clock.now(),
6674
+ afterTpenStartedAt: null,
6675
+ timer: null,
6676
+ };
6677
+ turn.timer = this.setTimeoutFn(() => this.commitTimeout(turn.requestId), TYPING_COLLECTION_TIMEOUT_MS);
6678
+ this.turns.set(params.requestId, turn);
6679
+ }
6680
+
6681
+ markUserMessageAck(params: { requestId: string; messageId: number }): void {
6682
+ const turn = this.turns.get(params.requestId);
6683
+ if (!turn) return;
6684
+ turn.userMessageId = params.messageId;
6685
+
6686
+ const pending = this.pendingAIResponseStartsByUserMessageId.get(params.messageId);
6687
+ if (!pending) return;
6688
+ this.pendingAIResponseStartsByUserMessageId.delete(params.messageId);
6689
+ this.applyAIResponseStarted(
6690
+ { channelUrl: pending.channelUrl, matchesUserMessage: true, userMessageId: params.messageId },
6691
+ pending.startedAt,
6692
+ );
6693
+ }
6694
+
6695
+ replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6696
+ if (currentRequestId === nextRequestId) return;
6697
+
6698
+ const turn = this.turns.get(currentRequestId);
6699
+ if (!turn) return;
6700
+
6701
+ this.turns.delete(currentRequestId);
6702
+ turn.requestId = nextRequestId;
6703
+ this.turns.set(nextRequestId, turn);
6704
+ }
6705
+
6706
+ cancelTurn(requestId: string): void {
6707
+ this.cleanupTurn(requestId);
6708
+ }
6709
+
6710
+ markTypingStarted(channelUrl?: string): void {
6711
+ const now = this.clock.now();
6712
+ this.turns.forEach((turn) => {
6713
+ if (!matchesChannel(turn, channelUrl)) return;
6714
+ this.recordAfterTpen(turn, now);
6715
+ this.recordBeforeTpst(turn, now);
6716
+ });
6717
+ }
6718
+
6719
+ markTypingEnded(channelUrl?: string): void {
6720
+ const now = this.clock.now();
6721
+ this.turns.forEach((turn) => {
6722
+ if (!matchesChannel(turn, channelUrl)) return;
6723
+ this.recordBeforeTpst(turn, now);
6724
+ turn.afterTpenStartedAt = now;
6725
+ });
6726
+ }
6727
+
6728
+ markAIResponseStarted(params: { channelUrl?: string; userMessageId?: number } = {}): void {
6729
+ const channelUrl = params.channelUrl;
6730
+ const userMessageId = params.userMessageId;
6731
+ const matchesUserMessage = userMessageId !== undefined;
6732
+ const now = this.clock.now();
6733
+ const matched = this.applyAIResponseStarted({ channelUrl, matchesUserMessage, userMessageId }, now);
6734
+ if (matched || !matchesUserMessage || userMessageId === undefined || !this.hasUnackedTurn(channelUrl)) return;
6735
+
6736
+ if (!this.pendingAIResponseStartsByUserMessageId.has(userMessageId)) {
6737
+ evictOldestIfAtLimit(this.pendingAIResponseStartsByUserMessageId, 100);
6738
+ this.pendingAIResponseStartsByUserMessageId.set(userMessageId, { channelUrl, startedAt: now });
6739
+ }
6740
+ }
6741
+
6742
+ private applyAIResponseStarted(
6743
+ params: { channelUrl?: string; matchesUserMessage: boolean; userMessageId?: number },
6744
+ startedAt: number,
6745
+ ): boolean {
6746
+ if (!params.matchesUserMessage) {
6747
+ const match = this.findSingleTurn(params.channelUrl);
6748
+ if (!match) return false;
6749
+ this.completeTurn(match.requestId, match.turn, startedAt);
6750
+ return true;
6751
+ }
6752
+
6753
+ let matched = false;
6754
+ this.turns.forEach((turn, requestId) => {
6755
+ if (!matchesChannel(turn, params.channelUrl)) return;
6756
+ if (params.matchesUserMessage && turn.userMessageId !== params.userMessageId) return;
6757
+ matched = true;
6758
+ this.completeTurn(requestId, turn, startedAt);
6759
+ });
6760
+ return matched;
6761
+ }
6762
+
6763
+ private findSingleTurn(channelUrl?: string): TypingTurnEntry | undefined {
6764
+ let match: TypingTurnEntry | undefined;
6765
+ let matchedCount = 0;
6766
+ this.turns.forEach((turn, requestId) => {
6767
+ if (!matchesChannel(turn, channelUrl)) return;
6768
+ matchedCount += 1;
6769
+ if (matchedCount > 1) return;
6770
+ match = { requestId, turn };
6771
+ });
6772
+ return matchedCount === 1 ? match : undefined;
6773
+ }
6774
+
6775
+ private completeTurn(requestId: string, turn: TypingTurn, startedAt: number): void {
6776
+ this.recordBeforeTpst(turn, startedAt);
6777
+ this.recordAfterTpen(turn, startedAt);
6778
+ this.commitIfAbsent(requestId, turn);
6779
+ }
6780
+
6781
+ private hasUnackedTurn(channelUrl?: string): boolean {
6782
+ return hasUnackedChannelTurn(this.turns.values(), channelUrl);
6783
+ }
6784
+
6785
+ clear(): void {
6786
+ this.turns.forEach((turn) => {
6787
+ clearTimer(turn.timer, this.clearTimeoutFn);
6788
+ });
6789
+ this.turns.clear();
6790
+ this.pendingAIResponseStartsByUserMessageId.clear();
6791
+ }
6792
+
6793
+ clearChannel(channelUrl?: string): void {
6794
+ if (!channelUrl) return;
6795
+ this.turns.forEach((turn, requestId) => {
6796
+ if (turn.channelUrl !== channelUrl) return;
6797
+ this.cleanupTurn(requestId);
6798
+ });
6799
+ this.pendingAIResponseStartsByUserMessageId.forEach((pending, userMessageId) => {
6800
+ if (pending.channelUrl === channelUrl) this.pendingAIResponseStartsByUserMessageId.delete(userMessageId);
6801
+ });
6802
+ }
6803
+
6804
+ private commitTimeout(requestId: string): void {
6805
+ const turn = this.turns.get(requestId);
6806
+ if (!turn) return;
6807
+ const now = this.clock.now();
6808
+ this.recordBeforeTpst(turn, now);
6809
+ this.recordAfterTpen(turn, now);
6810
+ this.commitIfAbsent(requestId, turn);
6811
+ }
6812
+
6813
+ private recordBeforeTpst(
6814
+ turn: { beforeTpstStartedAt: number | null; beforeTpstAbsentMs?: number },
6815
+ endedAt: number,
6816
+ ): void {
6817
+ if (turn.beforeTpstStartedAt === null) return;
6818
+ const duration = endedAt - turn.beforeTpstStartedAt;
6819
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.beforeTpstAbsentMs = duration;
6820
+ turn.beforeTpstStartedAt = null;
6821
+ }
6822
+
6823
+ private recordAfterTpen(
6824
+ turn: { afterTpenStartedAt: number | null; afterTpenAbsentMs?: number },
6825
+ endedAt: number,
6826
+ ): void {
6827
+ if (turn.afterTpenStartedAt === null) return;
6828
+ const duration = endedAt - turn.afterTpenStartedAt;
6829
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.afterTpenAbsentMs = duration;
6830
+ turn.afterTpenStartedAt = null;
6831
+ }
6832
+
6833
+ private commitIfAbsent(requestId: string, turn: { beforeTpstAbsentMs?: number; afterTpenAbsentMs?: number }): void {
6834
+ const beforeTpstAbsentMs = turn.beforeTpstAbsentMs;
6835
+ const afterTpenAbsentMs = turn.afterTpenAbsentMs;
6836
+ if (beforeTpstAbsentMs === undefined && afterTpenAbsentMs === undefined) {
6837
+ this.cleanupTurn(requestId);
6838
+ return;
6839
+ }
6840
+
6841
+ append(
6842
+ this.callback,
6843
+ METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT,
6844
+ (beforeTpstAbsentMs ?? 0) + (afterTpenAbsentMs ?? 0),
6845
+ {
6846
+ extra: {
6847
+ ...(beforeTpstAbsentMs !== undefined && { before_tpst_absent_ms: beforeTpstAbsentMs }),
6848
+ ...(afterTpenAbsentMs !== undefined && { after_tpen_absent_ms: afterTpenAbsentMs }),
6849
+ },
6850
+ },
6851
+ );
6852
+ this.cleanupTurn(requestId);
6853
+ }
6854
+
6855
+ private cleanupTurn(requestId: string): void {
6856
+ const turn = this.turns.get(requestId);
6857
+ if (!turn) return;
6858
+ clearTimer(turn.timer, this.clearTimeoutFn);
6859
+ this.turns.delete(requestId);
6860
+ }
6861
+ }
6862
+
6863
+ declare type TypingTurn = {
6864
+ requestId: string;
6865
+ channelUrl?: string;
6866
+ userMessageId?: number;
6867
+ beforeTpstStartedAt: number | null;
6868
+ afterTpenStartedAt: number | null;
6869
+ beforeTpstAbsentMs?: number;
6870
+ afterTpenAbsentMs?: number;
6871
+ timer: TimeoutId | null;
6872
+ };
6873
+
6874
+ declare type TypingTurnEntry = {
6875
+ requestId: string;
6876
+ turn: TypingTurn;
6877
+ };
6878
+
5558
6879
  declare interface TypographyShape {
5559
6880
  h1: TypographyVariant;
5560
6881
  h2: TypographyVariant;
@@ -5630,6 +6951,189 @@ export declare type UserActionStatus =
5630
6951
 
5631
6952
  export declare const useRefreshActiveChannel: (updater: () => Promise<void>) => () => Promise<void>;
5632
6953
 
6954
+ declare class UserPerceivedResponseTimeStatsTracker {
6955
+ private readonly callback: StatsAppendCallback;
6956
+ private readonly clock: StatsClock;
6957
+ private readonly random: () => number;
6958
+ private readonly sampledByRequestId = new Map<string, UserTurn>();
6959
+ private readonly sampledByAIMessageId = new Map<number, UserTurn>();
6960
+ private readonly pendingAIResponseByUserMessageId = new Map<number, PendingAIResponseRaw>();
6961
+ private collectedCount = 0;
6962
+
6963
+ constructor(callback: StatsAppendCallback, options?: TrackerOptions & { random?: () => number }) {
6964
+ this.callback = callback;
6965
+ this.clock = getClock(options);
6966
+ this.random = options?.random ?? Math.random;
6967
+ }
6968
+
6969
+ startTurn(params: { requestId: string; channelUrl?: string }): void {
6970
+ if (this.collectedCount >= 10) return;
6971
+ if (this.random() >= 0.15) return;
6972
+ this.collectedCount += 1;
6973
+ this.sampledByRequestId.set(params.requestId, {
6974
+ requestId: params.requestId,
6975
+ channelUrl: params.channelUrl,
6976
+ startedAt: this.clock.now(),
6977
+ });
6978
+ }
6979
+
6980
+ markUserMessageAck(params: { requestId: string; messageId: number }): void {
6981
+ const turn = this.sampledByRequestId.get(params.requestId);
6982
+ if (!turn) return;
6983
+
6984
+ turn.ackAt = this.clock.now();
6985
+ turn.userMessageId = params.messageId;
6986
+
6987
+ const pending = this.pendingAIResponseByUserMessageId.get(params.messageId);
6988
+ if (!pending) return;
6989
+ this.pendingAIResponseByUserMessageId.delete(params.messageId);
6990
+ this.applyAIResponseRaw(turn, pending);
6991
+ }
6992
+
6993
+ replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6994
+ if (currentRequestId === nextRequestId) return;
6995
+
6996
+ const turn = this.sampledByRequestId.get(currentRequestId);
6997
+ if (!turn) return;
6998
+
6999
+ this.sampledByRequestId.delete(currentRequestId);
7000
+ turn.requestId = nextRequestId;
7001
+ this.sampledByRequestId.set(nextRequestId, turn);
7002
+ }
7003
+
7004
+ cancelTurn(requestId: string): void {
7005
+ const turn = this.sampledByRequestId.get(requestId);
7006
+ if (!turn) return;
7007
+ this.deleteTurn(turn);
7008
+ // Return the unused sample slot so cancelled turns don't burn the per-session cap.
7009
+ if (this.collectedCount > 0) this.collectedCount -= 1;
7010
+ }
7011
+
7012
+ markAIResponseRaw(params: { messageId: number; channelUrl?: string; aiResponse: AIResponseMessageMetadata }): void {
7013
+ const firstChunkAt = this.clock.now();
7014
+ const userMessageId = params.aiResponse.userMessageId;
7015
+ if (userMessageId === undefined && this.hasUnackedTurn(params.channelUrl)) return;
7016
+
7017
+ const turn = this.findPendingTurn(params.channelUrl, userMessageId);
7018
+ if (turn) {
7019
+ this.applyAIResponseRaw(turn, { ...params, firstChunkAt });
7020
+ return;
7021
+ }
7022
+
7023
+ if (userMessageId === undefined || !this.hasUnackedTurn(params.channelUrl)) return;
7024
+ this.bufferAIResponseRaw(
7025
+ {
7026
+ messageId: params.messageId,
7027
+ ...(params.channelUrl !== undefined && { channelUrl: params.channelUrl }),
7028
+ aiResponse: params.aiResponse,
7029
+ firstChunkAt,
7030
+ },
7031
+ userMessageId,
7032
+ );
7033
+ }
7034
+
7035
+ markAIResponseVisible(aiMessageId: number): boolean {
7036
+ const turn = this.sampledByAIMessageId.get(aiMessageId);
7037
+ if (!turn || turn.ackAt === undefined || turn.firstChunkAt === undefined || turn.userMessageId === undefined)
7038
+ return false;
7039
+
7040
+ const renderedAt = this.clock.now();
7041
+ append(this.callback, METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME, renderedAt - turn.startedAt, {
7042
+ extra: {
7043
+ ack_to_first_chunk_ms: turn.firstChunkAt - turn.ackAt,
7044
+ first_chunk_to_render_ms: renderedAt - turn.firstChunkAt,
7045
+ is_streaming: Boolean(turn.isStreaming),
7046
+ send_to_ack_ms: turn.ackAt - turn.startedAt,
7047
+ },
7048
+ });
7049
+
7050
+ this.deleteTurn(turn);
7051
+ return true;
7052
+ }
7053
+
7054
+ hasPendingAIResponse(aiMessageId: number): boolean {
7055
+ return this.sampledByAIMessageId.has(aiMessageId);
7056
+ }
7057
+
7058
+ clearMessage(aiMessageId: number): void {
7059
+ this.pendingAIResponseByUserMessageId.forEach((pending, userMessageId) => {
7060
+ if (pending.messageId === aiMessageId) this.pendingAIResponseByUserMessageId.delete(userMessageId);
7061
+ });
7062
+
7063
+ const turn = this.sampledByAIMessageId.get(aiMessageId);
7064
+ if (!turn) return;
7065
+
7066
+ this.deleteTurn(turn);
7067
+ if (this.collectedCount > 0) this.collectedCount -= 1;
7068
+ }
7069
+
7070
+ clearChannel(channelUrl?: string): void {
7071
+ if (!channelUrl) return;
7072
+ this.sampledByRequestId.forEach((turn) => {
7073
+ if (turn.channelUrl !== channelUrl) return;
7074
+ this.deleteTurn(turn);
7075
+ if (this.collectedCount > 0) this.collectedCount -= 1;
7076
+ });
7077
+ this.pendingAIResponseByUserMessageId.forEach((pending, userMessageId) => {
7078
+ if (pending.channelUrl === channelUrl) this.pendingAIResponseByUserMessageId.delete(userMessageId);
7079
+ });
7080
+ }
7081
+
7082
+ clear(): void {
7083
+ this.sampledByRequestId.clear();
7084
+ this.sampledByAIMessageId.clear();
7085
+ this.pendingAIResponseByUserMessageId.clear();
7086
+ this.collectedCount = 0;
7087
+ }
7088
+
7089
+ private findPendingTurn(channelUrl?: string, userMessageId?: number): UserTurn | undefined {
7090
+ if (userMessageId !== undefined) {
7091
+ for (const turn of this.sampledByRequestId.values()) {
7092
+ if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7093
+ if (!matchesChannel(turn, channelUrl)) continue;
7094
+ if (turn.userMessageId === userMessageId) return turn;
7095
+ }
7096
+ return undefined;
7097
+ }
7098
+
7099
+ let match: UserTurn | undefined;
7100
+ for (const turn of this.sampledByRequestId.values()) {
7101
+ if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7102
+ if (!matchesChannel(turn, channelUrl)) continue;
7103
+ if (match) return undefined;
7104
+ match = turn;
7105
+ }
7106
+ return match;
7107
+ }
7108
+
7109
+ private applyAIResponseRaw(turn: UserTurn, params: PendingAIResponseRaw): void {
7110
+ if (turn.firstChunkAt !== undefined) return;
7111
+
7112
+ turn.firstChunkAt = turn.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, turn.ackAt);
7113
+ turn.aiMessageId = params.messageId;
7114
+ turn.channelUrl = turn.channelUrl ?? params.channelUrl;
7115
+ turn.isStreaming = params.aiResponse.isStreaming;
7116
+ this.sampledByAIMessageId.set(params.messageId, turn);
7117
+ }
7118
+
7119
+ private bufferAIResponseRaw(params: PendingAIResponseRaw, userMessageId: number): void {
7120
+ if (!this.pendingAIResponseByUserMessageId.has(userMessageId)) {
7121
+ evictOldestIfAtLimit(this.pendingAIResponseByUserMessageId, 100);
7122
+ this.pendingAIResponseByUserMessageId.set(userMessageId, params);
7123
+ }
7124
+ }
7125
+
7126
+ private hasUnackedTurn(channelUrl?: string): boolean {
7127
+ return hasUnackedChannelTurn(this.sampledByRequestId.values(), channelUrl);
7128
+ }
7129
+
7130
+ private deleteTurn(turn: UserTurn): void {
7131
+ this.sampledByRequestId.delete(turn.requestId);
7132
+ if (turn.aiMessageId !== undefined) this.sampledByAIMessageId.delete(turn.aiMessageId);
7133
+ if (turn.userMessageId !== undefined) this.pendingAIResponseByUserMessageId.delete(turn.userMessageId);
7134
+ }
7135
+ }
7136
+
5633
7137
  /**
5634
7138
  * User session containing authentication credentials.
5635
7139
  */
@@ -5647,6 +7151,17 @@ declare interface UserSessionInfo {
5647
7151
  sessionHandler: AIAgentSessionHandler;
5648
7152
  }
5649
7153
 
7154
+ declare type UserTurn = {
7155
+ requestId: string;
7156
+ channelUrl?: string;
7157
+ startedAt: number;
7158
+ ackAt?: number;
7159
+ firstChunkAt?: number;
7160
+ userMessageId?: number;
7161
+ aiMessageId?: number;
7162
+ isStreaming?: boolean;
7163
+ };
7164
+
5650
7165
  export { }
5651
7166
 
5652
7167