@sendbird/ai-agent-messenger-react 1.35.1 → 1.37.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/dist/index.d.ts CHANGED
@@ -4,7 +4,6 @@ import type { AIAgentModule } from '@sendbird/chat/aiAgent';
4
4
  import { AriaRole } from 'react';
5
5
  import type { BaseChannelCollectionEventHandler } from '@sendbird/chat/groupChannel';
6
6
  import type { BaseMessage } from '@sendbird/chat/message';
7
- import type { CollectionEventSource } from '@sendbird/chat';
8
7
  import { ComponentType } from 'react';
9
8
  import { Context } from 'react';
10
9
  import type { Conversation as Conversation_2 } from '@sendbird/chat/aiAgent';
@@ -292,29 +291,28 @@ declare abstract class AIAgentBaseStats {
292
291
  protected committed: boolean = false;
293
292
  protected commitCallback: StatsAppendCallback | null = null;
294
293
  protected conversationId: number | null = null;
295
- protected channelUrl: string | null = null;
296
294
  protected errorCode: number | null = null;
297
295
  protected errorDescription: string | null = null;
298
296
  protected extraData: Record<string, unknown> = {};
299
297
 
298
+ protected abstract buildPayload(): AIAgentStatPayload | null;
299
+ protected abstract getMetricKey(): string;
300
+
300
301
  setCommitCallback(callback: StatsAppendCallback): this {
301
302
  this.commitCallback = callback;
302
303
  return this;
303
304
  }
304
305
 
305
- setConversationId(id: number): this {
306
- this.conversationId = id;
306
+ setConversationId(id?: number): this {
307
+ if (id) this.conversationId = id;
307
308
  return this;
308
309
  }
309
310
 
310
- setChannelUrl(url: string): this {
311
- this.setExtra('channel_url', url);
312
- return this;
313
- }
311
+ setError(error: unknown | null | undefined): this {
312
+ if (error === null || error === undefined) return this;
314
313
 
315
- setError(code: number | undefined, description: string): this {
316
- this.errorCode = code ?? DEFAULT_ERROR_CODE;
317
- this.errorDescription = description;
314
+ this.errorCode = getErrorCode(error) ?? DEFAULT_ERROR_CODE;
315
+ this.errorDescription = getErrorDescription(error);
318
316
  return this;
319
317
  }
320
318
 
@@ -331,7 +329,7 @@ declare abstract class AIAgentBaseStats {
331
329
  const existing = this.timers.get(key);
332
330
  if (existing?.startTime !== null && existing?.startTime !== undefined) return this;
333
331
 
334
- this.timers.set(key, { startTime: defaultStatsClock.now(), endTime: null });
332
+ this.timers.set(key, { startTime: performance.now(), endTime: null });
335
333
  return this;
336
334
  }
337
335
 
@@ -341,22 +339,51 @@ declare abstract class AIAgentBaseStats {
341
339
  const existing = this.timers.get(key);
342
340
  if (!existing || existing.startTime === null || existing.endTime !== null) return this;
343
341
 
344
- this.timers.set(key, { ...existing, endTime: defaultStatsClock.now() });
342
+ this.timers.set(key, { ...existing, endTime: performance.now() });
345
343
  return this;
346
344
  }
347
345
 
348
- protected getDuration(key: string): number | null {
346
+ getTimer(key: string): TimerReading {
349
347
  const timer = this.timers.get(key);
350
- if (!timer || timer.startTime === null || timer.endTime === null) return null;
351
- return timer.endTime - timer.startTime;
348
+ if (!timer || timer.startTime === null) return { startAt: null, endedAt: null, duration: null };
349
+ return {
350
+ startAt: timer.startTime,
351
+ endedAt: timer.endTime,
352
+ duration: timer.endTime === null ? null : timer.endTime - timer.startTime,
353
+ };
354
+ }
355
+
356
+ protected createPayload(value: string | number, params?: StatsPayloadParams): AIAgentStatPayload {
357
+ const payload: AIAgentStatPayload = {
358
+ key: this.getMetricKey(),
359
+ value: String(value),
360
+ };
361
+
362
+ const conversationId = params?.conversationId ?? this.conversationId ?? undefined;
363
+ if (conversationId !== undefined) payload.conversation_id = conversationId;
364
+
365
+ if (this.errorCode !== null) {
366
+ payload.error_code = this.errorCode;
367
+ payload.error_description = this.errorDescription ?? '';
368
+ }
369
+
370
+ const extra = dropNullish({
371
+ ...this.extraData,
372
+ ...params?.extra,
373
+ });
374
+ if (Object.keys(extra).length > 0) payload.extra = extra;
375
+
376
+ return payload;
352
377
  }
353
378
 
354
379
  isCommitted(): boolean {
355
380
  return this.committed;
356
381
  }
357
382
 
358
- protected abstract buildPayload(): AIAgentStatPayload | null;
359
- protected abstract getMetricKey(): string;
383
+ /** Consume without emitting: the metric counts as committed for once-per-session gating. */
384
+ discard(): void {
385
+ this.committed = true;
386
+ }
360
387
 
361
388
  commit(): boolean {
362
389
  if (this.committed || !this.commitCallback) {
@@ -364,14 +391,10 @@ declare abstract class AIAgentBaseStats {
364
391
  }
365
392
 
366
393
  const payload = this.buildPayload();
367
-
368
- if (!payload) {
369
- return false;
370
- }
394
+ if (!payload) return false;
371
395
 
372
396
  this.committed = true;
373
- const result = this.commitCallback(AI_AGENT_STAT_TYPE, payload);
374
- return result;
397
+ return this.commitCallback(AI_AGENT_STAT_TYPE, payload);
375
398
  }
376
399
  }
377
400
 
@@ -587,7 +610,6 @@ declare interface AIAgentConversationContextValue {
587
610
  messages: ChatSDKBaseMessage[];
588
611
  newMessages: ChatSDKBaseMessage[];
589
612
  initialized: boolean;
590
- subscribeMessageEvents: (listener: (event: ConversationMessageEvent) => void) => () => void;
591
613
  loadPrevious: () => Promise<void>;
592
614
  loadNext: () => Promise<void>;
593
615
  sendUserMessage: (params: ChatSDKUserMessageCreateParams) => Promise<ChatSDKUserMessage>;
@@ -608,6 +630,8 @@ declare interface AIAgentConversationContextValue {
608
630
  };
609
631
  input: InputState;
610
632
  };
633
+
634
+ _statsEnabled: boolean;
611
635
  }
612
636
 
613
637
  declare interface AIAgentConversationListContextValue {
@@ -728,6 +752,9 @@ declare interface AIAgentInterface {
728
752
  /** @internal Schedules metrics work that should only start while the app is foregrounded. */
729
753
  readonly scheduleForegroundTask?: ForegroundTaskScheduler;
730
754
 
755
+ /** @internal Watches whether the app was backgrounded during a stats measurement window. */
756
+ readonly watchBackgrounded?: BackgroundedWatcher;
757
+
731
758
  /** @internal Stats trackers owned by this SDK instance. */
732
759
  readonly statsTrackers: AIAgentStatsTrackers;
733
760
 
@@ -749,6 +776,8 @@ declare interface AIAgentInterface {
749
776
  updateContext: (channelUrl: string, context: Record<string, string>) => Promise<ContextObject>;
750
777
  /** Retrieves the context data for the specified channel */
751
778
  getContextObject: (channelUrl: string) => Promise<ContextObject>;
779
+ /** Requests a nudge message to be shown above the launcher. Fire-and-forget: the result is only broadcast to onNudgeMessageReceived subscribers */
780
+ requestNudgeMessage: (params: RequestNudgeMessageParams) => Promise<void>;
752
781
  /** Clears memories saved for the current user */
753
782
  clearUserMemories: (params?: ClearUserMemoriesParams) => Promise<void>;
754
783
 
@@ -759,7 +788,7 @@ declare interface AIAgentInterface {
759
788
  setUserMemoryState: (params: SetUserMemoryStateParams) => Promise<MemoryInfo>;
760
789
 
761
790
  /** @internal Initializes a conversation on the channel if needed (AI Agent channel without conversation). */
762
- initConversationIfNeeded: (channel: ChatSDKGroupChannel) => Promise<void>;
791
+ initConversationIfNeeded: (channel: ChatSDKGroupChannel, params?: { initialUserMessage?: string }) => Promise<void>;
763
792
 
764
793
  /** @internal Live metric handler; bridged from {@link AIAgentClientHandlers.onLiveMetric}. */
765
794
  onLiveMetric?: LiveMetricDispatch;
@@ -770,6 +799,8 @@ declare interface AIAgentInterface {
770
799
  /** Disposes all resources held by this instance (WebSocket, desk auth, event handlers). */
771
800
  dispose: () => void;
772
801
 
802
+ /** Registers a handler for nudge message responses */
803
+ onNudgeMessageReceived: (handler: (response: NudgeMessageResponse) => void) => () => void;
773
804
  /** Registers a handler for messenger settings updates */
774
805
  onMessengerSettingsUpdated: (handler: (params: MessengerSettingsUpdatedParams) => void) => () => void;
775
806
  /** Registers a handler for active channel updates */
@@ -830,6 +861,8 @@ declare interface AIAgentMessengerSessionRef {
830
861
  /** Patches the context of a specific conversation by aiAgentId and channelUrl. */
831
862
  (aiAgentId: string, channelUrl: string, context: Record<string, string>): Promise<ContextObject>;
832
863
  };
864
+ /** Requests a nudge message to be shown above the launcher. Fire-and-forget: the response is not exposed. */
865
+ requestNudgeMessage: (params: RequestNudgeMessageParams) => Promise<void>;
833
866
  }
834
867
 
835
868
  export declare type AIAgentProps = PropsWithChildren<{
@@ -1136,6 +1169,7 @@ declare interface AIAgentStringSet {
1136
1169
  a11y_send_message: string;
1137
1170
  a11y_attach_file: string;
1138
1171
  a11y_close_messenger: string;
1172
+ a11y_dismiss_nudge_message: string;
1139
1173
  a11y_expand_messenger: string;
1140
1174
  a11y_collapse_messenger: string;
1141
1175
  a11y_connect_agent: string;
@@ -1278,23 +1312,40 @@ declare interface AIAgentStringSet {
1278
1312
  };
1279
1313
  }
1280
1314
 
1281
- declare class AIEVAuthExpiredUnhandledStatsTracker {
1282
- private readonly callback: StatsAppendCallback;
1283
-
1284
- constructor(callback: StatsAppendCallback) {
1285
- this.callback = callback;
1286
- }
1287
-
1315
+ /**
1316
+ * Spec: aiev_auth_expired_unhandled
1317
+ *
1318
+ * Records a server EXTERNAL_AUTH_TOKEN_EXPIRED event that the SDK cannot
1319
+ * deliver to the host app.
1320
+ *
1321
+ * Timeline:
1322
+ * - Trigger when the SDK receives the external-token-expired AIEV payload.
1323
+ * - Commit immediately if payload parsing fails or no host callback/delegate
1324
+ * can receive the event.
1325
+ *
1326
+ * Payload:
1327
+ * - value: failure_reason = payload_parse_failed | callback_not_found.
1328
+ * - extra.agent_id: AI Agent id when available.
1329
+ *
1330
+ * Policy:
1331
+ * - Fire-and-forget; one failed event produces one stat.
1332
+ * - Successful delivery to the host app is not recorded by this metric.
1333
+ */
1334
+ declare class AIEVAuthExpiredUnhandledStatsTracker extends StatsTrackerBase {
1288
1335
  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
- });
1336
+ new AIEVAuthExpiredUnhandledStats()
1337
+ .setReason('payload_parse_failed')
1338
+ .setAIAgentId(params.aiAgentId)
1339
+ .setCommitCallback(this.commitCallback)
1340
+ .commit();
1292
1341
  }
1293
1342
 
1294
1343
  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
- });
1344
+ new AIEVAuthExpiredUnhandledStats()
1345
+ .setReason('callback_not_found')
1346
+ .setAIAgentId(params.aiAgentId)
1347
+ .setCommitCallback(this.commitCallback)
1348
+ .commit();
1298
1349
  }
1299
1350
 
1300
1351
  clear(): void {}
@@ -1344,8 +1395,15 @@ declare interface AttachmentRules {
1344
1395
  getUploadSizeLimitInMB: (mimeType: string) => number;
1345
1396
  }
1346
1397
 
1398
+ declare type AuthState = 'idle' | 'in_progress' | 'completed' | 'failed';
1399
+
1400
+ declare type AuthState_2 = 'idle' | 'in_progress' | 'completed' | 'failed';
1401
+
1347
1402
  declare interface AuthTokenRefreshedParams {}
1348
1403
 
1404
+ /** Starts watching foreground state; the returned function stops watching and reports whether the app was backgrounded while watched. */
1405
+ declare type BackgroundedWatcher = () => () => boolean;
1406
+
1349
1407
  declare abstract class BaseCommand {
1350
1408
  abstract type: CommandType;
1351
1409
  payload: any;
@@ -1389,7 +1447,7 @@ declare type CachedMessengerSettings = Omit<MessengerSettingsResponse, 'memory'>
1389
1447
  export declare type ChallengeActionParams = {
1390
1448
  key: string;
1391
1449
  requestId: string;
1392
- action: 'submit' | 'cancel';
1450
+ action: 'submit' | 'cancel' | 'fail';
1393
1451
  data?: Record<string, unknown>;
1394
1452
  };
1395
1453
 
@@ -1407,8 +1465,6 @@ declare type ChatSDKBaseChannelCollectionEventHandler = BaseChannelCollectionEve
1407
1465
 
1408
1466
  declare type ChatSDKBaseMessage = BaseMessage;
1409
1467
 
1410
- declare type ChatSDKCollectionEventSource = CollectionEventSource;
1411
-
1412
1468
  declare type ChatSDKConversation = Conversation_2;
1413
1469
 
1414
1470
  declare type ChatSDKConversationStatus = ConversationStatus;
@@ -1491,6 +1547,9 @@ declare interface ColorSet {
1491
1547
  csat: {
1492
1548
  background: string;
1493
1549
  };
1550
+ conversation: {
1551
+ headerProfileSkeleton: string;
1552
+ };
1494
1553
  memoryDialog: {
1495
1554
  titleText: string;
1496
1555
  markdownBodyText: string;
@@ -1529,6 +1588,7 @@ declare interface CommandPayloads {
1529
1588
  [CommandType.ActiveChannelUpdated]: ActiveChannelUpdatedParams;
1530
1589
  [CommandType.AnonymousSessionTokenExpired]: undefined;
1531
1590
  [CommandType.AuthTokenRefreshed]: AuthTokenRefreshedParams;
1591
+ [CommandType.NudgeMessageReceived]: NudgeMessageResponse;
1532
1592
  }
1533
1593
 
1534
1594
  export declare const Commands = {
@@ -1537,6 +1597,7 @@ export declare const Commands = {
1537
1597
  ActiveChannelUpdated: (params: ActiveChannelUpdatedParams) => new ActiveChannelUpdatedCommand(params),
1538
1598
  AnonymousSessionTokenExpired: () => new AnonymousSessionTokenExpiredCommand(),
1539
1599
  AuthTokenRefreshed: () => new AuthTokenRefreshedCommand(),
1600
+ NudgeMessageReceived: (response: NudgeMessageResponse) => new NudgeMessageReceivedCommand(response),
1540
1601
  };
1541
1602
 
1542
1603
  declare enum CommandType {
@@ -1545,6 +1606,7 @@ declare enum CommandType {
1545
1606
  ActiveChannelUpdated = 'active.channel.updated',
1546
1607
  AnonymousSessionTokenExpired = 'anon.token.expired',
1547
1608
  AuthTokenRefreshed = 'auth.token.refreshed',
1609
+ NudgeMessageReceived = 'nudge.message.received',
1548
1610
  }
1549
1611
 
1550
1612
  export declare interface ConnectionDelayDialogProps {
@@ -1590,7 +1652,7 @@ declare interface ContextObject {
1590
1652
  context: Record<string, string>;
1591
1653
  }
1592
1654
 
1593
- export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, shouldInitializeConversation, announcementsEnabled, conversationStatsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1655
+ export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, shouldInitializeConversation, initialUserMessage, announcementsEnabled, conversationStatsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1594
1656
 
1595
1657
  export declare const ConversationContext: Context<ConversationContextValue | null>;
1596
1658
 
@@ -1602,12 +1664,12 @@ declare interface ConversationContextProps {
1602
1664
  conversationStatsEnabled?: boolean;
1603
1665
  /** @internal */
1604
1666
  shouldInitializeConversation?: boolean;
1667
+ initialUserMessage?: string;
1605
1668
  }
1606
1669
 
1607
1670
  export declare const ConversationContextProvider: ({ children, ...props }: PropsWithChildren<ConversationContextProps>) => JSX.Element;
1608
1671
 
1609
1672
  declare interface ConversationContextValue extends AIAgentConversationContextValue {
1610
- conversationStatsEnabled: boolean;
1611
1673
  scrollSource: ConversationScrollContextValue;
1612
1674
  goToActiveConversation: () => Promise<void>;
1613
1675
  onNavigateToConversationList?: () => void;
@@ -1704,234 +1766,326 @@ declare interface ConversationHeaderTemplateProps {
1704
1766
 
1705
1767
  declare type ConversationInitialFocusTarget = 'messageInput';
1706
1768
 
1769
+ declare type ConversationInitialRenderMeasure = {
1770
+ channelUrl: string;
1771
+ pageRenderState: PageRenderState;
1772
+ };
1773
+
1707
1774
  declare class ConversationInitialRenderStats extends AIAgentBaseStats {
1775
+ private channelUrl: string | null = null;
1708
1776
  private presentMethod: PresentMethod = 'direct_present';
1709
1777
 
1778
+ setChannelUrl(url: string): this {
1779
+ this.channelUrl = url;
1780
+ return this;
1781
+ }
1782
+
1710
1783
  setPresentMethod(method: PresentMethod): this {
1711
1784
  this.presentMethod = method;
1712
1785
  return this;
1713
1786
  }
1714
1787
 
1788
+ resetPageRenderSpan(): this {
1789
+ this.timers.delete(DurationKey.PAGE_RENDER);
1790
+ this.timers.delete(DurationKey.PAGE_RENDER_FROM_CACHE);
1791
+ this.timers.delete(DurationKey.GET_CHANNEL);
1792
+ this.timers.delete(DurationKey.GET_MESSAGES);
1793
+ this.conversationId = null;
1794
+ this.channelUrl = null;
1795
+ this.errorCode = null;
1796
+ this.errorDescription = null;
1797
+ return this;
1798
+ }
1799
+
1715
1800
  protected getMetricKey(): string {
1716
1801
  return METRIC_KEY_CONVERSATION_INITIAL_RENDER;
1717
1802
  }
1718
1803
 
1719
1804
  protected buildPayload(): AIAgentStatPayload | null {
1720
- const authDuration = this.getDuration(DurationKey.AUTH);
1721
- const getChannelDuration = this.getDuration(DurationKey.GET_CHANNEL);
1722
- const getMessagesDuration = this.getDuration(DurationKey.GET_MESSAGES);
1723
- const totalDurationFromCache = this.getDuration(DurationKey.TOTAL_DURATION_FROM_CACHE);
1724
- const totalDuration = this.getDuration(DurationKey.TOTAL_DURATION);
1805
+ const auth = this.getTimer(DurationKey.AUTH);
1806
+ const pageRender = this.getTimer(DurationKey.PAGE_RENDER);
1807
+ const fromCache = this.getTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1725
1808
 
1726
- const extra: Record<string, unknown> = {
1727
- ...this.extraData,
1728
- present_method: this.presentMethod,
1729
- ...(authDuration !== null && { [DurationKey.AUTH]: authDuration }),
1730
- ...(getChannelDuration !== null && { [DurationKey.GET_CHANNEL]: getChannelDuration }),
1731
- ...(getMessagesDuration !== null && { [DurationKey.GET_MESSAGES]: getMessagesDuration }),
1732
- ...(totalDurationFromCache !== null && { [DurationKey.TOTAL_DURATION_FROM_CACHE]: totalDurationFromCache }),
1733
- };
1809
+ // Only the auth-error early commit legitimately lacks a page render duration;
1810
+ // any other commit without it is an incomplete measurement and must not emit.
1811
+ if (pageRender.duration === null && (this.errorCode === null || auth.duration === null)) return null;
1812
+
1813
+ // The timers race freely; subtracting the overlap counts the auth wait exactly once.
1814
+ const pageRenderDuration = pageRender.duration === null ? 0 : pageRender.duration - overlapMs(auth, pageRender);
1815
+ const fromCacheDuration = fromCache.duration === null ? null : fromCache.duration - overlapMs(auth, fromCache);
1734
1816
 
1735
- return createStatsPayload(this.getMetricKey(), totalDuration ?? 0, {
1736
- conversationId: this.conversationId ?? undefined,
1737
- error: this.errorCode !== null ? { code: this.errorCode, message: this.errorDescription ?? '' } : undefined,
1738
- extra,
1817
+ return this.createPayload((auth.duration ?? 0) + pageRenderDuration, {
1818
+ extra: {
1819
+ channel_url: this.channelUrl,
1820
+ present_method: this.presentMethod,
1821
+ [DurationKey.AUTH]: auth.duration,
1822
+ [DurationKey.GET_CHANNEL]: this.getTimer(DurationKey.GET_CHANNEL).duration,
1823
+ [DurationKey.GET_MESSAGES]: this.getTimer(DurationKey.GET_MESSAGES).duration,
1824
+ [DurationKey.PAGE_RENDER_FROM_CACHE]: fromCacheDuration,
1825
+ },
1739
1826
  });
1740
1827
  }
1741
1828
  }
1742
1829
 
1743
1830
  /**
1744
- * Manages ConversationInitialRenderStats lifecycle and encapsulates tracking logic.
1831
+ * One Conversation entry attempt. Owns the page render phase machine; the tracker
1832
+ * owns the cross-attempt policy (auth span, supersession, one commit per entry).
1833
+ *
1834
+ * Async-boundary events carry the channel identity so a result delivered to the
1835
+ * wrong span is rejected instead of consuming a phase transition.
1836
+ *
1837
+ * Timers record their own event boundaries and race freely against auth; the
1838
+ * metric subtracts the overlap at payload time, so event ordering needs no
1839
+ * orchestration here.
1745
1840
  */
1746
- declare class ConversationInitialRenderStatsTracker {
1747
- private readonly commitCallback: StatsAppendCallback;
1748
- private apiResultReceived = false;
1749
- private channelUrl?: string;
1750
- private consumed = false;
1751
- private consumedByAuthError = false;
1752
- private presentMethod: PresentMethod = 'direct_present';
1753
- private renderedAfterApiResult = false;
1754
- private stats: ConversationInitialRenderStats | null = null;
1841
+ declare class ConversationInitialRenderStatsSpan extends StatsSpanBase<
1842
+ ConversationInitialRenderStats,
1843
+ ConversationInitialRenderMeasure
1844
+ > {
1845
+ protected measure: ConversationInitialRenderMeasure;
1755
1846
 
1756
- constructor(commitCallback: StatsAppendCallback) {
1757
- this.commitCallback = commitCallback;
1847
+ constructor(stats: ConversationInitialRenderStats | null, channelUrl: string, visible: boolean) {
1848
+ super(stats, visible);
1849
+ this.measure = { channelUrl, pageRenderState: 'initialized' };
1850
+ this.stats
1851
+ ?.setChannelUrl(channelUrl)
1852
+ .startTimer(DurationKey.PAGE_RENDER)
1853
+ .startTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1758
1854
  }
1759
1855
 
1760
- private getOrCreateStats(): ConversationInitialRenderStats | null {
1761
- if (this.consumed) return null;
1762
- if (this.stats?.isCommitted()) {
1763
- this.consumed = true;
1764
- return null;
1765
- }
1766
- if (!this.stats) {
1767
- this.stats = new ConversationInitialRenderStats()
1768
- .setCommitCallback(this.commitCallback)
1769
- .setPresentMethod(this.presentMethod);
1770
- this.apiResultReceived = false;
1771
- this.renderedAfterApiResult = false;
1772
- }
1773
- return this.stats;
1856
+ onGetChannelStart(channelUrl: string): void {
1857
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'initialized') return;
1858
+
1859
+ this.stats.startTimer(DurationKey.GET_CHANNEL);
1860
+ this.measure.pageRenderState = 'channel_fetching';
1774
1861
  }
1775
1862
 
1776
- private getActiveStats(): ConversationInitialRenderStats | null {
1777
- if (this.consumed || !this.stats || this.stats.isCommitted()) return null;
1778
- return this.stats;
1863
+ onGetChannelComplete(channelUrl: string, conversationId?: number): void {
1864
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'channel_fetching')
1865
+ return;
1866
+
1867
+ this.stats.stopTimer(DurationKey.GET_CHANNEL).setConversationId(conversationId);
1868
+ this.measure.pageRenderState = 'channel_fetched';
1779
1869
  }
1780
1870
 
1781
- private matchesChannelUrl(channelUrl?: string): boolean {
1782
- return channelUrl === undefined || this.channelUrl === undefined || channelUrl === this.channelUrl;
1871
+ onGetChannelError(channelUrl: string, error: Error): void {
1872
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'channel_fetching')
1873
+ return;
1874
+
1875
+ this.stats.stopTimer(DurationKey.GET_CHANNEL).setError(error).stopTimer(DurationKey.PAGE_RENDER);
1876
+ this.measure.pageRenderState = 'channel_failed';
1877
+ this.tryFinalize();
1783
1878
  }
1784
1879
 
1785
- private pinChannelUrl(channelUrl?: string): boolean {
1786
- if (channelUrl === undefined) return true;
1787
- if (!this.matchesChannelUrl(channelUrl)) return false;
1880
+ onGetMessagesStart(): void {
1881
+ if (!this.stats || this.measure.pageRenderState !== 'channel_fetched') return;
1788
1882
 
1789
- this.channelUrl = channelUrl;
1790
- this.stats?.setChannelUrl(channelUrl);
1791
- return true;
1883
+ this.stats.startTimer(DurationKey.GET_MESSAGES);
1884
+ this.measure.pageRenderState = 'messages_fetching';
1792
1885
  }
1793
1886
 
1794
- private consume(commitResult: boolean): boolean {
1795
- if (commitResult) {
1796
- this.consumed = true;
1887
+ onGetMessagesCacheComplete(channelUrl: string, error: Error | null): void {
1888
+ if (
1889
+ !this.stats ||
1890
+ channelUrl !== this.measure.channelUrl ||
1891
+ error ||
1892
+ this.measure.pageRenderState !== 'messages_fetching'
1893
+ ) {
1894
+ return;
1797
1895
  }
1798
- return commitResult;
1799
- }
1800
1896
 
1801
- private commitIfReady(channelUrl?: string): boolean {
1802
- if (this.consumed || !this.stats || this.stats.isCommitted()) return true;
1803
- if (!this.matchesChannelUrl(channelUrl)) return false;
1804
- if (!this.apiResultReceived || !this.renderedAfterApiResult) return false;
1805
- return this.consume(this.stats.stopTimer(DurationKey.TOTAL_DURATION).commit());
1897
+ this.stats.stopTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1806
1898
  }
1807
1899
 
1808
- start(channelUrl?: string): void {
1809
- const stats = this.getOrCreateStats();
1810
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1900
+ onGetMessagesComplete(channelUrl: string, error: Error | null): void {
1901
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'messages_fetching')
1902
+ return;
1811
1903
 
1812
- stats.startTimer(DurationKey.TOTAL_DURATION).startTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1904
+ this.stats.stopTimer(DurationKey.GET_MESSAGES).setError(error);
1905
+ this.measure.pageRenderState = 'messages_fetching_completed';
1813
1906
  }
1814
1907
 
1815
- onAuthStart(): void {
1816
- if (this.consumedByAuthError) {
1817
- this.clear();
1818
- }
1819
- this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1908
+ onRendered(): void {
1909
+ if (!this.stats || this.measure.pageRenderState !== 'messages_fetching_completed') return;
1910
+
1911
+ this.stats.stopTimer(DurationKey.PAGE_RENDER);
1912
+ this.tryFinalize();
1820
1913
  }
1821
1914
 
1822
- onAuthComplete(): void {
1823
- this.getActiveStats()?.stopTimer(DurationKey.AUTH);
1915
+ onRenderDiscarded(): void {
1916
+ if (!this.stats || this.measure.pageRenderState !== 'messages_fetching_completed') return;
1917
+
1918
+ this.stats.discard();
1919
+ this.stats = null;
1824
1920
  }
1825
1921
 
1826
- onAuthError(error: Error): void {
1827
- const stats = this.getActiveStats();
1828
- if (stats) {
1829
- const committed = this.consume(
1830
- stats
1831
- .stopTimer(DurationKey.AUTH)
1832
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1833
- .stopTimer(DurationKey.TOTAL_DURATION)
1834
- .commit(),
1835
- );
1836
- if (committed) {
1837
- this.consumedByAuthError = true;
1838
- }
1839
- }
1922
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
1923
+ onAuthSettled(): void {
1924
+ this.tryFinalize();
1840
1925
  }
1841
1926
 
1842
- onGetChannelStart(channelUrl?: string): void {
1843
- const stats = this.getActiveStats();
1844
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1927
+ /** Commit once both facts exist: the page render ended (render or failure) and auth is not in flight. */
1928
+ private tryFinalize(): void {
1929
+ if (!this.stats) return;
1930
+ if (this.stats.getTimer(DurationKey.PAGE_RENDER).endedAt === null) return;
1845
1931
 
1846
- stats.startTimer(DurationKey.GET_CHANNEL);
1932
+ const auth = this.stats.getTimer(DurationKey.AUTH);
1933
+ if (auth.startAt !== null && auth.endedAt === null) return;
1934
+
1935
+ if (this.measure.pageRenderState === 'channel_failed') {
1936
+ // Early error commit (iOS/Android convergence) bypasses the visibility gate, like onAuthError.
1937
+ this.stats.commit();
1938
+ this.stats = null;
1939
+ return;
1940
+ }
1941
+ this.commitOrDefer();
1847
1942
  }
1943
+ }
1848
1944
 
1849
- onGetChannelComplete(params?: number | GetChannelCompleteParams): void {
1850
- const channelUrl = typeof params === 'object' ? params.channelUrl : undefined;
1851
- const conversationId = typeof params === 'object' ? params.conversationId : params;
1852
- const stats = this.getActiveStats();
1853
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1945
+ /**
1946
+ * Spec: conversation_initial_render
1947
+ *
1948
+ * Measures the first Conversation screen render as the sum of auth and the
1949
+ * page render span required before the first remote-rendered message list frame.
1950
+ *
1951
+ * Timeline:
1952
+ * - Resolve present method.
1953
+ * - Track auth independently; it can start before any Conversation screen exists.
1954
+ * - Start a span when the Conversation screen display lifecycle begins.
1955
+ * - Track get-channel and get-messages phases on the span.
1956
+ * - Capture cache completion as best-effort total_duration_from_cache_ms, but
1957
+ * do not commit until the remote render path completes.
1958
+ * - Commit on the first remote rendered frame. While the screen is hidden the
1959
+ * measurement still freezes at that frame, but emission is deferred until the
1960
+ * screen becomes visible.
1961
+ * - Discard (no payload) when the rendered frame was deferred by a
1962
+ * backgrounded page/app; the session stays consumed.
1963
+ * - Confluence/iOS/Android converge on early error commit for auth or
1964
+ * get-channel failures that prevent Conversation entry.
1965
+ * - Cache message-list failure is not terminal; remote message-list failure
1966
+ * stores error data, and the rendered signal commits the initial render attempt.
1967
+ *
1968
+ * Payload:
1969
+ * - value: total_duration_ms.
1970
+ * - extra.auth_duration_ms, get_channel_duration_ms, get_messages_duration_ms.
1971
+ * - extra.total_duration_from_cache_ms, present_method, channel_url.
1972
+ *
1973
+ * Method call order:
1974
+ * - Resolve present method - setPresentMethod()
1975
+ * - Start auth - onAuthStart()
1976
+ * - Complete auth - onAuthComplete()
1977
+ * - Fail auth - onAuthError()
1978
+ * - Enter display lifecycle - startSpan(channelUrl)
1979
+ * - Screen visibility change - setVisible(visible)
1980
+ * - Start get channel - span.onGetChannelStart(channelUrl)
1981
+ * - Complete get channel - span.onGetChannelComplete(channelUrl, conversationId)
1982
+ * - Fail get channel - span.onGetChannelError(channelUrl, error)
1983
+ * - Start remote message list - span.onGetMessagesStart()
1984
+ * - Complete cache message list - span.onGetMessagesCacheComplete(channelUrl, error)
1985
+ * - Complete remote message list - span.onGetMessagesComplete(channelUrl, error)
1986
+ * - Render conversation - span.onRendered()
1987
+ * - Render deferred by background - span.onRenderDiscarded()
1988
+ * - Abandon entry attempt - span.cancel()
1989
+ * - Logout (deauthenticate/dispose) - clear()
1990
+ *
1991
+ * Policy:
1992
+ * - Session-scoped; collect at most once per authenticated session. Only the
1993
+ * logout-time clear() reopens measurement: re-entering a conversation after
1994
+ * a commit is a warm navigation and is deliberately not re-measured.
1995
+ * - value = auth + (page render − overlap): the timers record their own event
1996
+ * boundaries and race freely, and the metric subtracts the overlapping
1997
+ * interval at payload time, so the auth wait is counted exactly once in any
1998
+ * ordering — a render, cache completion, or channel failure landing mid-auth
1999
+ * included; the cache extra derives the same way. The commit waits for
2000
+ * in-flight auth to settle, since the value needs the auth duration.
2001
+ * - startSpan supersedes the previous span: the old span becomes non-recording
2002
+ * and the page render phase restarts. Auth data carries over to the new span.
2003
+ * - After success or error commit, new spans are born non-recording until clear().
2004
+ * - An auth failure before any span started allows a fresh measurement on retry.
2005
+ * - A backgrounded page/app pauses requestAnimationFrame, so a render signal
2006
+ * arriving after background time would fold the idle dwell into
2007
+ * total_duration_ms. Such samples are discarded without a payload
2008
+ * (web-vitals convention) and consume the session like a commit.
2009
+ * - Measurement is independent of screen visibility: a hidden (launcher-closed
2010
+ * or route-inactive) preload keeps measuring with the same render criterion,
2011
+ * so visibility toggles never tear a measurement. Visibility only gates
2012
+ * emission: a render completed while hidden commits the moment the screen
2013
+ * becomes visible (setVisible), and a preload that is never shown is
2014
+ * cancelled with its span and never emitted.
2015
+ */
2016
+ declare class ConversationInitialRenderStatsTracker extends SpanStatsTrackerBase<ConversationInitialRenderStatsSpan> {
2017
+ private stats: ConversationInitialRenderStats | null = null;
2018
+ private authState: AuthState = 'idle';
2019
+ private presentMethod: PresentMethod = 'direct_present';
1854
2020
 
1855
- stats.stopTimer(DurationKey.GET_CHANNEL);
1856
- if (conversationId !== undefined) {
1857
- stats.setConversationId(conversationId);
2021
+ private getOrCreateStats(): ConversationInitialRenderStats | null {
2022
+ if (this.stats?.isCommitted()) return null;
2023
+ if (!this.stats) {
2024
+ this.stats = new ConversationInitialRenderStats()
2025
+ .setCommitCallback(this.commitCallback)
2026
+ .setPresentMethod(this.presentMethod);
1858
2027
  }
1859
- stats.startTimer(DurationKey.GET_MESSAGES);
2028
+ return this.stats;
1860
2029
  }
1861
2030
 
1862
- onGetChannelError(params: Error | GetChannelErrorParams): void {
1863
- const channelUrl = params instanceof Error ? undefined : params.channelUrl;
1864
- const error = params instanceof Error ? params : params.error;
1865
- const stats = this.getActiveStats();
1866
- if (stats && this.pinChannelUrl(channelUrl)) {
1867
- this.consume(
1868
- stats
1869
- .stopTimer(DurationKey.GET_CHANNEL)
1870
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1871
- .stopTimer(DurationKey.TOTAL_DURATION)
1872
- .commit(),
1873
- );
1874
- }
2031
+ private getActiveStats(): ConversationInitialRenderStats | null {
2032
+ if (!this.stats || this.stats.isCommitted()) return null;
2033
+ return this.stats;
1875
2034
  }
1876
2035
 
1877
- onCacheResult(error: Error | null): void {
1878
- const stats = this.getActiveStats();
1879
- if (stats) {
1880
- if (error) {
1881
- stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1882
- }
1883
- stats.stopTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1884
- }
2036
+ setPresentMethod(method: PresentMethod): void {
2037
+ this.presentMethod = method;
2038
+ this.getActiveStats()?.setPresentMethod(method);
1885
2039
  }
1886
2040
 
1887
- onApiResult(error: Error | null): void {
1888
- const stats = this.getActiveStats();
1889
- if (stats) {
1890
- if (error) {
1891
- this.consume(
1892
- stats
1893
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1894
- .stopTimer(DurationKey.TOTAL_DURATION)
1895
- .stopTimer(DurationKey.GET_MESSAGES)
1896
- .commit(),
1897
- );
1898
- return;
1899
- }
1900
- this.apiResultReceived = true;
1901
- this.renderedAfterApiResult = false;
1902
- stats.stopTimer(DurationKey.GET_MESSAGES);
1903
- this.commitIfReady();
2041
+ onAuthStart(): void {
2042
+ if (this.stats?.isCommitted()) {
2043
+ if (this.authState !== 'failed' || this.activeSpan !== null) return;
2044
+ // Auth failed before any Conversation entry: retrying auth starts a new measurement.
2045
+ this.stats = null;
2046
+ this.authState = 'idle';
1904
2047
  }
1905
- }
2048
+ if (this.authState !== 'idle') return;
1906
2049
 
1907
- setChannelUrl(url: string): void {
1908
- this.pinChannelUrl(url);
2050
+ this.authState = 'in_progress';
2051
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1909
2052
  }
1910
2053
 
1911
- commitPending(): boolean {
1912
- const stats = this.getActiveStats();
1913
- return stats ? this.consume(stats.stopTimer(DurationKey.TOTAL_DURATION).commit()) : true;
2054
+ onAuthComplete(): void {
2055
+ if (this.authState === 'in_progress') {
2056
+ this.authState = 'completed';
2057
+ this.getActiveStats()?.stopTimer(DurationKey.AUTH);
2058
+ }
2059
+
2060
+ this.activeSpan?.onAuthSettled();
1914
2061
  }
1915
2062
 
1916
- markRendered(channelUrl?: string): boolean {
1917
- if (!this.matchesChannelUrl(channelUrl)) return false;
2063
+ onAuthError(error: Error): void {
2064
+ if (this.authState !== 'in_progress') return;
1918
2065
 
1919
- this.renderedAfterApiResult = true;
1920
- return this.commitIfReady(channelUrl);
2066
+ this.authState = 'failed';
2067
+ this.getActiveStats()?.stopTimer(DurationKey.AUTH).setError(error).commit();
2068
+ // Keep the superseded reference: it marks that a span existed in this era,
2069
+ // which blocks the auth-retry reset above.
2070
+ this.activeSpan?.cancel();
1921
2071
  }
1922
2072
 
1923
- setPresentMethod(method: PresentMethod): void {
1924
- this.presentMethod = method;
1925
- this.getActiveStats()?.setPresentMethod(method);
2073
+ startSpan(channelUrl: string): ConversationInitialRenderStatsSpan {
2074
+ const superseded = this.activeSpan;
2075
+ superseded?.cancel();
2076
+
2077
+ const stats = this.getOrCreateStats();
2078
+ if (stats && superseded) stats.resetPageRenderSpan();
2079
+
2080
+ this.activeSpan = new ConversationInitialRenderStatsSpan(stats, channelUrl, this.visible);
2081
+ return this.activeSpan;
1926
2082
  }
1927
2083
 
1928
2084
  clear(): void {
2085
+ this.activeSpan?.cancel();
2086
+ this.activeSpan = null;
1929
2087
  this.stats = null;
1930
- this.apiResultReceived = false;
1931
- this.channelUrl = undefined;
1932
- this.consumed = false;
1933
- this.consumedByAuthError = false;
1934
- this.renderedAfterApiResult = false;
2088
+ this.authState = 'idle';
1935
2089
  }
1936
2090
  }
1937
2091
 
@@ -2065,153 +2219,263 @@ declare interface ConversationListHeaderTemplateProps {
2065
2219
  titleAlign?: 'start' | 'center' | 'end';
2066
2220
  }
2067
2221
 
2068
- declare class ConversationListInitialRenderStatsTracker {
2069
- private readonly callback: StatsAppendCallback;
2070
- private readonly clock: StatsClock;
2071
- private measurement = createEmptyProgress();
2072
- private renderedCommitted = false;
2073
- private startedAt: number | null = null;
2074
- private listStartedAt: number | null = null;
2075
- private authStartedAt: number | null = null;
2076
- private authDuration: number | null = null;
2077
- private listDuration: number | null = null;
2222
+ declare type ConversationListInitialRenderMeasure = {
2223
+ listStarted: boolean;
2224
+ listCompleted: boolean;
2225
+ };
2226
+
2227
+ declare class ConversationListInitialRenderStats extends AIAgentBaseStats {
2078
2228
  private presentMethod: PresentMethod = 'direct_present';
2079
- private committed = false;
2080
2229
 
2081
- constructor(callback: StatsAppendCallback, options?: TrackerOptions) {
2082
- this.callback = callback;
2083
- this.clock = getClock(options);
2230
+ setPresentMethod(method: PresentMethod): this {
2231
+ this.presentMethod = method;
2232
+ return this;
2084
2233
  }
2085
2234
 
2086
- onInitializeStart(): void {
2087
- if (this.measurement.rendered) return;
2088
- if (this.measurement.initialized) {
2089
- this.clearMeasurementState();
2090
- }
2091
- this.renderedCommitted = false;
2092
- this.startMeasurement();
2235
+ resetPageRenderSpan(): this {
2236
+ this.timers.delete(DurationKey.PAGE_RENDER);
2237
+ this.timers.delete(DurationKey.GET_LIST);
2238
+ this.errorCode = null;
2239
+ this.errorDescription = null;
2240
+ return this;
2093
2241
  }
2094
2242
 
2095
- onListRequestStart(): void {
2096
- if (this.measurement.listStarted && !this.measurement.listCompleted && !this.measurement.rendered) {
2097
- this.clearMeasurementState();
2098
- }
2099
-
2100
- this.markListRequestStartOnce();
2243
+ protected getMetricKey(): string {
2244
+ return METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER;
2101
2245
  }
2102
2246
 
2103
- onListRequestComplete(): void {
2104
- this.markListRequestCompleteOnce();
2105
- }
2247
+ protected buildPayload(): AIAgentStatPayload | null {
2248
+ const auth = this.getTimer(DurationKey.AUTH);
2249
+ const pageRender = this.getTimer(DurationKey.PAGE_RENDER);
2250
+ const listDuration = this.getTimer(DurationKey.GET_LIST).duration;
2106
2251
 
2107
- onRendered(): void {
2108
- this.markRenderedOnce();
2109
- }
2252
+ // Only the auth-error early commit legitimately lacks a page render duration;
2253
+ // any other commit without it is an incomplete measurement and must not emit.
2254
+ if (pageRender.duration === null && (this.errorCode === null || auth.duration === null)) return null;
2110
2255
 
2111
- setPresentMethod(method: PresentMethod): void {
2112
- this.presentMethod = method;
2256
+ // The timers race freely; subtracting the overlap counts the auth wait exactly once.
2257
+ const pageRenderDuration = pageRender.duration === null ? 0 : pageRender.duration - overlapMs(auth, pageRender);
2258
+ const totalDuration = (auth.duration ?? 0) + pageRenderDuration;
2259
+
2260
+ return this.createPayload(totalDuration, {
2261
+ extra: {
2262
+ present_method: this.presentMethod,
2263
+ [DurationKey.AUTH]: auth.duration,
2264
+ [DurationKey.GET_LIST]: listDuration,
2265
+ },
2266
+ });
2113
2267
  }
2268
+ }
2269
+
2270
+ /**
2271
+ * One ConversationList entry attempt. Owns the list request phase; the tracker
2272
+ * owns the cross-attempt policy (auth span, supersession, one commit per entry).
2273
+ *
2274
+ * Timers record their own event boundaries and race freely against auth; the
2275
+ * metric subtracts the overlap at payload time, so event ordering needs no
2276
+ * orchestration here.
2277
+ */
2278
+ declare class ConversationListInitialRenderStatsSpan extends StatsSpanBase<
2279
+ ConversationListInitialRenderStats,
2280
+ ConversationListInitialRenderMeasure
2281
+ > {
2282
+ protected measure: ConversationListInitialRenderMeasure = { listStarted: false, listCompleted: false };
2114
2283
 
2115
- start(presentMethod?: PresentMethod): void {
2116
- if (this.committed) return;
2117
- this.startedAt = this.clock.now();
2118
- if (presentMethod) this.presentMethod = presentMethod;
2284
+ constructor(stats: ConversationListInitialRenderStats | null, visible: boolean) {
2285
+ super(stats, visible);
2286
+ this.stats?.startTimer(DurationKey.PAGE_RENDER);
2119
2287
  }
2120
2288
 
2121
- onAuthStart(): void {
2122
- if (this.committed) {
2123
- this.clearMeasurementState();
2124
- this.renderedCommitted = false;
2125
- }
2126
- this.authDuration = null;
2127
- this.authStartedAt = this.clock.now();
2289
+ onListRequestStart(): void {
2290
+ if (!this.stats || this.measure.listStarted) return;
2291
+
2292
+ // After a failed attempt cleared the timers via resetPageRenderSpan(), this
2293
+ // re-baselines them to the retry so the earlier failure and downtime are excluded.
2294
+ this.stats.startTimer(DurationKey.PAGE_RENDER);
2295
+ this.stats.startTimer(DurationKey.GET_LIST);
2296
+ this.measure.listStarted = true;
2128
2297
  }
2129
2298
 
2130
- onAuthComplete(): void {
2131
- if (this.committed || this.authDuration !== null) return;
2132
- if (this.authStartedAt === null) {
2133
- this.authDuration = 0;
2299
+ onListRequestComplete(error?: unknown): void {
2300
+ if (!this.stats || !this.measure.listStarted || this.measure.listCompleted) return;
2301
+
2302
+ if (error) {
2303
+ // Post-auth failures are not collected; drop the in-flight attempt so a retry restarts clean.
2304
+ this.stats.resetPageRenderSpan();
2305
+ this.measure.listStarted = false;
2134
2306
  return;
2135
2307
  }
2136
- this.authDuration = this.clock.now() - this.authStartedAt;
2137
- this.authStartedAt = null;
2308
+
2309
+ this.stats.stopTimer(DurationKey.GET_LIST);
2310
+ this.measure.listCompleted = true;
2138
2311
  }
2139
2312
 
2140
- markListRequestStart(): void {
2141
- if (this.committed) return;
2142
- if (this.startedAt === null) this.start();
2143
- this.listStartedAt = this.clock.now();
2313
+ onRendered(): void {
2314
+ if (!this.stats || !this.measure.listCompleted) return;
2315
+
2316
+ this.stats.stopTimer(DurationKey.PAGE_RENDER);
2317
+ this.tryFinalize();
2144
2318
  }
2145
2319
 
2146
- markListRequestComplete(): void {
2147
- if (this.committed || this.listStartedAt === null) return;
2148
- this.listDuration = this.clock.now() - this.listStartedAt;
2320
+ onRenderDiscarded(): void {
2321
+ if (!this.stats || !this.measure.listCompleted) return;
2322
+
2323
+ this.stats.discard();
2324
+ this.stats = null;
2149
2325
  }
2150
2326
 
2151
- markRendered(): void {
2152
- if (this.committed || this.startedAt === null) return;
2153
- this.committed = true;
2154
- append(this.callback, METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER, this.clock.now() - this.startedAt, {
2155
- extra: {
2156
- ...(this.authDuration !== null && { auth_duration_ms: this.authDuration }),
2157
- ...(this.listDuration !== null && { get_list_duration_ms: this.listDuration }),
2158
- present_method: this.presentMethod,
2159
- },
2160
- });
2327
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
2328
+ onAuthSettled(): void {
2329
+ this.tryFinalize();
2161
2330
  }
2162
2331
 
2163
- clear(): void {
2164
- this.clearMeasurementState();
2165
- this.clearAuthState();
2166
- this.renderedCommitted = false;
2332
+ /** Commit once both facts exist: the render happened and auth is not in flight. */
2333
+ private tryFinalize(): void {
2334
+ if (!this.stats) return;
2335
+ if (this.stats.getTimer(DurationKey.PAGE_RENDER).endedAt === null) return;
2336
+
2337
+ const auth = this.stats.getTimer(DurationKey.AUTH);
2338
+ if (auth.startAt !== null && auth.endedAt === null) return;
2339
+
2340
+ this.commitOrDefer();
2167
2341
  }
2342
+ }
2343
+
2344
+ /**
2345
+ * Spec: conversation_list_initial_render
2346
+ *
2347
+ * Measures the first ConversationList screen render as the sum of auth and the
2348
+ * page render span required before the first rendered list frame.
2349
+ *
2350
+ * Timeline:
2351
+ * - Track auth independently; it can start before the list screen exists.
2352
+ * - Start a span when entering the display lifecycle.
2353
+ * - Track the list request phase on the span.
2354
+ * - Commit on the first rendered list frame. While the screen is hidden the
2355
+ * measurement still freezes at that frame, but emission is deferred until the
2356
+ * screen becomes visible.
2357
+ * - Discard (no payload) when the rendered frame was deferred by a
2358
+ * backgrounded page/app; the session stays consumed.
2359
+ *
2360
+ * Method call order:
2361
+ * - Resolve present method - setPresentMethod()
2362
+ * - Start auth - onAuthStart()
2363
+ * - Complete auth - onAuthComplete()
2364
+ * - Fail auth - onAuthError()
2365
+ * - Enter display lifecycle - startSpan()
2366
+ * - Screen visibility change - setVisible(visible)
2367
+ * - Start list request - span.onListRequestStart()
2368
+ * - Complete list request - span.onListRequestComplete()
2369
+ * - Fail list request - span.onListRequestComplete(error)
2370
+ * - Render list - span.onRendered()
2371
+ * - Render deferred by background - span.onRenderDiscarded()
2372
+ * - Abandon entry attempt - span.cancel()
2373
+ * - Logout (deauthenticate/dispose) - clear()
2374
+ *
2375
+ * Payload:
2376
+ * - value: total_duration_ms.
2377
+ * - extra.auth_duration_ms, get_list_duration_ms, present_method.
2378
+ *
2379
+ * Policy:
2380
+ * - Session-scoped; collect at most once per authenticated session. Only the
2381
+ * logout-time clear() reopens measurement: re-entering the list after a
2382
+ * commit is a warm navigation and is deliberately not re-measured.
2383
+ * - value = auth + (page render − overlap): both timers record their own event
2384
+ * boundaries and race freely, and the metric subtracts the overlapping
2385
+ * interval at payload time, so the auth wait is counted exactly once in any
2386
+ * ordering. The commit waits for in-flight auth to settle, since the value
2387
+ * needs the auth duration. A failed attempt re-baselines the page render
2388
+ * timer at its retry, excluding the failure and downtime.
2389
+ * - startSpan supersedes the previous span: the old span becomes non-recording
2390
+ * and the page render phase restarts. Auth data carries over to the new span.
2391
+ * - Success or auth failure closes collection until clear(). Post-auth failures
2392
+ * are not collected.
2393
+ * - An auth failure before any span is recording drops the pending measurement;
2394
+ * an auth retry starts a new one.
2395
+ * - A backgrounded page/app pauses requestAnimationFrame, so a render signal
2396
+ * arriving after background time would fold the idle dwell into
2397
+ * total_duration_ms. Such samples are discarded without a payload
2398
+ * (web-vitals convention) and consume the session like a commit.
2399
+ * - Measurement is independent of screen visibility: a hidden (launcher-closed
2400
+ * or route-inactive) preload keeps measuring with the same render criterion,
2401
+ * so visibility toggles never tear a measurement. Visibility only gates
2402
+ * emission: a render completed while hidden commits the moment the screen
2403
+ * becomes visible (setVisible), and a preload that is never shown is
2404
+ * cancelled with its span and never emitted.
2405
+ */
2406
+ declare class ConversationListInitialRenderStatsTracker extends SpanStatsTrackerBase<ConversationListInitialRenderStatsSpan> {
2407
+ private stats: ConversationListInitialRenderStats | null = null;
2408
+ private authState: AuthState_2 = 'idle';
2409
+ private presentMethod: PresentMethod = 'direct_present';
2168
2410
 
2169
- private clearMeasurementState(): void {
2170
- this.startedAt = null;
2171
- this.listStartedAt = null;
2172
- this.listDuration = null;
2173
- this.committed = false;
2174
- this.measurement = createEmptyProgress();
2411
+ private getOrCreateStats(): ConversationListInitialRenderStats | null {
2412
+ if (this.stats?.isCommitted()) return null;
2413
+ if (!this.stats) {
2414
+ this.stats = new ConversationListInitialRenderStats()
2415
+ .setCommitCallback(this.commitCallback)
2416
+ .setPresentMethod(this.presentMethod);
2417
+ }
2418
+ return this.stats;
2175
2419
  }
2176
2420
 
2177
- private clearAuthState(): void {
2178
- this.authStartedAt = null;
2179
- this.authDuration = null;
2421
+ private getActiveStats(): ConversationListInitialRenderStats | null {
2422
+ if (!this.stats || this.stats.isCommitted()) return null;
2423
+ return this.stats;
2180
2424
  }
2181
2425
 
2182
- private startMeasurement(): void {
2183
- if (this.measurement.initialized) return;
2426
+ setPresentMethod(method: PresentMethod): void {
2427
+ this.presentMethod = method;
2428
+ this.getActiveStats()?.setPresentMethod(method);
2429
+ }
2184
2430
 
2185
- this.clearMeasurementState();
2186
- this.start();
2187
- this.measurement.initialized = true;
2431
+ onAuthStart(): void {
2432
+ this.authState = 'in_progress';
2433
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
2188
2434
  }
2189
2435
 
2190
- private markListRequestStartOnce(): void {
2191
- this.startMeasurement();
2192
- if (this.measurement.listStarted) return;
2436
+ onAuthComplete(): void {
2437
+ const wasInProgress = this.authState === 'in_progress';
2438
+ this.authState = 'completed';
2439
+ if (wasInProgress) this.getActiveStats()?.stopTimer(DurationKey.AUTH);
2193
2440
 
2194
- this.markListRequestStart();
2195
- this.measurement.listStarted = true;
2441
+ this.activeSpan?.onAuthSettled();
2196
2442
  }
2197
2443
 
2198
- private markListRequestCompleteOnce(): void {
2199
- this.markListRequestStartOnce();
2200
- if (this.measurement.listCompleted) return;
2444
+ onAuthError(error: Error): void {
2445
+ const wasInProgress = this.authState === 'in_progress';
2446
+ this.authState = 'failed';
2447
+
2448
+ const stats = this.getActiveStats();
2449
+ if (!stats) return;
2450
+ if (!this.activeSpan?.isRecording) {
2451
+ // Auth failed before any span was recording: drop the pending measurement so an auth retry re-measures cleanly.
2452
+ this.stats = null;
2453
+ return;
2454
+ }
2455
+
2456
+ if (wasInProgress) stats.stopTimer(DurationKey.AUTH);
2201
2457
 
2202
- this.markListRequestComplete();
2203
- this.measurement.listCompleted = true;
2458
+ stats.setError(error).commit();
2459
+ this.activeSpan.cancel();
2204
2460
  }
2205
2461
 
2206
- private markRenderedOnce(): void {
2207
- if (this.renderedCommitted) return;
2462
+ startSpan(): ConversationListInitialRenderStatsSpan {
2463
+ const superseded = this.activeSpan;
2464
+ superseded?.cancel();
2465
+
2466
+ const stats = this.getOrCreateStats();
2467
+ if (stats && superseded) stats.resetPageRenderSpan();
2468
+ if (stats && this.authState === 'in_progress') stats.startTimer(DurationKey.AUTH);
2208
2469
 
2209
- this.startMeasurement();
2210
- if (this.measurement.rendered) return;
2470
+ this.activeSpan = new ConversationListInitialRenderStatsSpan(stats, this.visible);
2471
+ return this.activeSpan;
2472
+ }
2211
2473
 
2212
- this.markRendered();
2213
- this.measurement.rendered = true;
2214
- this.renderedCommitted = true;
2474
+ clear(): void {
2475
+ this.activeSpan?.cancel();
2476
+ this.activeSpan = null;
2477
+ this.stats = null;
2478
+ this.authState = 'idle';
2215
2479
  }
2216
2480
  }
2217
2481
 
@@ -2284,107 +2548,133 @@ export declare type ConversationListProps = PropsWithChildren<{
2284
2548
  conversationListFilter?: Partial<AIAgentGroupChannelFilter>;
2285
2549
  onOpenConversationView?: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
2286
2550
  announcementsEnabled?: boolean;
2551
+ /** @default true */
2287
2552
  conversationListStatsEnabled?: boolean;
2288
2553
  /** Custom styles for the conversation list container. */
2289
2554
  style?: CSSProperties;
2290
2555
  }>;
2291
2556
 
2292
- declare type ConversationMessageEvent = ConversationMessageEventPayload & { id: number };
2293
-
2294
- declare type ConversationMessageEventPayload =
2295
- | {
2296
- kind: 'messagesAdded';
2297
- source: ChatSDKCollectionEventSource;
2298
- messages: ChatSDKBaseMessage[];
2299
- }
2300
- | {
2301
- kind: 'messagesUpdated';
2302
- source: ChatSDKCollectionEventSource;
2303
- messages: ChatSDKBaseMessage[];
2304
- }
2305
- | {
2306
- kind: 'initialized';
2307
- source: 'cache' | 'api';
2308
- messages: ChatSDKBaseMessage[];
2309
- };
2310
-
2311
- declare type ConversationNotInteractiveMeasurement = {
2312
- timer: TimeoutId | null;
2313
- startedAt: number;
2314
- committed: boolean;
2315
- inputEnabled: boolean;
2316
- phaseReached: ConversationNotInteractivePhase;
2317
- historyLoadResult: HistoryLoadResult;
2318
- conversationId?: number;
2557
+ declare type ConversationNotInteractiveMeasure = {
2319
2558
  channelUrl?: string;
2559
+ historyLoadResult: HistoryLoadResult;
2560
+ inputEnabled: boolean;
2561
+ timer: TimeoutId | null;
2320
2562
  };
2321
2563
 
2322
2564
  declare type ConversationNotInteractivePhase = 'auth' | 'channel_join' | 'history_load' | 'input_enable' | 'render';
2323
2565
 
2324
- declare class ConversationNotInteractiveTimeoutStatsTracker {
2325
- private readonly callback: StatsAppendCallback;
2326
- private readonly clock: StatsClock;
2327
- private readonly setTimeoutFn: typeof setTimeout;
2328
- private readonly clearTimeoutFn: typeof clearTimeout;
2329
- private readonly measurements = new Map<string, ConversationNotInteractiveMeasurement>();
2566
+ declare class ConversationNotInteractiveTimeoutStats extends AIAgentBaseStats {
2567
+ private phaseReached: ConversationNotInteractivePhase = 'auth';
2568
+ private historyLoadResult: HistoryLoadResult = 'pending';
2330
2569
 
2331
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
2332
- this.callback = callback;
2333
- this.clock = getClock(options);
2334
- this.setTimeoutFn = getSetTimeout(options);
2335
- this.clearTimeoutFn = getClearTimeout(options);
2570
+ setHistoryLoadResult(result: HistoryLoadResult): this {
2571
+ this.historyLoadResult = result;
2572
+ return this;
2336
2573
  }
2337
2574
 
2338
- start(params?: { channelUrl?: string; conversationId?: number }): void {
2339
- const key = this.getKey(params?.channelUrl);
2340
- this.clearKey(key);
2341
-
2342
- const measurement = {
2343
- startedAt: this.clock.now(),
2344
- channelUrl: params?.channelUrl,
2345
- conversationId: params?.conversationId,
2346
- committed: false,
2347
- inputEnabled: false,
2348
- phaseReached: 'auth' as ConversationNotInteractivePhase,
2349
- historyLoadResult: 'pending' as HistoryLoadResult,
2350
- timer: null as TimeoutId | null,
2351
- };
2352
- measurement.timer = this.setTimeoutFn(() => this.commitTimeout(key), 10_000);
2353
- this.measurements.set(key, measurement);
2575
+ setPhaseReached(phase: ConversationNotInteractivePhase): this {
2576
+ this.phaseReached = phase;
2577
+ return this;
2354
2578
  }
2355
2579
 
2356
- markChannelJoined(conversationId?: number, channelUrl?: string): void {
2357
- const measurement = this.getMeasurement(channelUrl);
2358
- if (!measurement || measurement.committed) return;
2359
- measurement.phaseReached = 'channel_join';
2360
- if (conversationId !== undefined) measurement.conversationId = conversationId;
2580
+ protected getMetricKey(): string {
2581
+ return METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT;
2361
2582
  }
2362
2583
 
2363
- markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2364
- const measurement = this.getMeasurement(channelUrl);
2365
- if (!measurement || measurement.committed) return;
2366
- measurement.phaseReached = 'history_load';
2367
- measurement.historyLoadResult = result;
2368
- this.cancelIfReady(channelUrl);
2369
- }
2584
+ protected buildPayload(): AIAgentStatPayload | null {
2585
+ const duration = this.getTimer(DurationKey.TIMEOUT).duration;
2586
+ if (duration === null) return null;
2370
2587
 
2371
- markInputEnabled(channelUrl?: string): void {
2372
- const measurement = this.getMeasurement(channelUrl);
2373
- if (!measurement || measurement.committed) return;
2374
- measurement.phaseReached = 'input_enable';
2375
- measurement.inputEnabled = true;
2588
+ return this.createPayload(duration, {
2589
+ extra: {
2590
+ outcome: 'timeout',
2591
+ phase_reached: this.phaseReached,
2592
+ history_load_result: this.historyLoadResult,
2593
+ },
2594
+ });
2595
+ }
2596
+ }
2597
+
2598
+ /**
2599
+ * Spec: conversation_not_interactive_timeout
2600
+ *
2601
+ * Detects a new Conversation entry that remains unusable for 10s.
2602
+ *
2603
+ * Timeline:
2604
+ * - Start when the Conversation screen display lifecycle begins.
2605
+ * - Track the farthest reached phase in auth -> channel_join -> history_load
2606
+ * -> input_enable -> render order.
2607
+ * - Success is input available and history display decision completed.
2608
+ * - Commit only if the 10s timeout fires before success.
2609
+ *
2610
+ * Payload:
2611
+ * - value: total_duration_ms.
2612
+ * - extra.phase_reached: farthest reached phase.
2613
+ * - extra.history_load_result: success | verified_empty | failed | pending.
2614
+ *
2615
+ * Policy:
2616
+ * - Entry-scoped; success or lifecycle abort clears the pending measurement.
2617
+ * - Placeholder/empty UI alone is not success unless the empty history result
2618
+ * is explicitly verified.
2619
+ */
2620
+ declare class ConversationNotInteractiveTimeoutStatsTracker extends MultiStatsTrackerBase<
2621
+ ConversationNotInteractiveTimeoutStats,
2622
+ ConversationNotInteractiveMeasure
2623
+ > {
2624
+ start(params?: { channelUrl?: string; conversationId?: number }): void {
2625
+ const key = this.getKey(params?.channelUrl);
2626
+ this.clearKey(key);
2627
+
2628
+ const record: StatsRecord<ConversationNotInteractiveTimeoutStats, ConversationNotInteractiveMeasure> = {
2629
+ stats: new ConversationNotInteractiveTimeoutStats()
2630
+ .setCommitCallback(this.commitCallback)
2631
+ .startTimer(DurationKey.TIMEOUT),
2632
+ measure: {
2633
+ channelUrl: params?.channelUrl,
2634
+ historyLoadResult: 'pending',
2635
+ inputEnabled: false,
2636
+ timer: null,
2637
+ },
2638
+ };
2639
+ if (params?.conversationId !== undefined) record.stats.setConversationId(params.conversationId);
2640
+ record.measure.timer = setTimeout(() => this.commitTimeout(key), 10_000);
2641
+ this.records.set(key, record);
2642
+ }
2643
+
2644
+ markChannelJoined(conversationId?: number, channelUrl?: string): void {
2645
+ const record = this.getRecord(channelUrl);
2646
+ if (!record) return;
2647
+ record.stats.setPhaseReached('channel_join');
2648
+ if (conversationId !== undefined) record.stats.setConversationId(conversationId);
2649
+ }
2650
+
2651
+ markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2652
+ const record = this.getRecord(channelUrl);
2653
+ if (!record) return;
2654
+ record.measure.historyLoadResult = result;
2655
+ record.stats.setHistoryLoadResult(result).setPhaseReached('history_load');
2656
+ this.cancelIfReady(channelUrl);
2657
+ }
2658
+
2659
+ markInputReady(channelUrl?: string): void {
2660
+ const record = this.getRecord(channelUrl);
2661
+ if (!record) return;
2662
+ record.measure.inputEnabled = true;
2663
+ record.stats.setPhaseReached('input_enable');
2376
2664
  this.cancelIfReady(channelUrl);
2377
2665
  }
2378
2666
 
2379
2667
  markRendered(channelUrl?: string): void {
2380
- const measurement = this.getMeasurement(channelUrl);
2381
- if (!measurement || measurement.committed) return;
2382
- measurement.phaseReached = 'render';
2668
+ const record = this.getRecord(channelUrl);
2669
+ if (!record) return;
2670
+ record.stats.setPhaseReached('render');
2383
2671
  }
2384
2672
 
2385
2673
  clear(): void {
2386
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
2387
- this.measurements.clear();
2674
+ this.records.forEach((record) => {
2675
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
2676
+ });
2677
+ this.records.clear();
2388
2678
  }
2389
2679
 
2390
2680
  clearChannel(channelUrl?: string): void {
@@ -2393,39 +2683,32 @@ declare class ConversationNotInteractiveTimeoutStatsTracker {
2393
2683
  }
2394
2684
 
2395
2685
  private clearKey(key: string): void {
2396
- const measurement = this.measurements.get(key);
2397
- if (!measurement) return;
2398
- clearTimer(measurement.timer, this.clearTimeoutFn);
2399
- this.measurements.delete(key);
2686
+ const record = this.records.get(key);
2687
+ if (!record) return;
2688
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
2689
+ this.records.delete(key);
2400
2690
  }
2401
2691
 
2402
2692
  private cancelIfReady(channelUrl?: string): void {
2403
- const measurement = this.getMeasurement(channelUrl);
2404
- if (!measurement || !measurement.inputEnabled || measurement.historyLoadResult === 'pending') return;
2405
- this.clearKey(this.getKey(measurement.channelUrl));
2693
+ const record = this.getRecord(channelUrl);
2694
+ if (!record || !record.measure.inputEnabled || record.measure.historyLoadResult === 'pending') return;
2695
+ this.clearKey(this.getKey(record.measure.channelUrl));
2406
2696
  }
2407
2697
 
2408
2698
  private commitTimeout(key: string): void {
2409
- const measurement = this.measurements.get(key);
2410
- if (!measurement || measurement.committed) return;
2411
- measurement.committed = true;
2412
- measurement.timer = null;
2413
- append(this.callback, METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT, this.clock.now() - measurement.startedAt, {
2414
- conversationId: measurement.conversationId,
2415
- error: { message: METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT },
2416
- extra: {
2417
- outcome: 'timeout',
2418
- phase_reached: measurement.phaseReached,
2419
- history_load_result: measurement.historyLoadResult,
2420
- },
2421
- });
2422
- this.measurements.delete(key);
2699
+ const record = this.records.get(key);
2700
+ if (!record) return;
2701
+ record.measure.timer = null;
2702
+ this.records.delete(key);
2703
+ record.stats.stopTimer(DurationKey.TIMEOUT).commit();
2423
2704
  }
2424
2705
 
2425
- private getMeasurement(channelUrl?: string): ConversationNotInteractiveMeasurement | undefined {
2426
- if (channelUrl !== undefined) return this.measurements.get(this.getKey(channelUrl));
2427
- if (this.measurements.size === 1) return Array.from(this.measurements.values())[0];
2428
- return this.measurements.get(this.getKey());
2706
+ private getRecord(
2707
+ channelUrl?: string,
2708
+ ): StatsRecord<ConversationNotInteractiveTimeoutStats, ConversationNotInteractiveMeasure> | undefined {
2709
+ if (channelUrl !== undefined) return this.records.get(this.getKey(channelUrl));
2710
+ if (this.records.size === 1) return this.records.values().next().value;
2711
+ return this.records.get(this.getKey());
2429
2712
  }
2430
2713
 
2431
2714
  private getKey(channelUrl?: string): string {
@@ -2440,7 +2723,14 @@ export declare type ConversationProps = PropsWithChildren<{
2440
2723
  shouldMarkAsRead?: boolean;
2441
2724
  /** @internal */
2442
2725
  shouldInitializeConversation?: boolean;
2726
+ /**
2727
+ * Message to send as the first user message when the conversation is initialized
2728
+ * on the server (e.g., the callback user message of a clicked nudge message).
2729
+ * Applied only when the channel has no conversation yet.
2730
+ */
2731
+ initialUserMessage?: string;
2443
2732
  announcementsEnabled?: boolean;
2733
+ /** @default true */
2444
2734
  conversationStatsEnabled?: boolean;
2445
2735
  initialFocusTarget?: ConversationInitialFocusTarget;
2446
2736
  /** Custom styles for the conversation container. */
@@ -2638,11 +2928,20 @@ declare interface Dispatcher {
2638
2928
  }
2639
2929
 
2640
2930
  declare enum DurationKey {
2931
+ ACK_TO_FIRST_CHUNK = 'ack_to_first_chunk_ms',
2932
+ AFTER_TPEN_ABSENT = 'after_tpen_absent_ms',
2641
2933
  AUTH = 'auth_duration_ms',
2934
+ BEFORE_TPST_ABSENT = 'before_tpst_absent_ms',
2935
+ FIRST_CHUNK_TO_RENDER = 'first_chunk_to_render_ms',
2642
2936
  GET_CHANNEL = 'get_channel_duration_ms',
2937
+ GET_LIST = 'get_list_duration_ms',
2643
2938
  GET_MESSAGES = 'get_messages_duration_ms',
2644
- TOTAL_DURATION = 'total_duration_ms',
2645
- TOTAL_DURATION_FROM_CACHE = 'total_duration_from_cache_ms',
2939
+ // Internal timer key; the emitted value derives from it and is never emitted raw.
2940
+ PAGE_RENDER = 'page_render_duration_ms',
2941
+ // Emitted field name predates the auth/page-render split; kept for dashboard compatibility.
2942
+ PAGE_RENDER_FROM_CACHE = 'total_duration_from_cache_ms',
2943
+ SEND_TO_ACK = 'send_to_ack_ms',
2944
+ TIMEOUT = 'timeout_ms',
2646
2945
  }
2647
2946
 
2648
2947
  declare type ErrorAnnouncementParams =
@@ -2651,8 +2950,6 @@ declare type ErrorAnnouncementParams =
2651
2950
  | { type: 'csatFormError' }
2652
2951
  | { type: 'generic'; message: string };
2653
2952
 
2654
- declare type ErrorLike = StatsPayloadError;
2655
-
2656
2953
  /**
2657
2954
  * For better understanding: https://sendbird.atlassian.net/wiki/spaces/AA/pages/3075014695/Extended+Message+Payload+Spec
2658
2955
  */
@@ -2821,16 +3118,6 @@ export declare interface FunctionCallsInfo {
2821
3118
  };
2822
3119
  }
2823
3120
 
2824
- declare type GetChannelCompleteParams = {
2825
- channelUrl?: string;
2826
- conversationId?: number;
2827
- };
2828
-
2829
- declare type GetChannelErrorParams = {
2830
- channelUrl?: string;
2831
- error: Error;
2832
- };
2833
-
2834
3121
  export declare interface GroundednessInfo {
2835
3122
  id: number;
2836
3123
  source_type:
@@ -5210,92 +5497,139 @@ export declare const MessageListUILayout: {
5210
5497
 
5211
5498
  export declare const MessageLogs: ({ actionbook, functionCalls, groundedness, agentMessageTemplates, flaggedTypes, onClickActionbook, onClickFunctionCall, onClickFunctionCallDetail, onClickGroundedness, onClickAgentMessageTemplate, topContent, bottomContent, renderCustomGroundednessIcon, style, }: Props_2) => JSX.Element;
5212
5499
 
5213
- declare class MessagePendingStuckStatsTracker {
5214
- private readonly callback: StatsAppendCallback;
5215
- private readonly setTimeoutFn: typeof setTimeout;
5216
- private readonly clearTimeoutFn: typeof clearTimeout;
5217
- private readonly measurements = new Map<string, PendingMeasurement>();
5500
+ declare class MessagePendingStuckStats extends AIAgentBaseStats {
5501
+ private requestId?: string;
5502
+ private messageType?: PendingMessageType;
5503
+ private stuckReason?: PendingStuckReason;
5218
5504
 
5219
- constructor(callback: StatsAppendCallback, options?: PendingStuckTrackerOptions) {
5220
- this.callback = callback;
5221
- this.setTimeoutFn = getSetTimeout(options);
5222
- this.clearTimeoutFn = getClearTimeout(options);
5505
+ setRequestId(requestId: string): this {
5506
+ this.requestId = requestId;
5507
+ return this;
5223
5508
  }
5224
5509
 
5510
+ setMessageType(messageType: PendingMessageType): this {
5511
+ this.messageType = messageType;
5512
+ return this;
5513
+ }
5514
+
5515
+ setStuckReason(stuckReason: PendingStuckReason): this {
5516
+ this.stuckReason = stuckReason;
5517
+ return this;
5518
+ }
5519
+
5520
+ protected getMetricKey(): string {
5521
+ return METRIC_KEY_MESSAGE_PENDING_STUCK;
5522
+ }
5523
+
5524
+ protected buildPayload(): AIAgentStatPayload | null {
5525
+ if (this.requestId === undefined || this.messageType === undefined || this.stuckReason === undefined) return null;
5526
+
5527
+ return this.createPayload(this.stuckReason, {
5528
+ extra: {
5529
+ message_type: this.messageType,
5530
+ request_id: this.requestId,
5531
+ },
5532
+ });
5533
+ }
5534
+ }
5535
+
5536
+ /**
5537
+ * Spec: message_pending_stuck
5538
+ *
5539
+ * Detects a user message send that stays pending too long.
5540
+ *
5541
+ * Timeline:
5542
+ * - Start after the SDK registers a pending send with a request_id.
5543
+ * - Text messages commit after 5s without send completion.
5544
+ * - File messages commit after 10s without upload progress or send completion.
5545
+ * - Upload progress resets the file no-progress timer.
5546
+ *
5547
+ * Payload:
5548
+ * - value: stuck_reason = pending_too_long | upload_no_progress.
5549
+ * - extra.message_type, request_id.
5550
+ *
5551
+ * Policy:
5552
+ * - Send-scoped; each request_id is measured independently.
5553
+ * - Send completion, disappearance, or lifecycle abort clears the measurement
5554
+ * without emitting.
5555
+ * - Offline sends are excluded from measurement.
5556
+ * - There is no duration value and no resolved follow-up stat.
5557
+ */
5558
+ declare class MessagePendingStuckStatsTracker extends MultiStatsTrackerBase<MessagePendingStuckStats, PendingMeasure> {
5225
5559
  start(params: {
5226
5560
  requestId: string;
5227
5561
  channelUrl?: string;
5228
5562
  messageType: PendingMessageType;
5229
5563
  tracksUploadProgress?: boolean;
5230
5564
  }): void {
5231
- if (!params.requestId || this.measurements.has(params.requestId)) return;
5565
+ if (!params.requestId || this.records.has(params.requestId)) return;
5232
5566
 
5233
5567
  const usesUploadProgress = params.messageType !== 'text' && params.tracksUploadProgress === true;
5234
- const measurement: PendingMeasurement = {
5235
- channelUrl: params.channelUrl,
5236
- messageType: params.messageType,
5237
- stuckReason: usesUploadProgress ? 'upload_no_progress' : 'pending_too_long',
5238
- timer: null,
5568
+ const stats = new MessagePendingStuckStats()
5569
+ .setRequestId(params.requestId)
5570
+ .setMessageType(params.messageType)
5571
+ .setStuckReason(usesUploadProgress ? 'upload_no_progress' : 'pending_too_long')
5572
+ .setCommitCallback(this.commitCallback);
5573
+ const record: StatsRecord<MessagePendingStuckStats, PendingMeasure> = {
5574
+ stats,
5575
+ measure: {
5576
+ channelUrl: params.channelUrl,
5577
+ timer: null,
5578
+ usesUploadProgress,
5579
+ },
5239
5580
  };
5240
5581
  const timeoutMs = usesUploadProgress ? FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS : TEXT_PENDING_TIMEOUT_MS;
5241
- measurement.timer = this.setTimeoutFn(() => this.detect(params.requestId), timeoutMs);
5242
- this.measurements.set(params.requestId, measurement);
5582
+ record.measure.timer = setTimeout(() => this.detect(params.requestId), timeoutMs);
5583
+ this.records.set(params.requestId, record);
5243
5584
  }
5244
5585
 
5245
5586
  resolve(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);
5587
+ this.cancel(params.requestId);
5251
5588
  }
5252
5589
 
5253
5590
  disappear(params: { requestId: string }): void {
5254
- const measurement = this.measurements.get(params.requestId);
5255
- if (!measurement) return;
5591
+ this.cancel(params.requestId);
5592
+ }
5593
+
5594
+ private cancel(requestId: string): void {
5595
+ const record = this.records.get(requestId);
5596
+ if (!record) return;
5256
5597
 
5257
- clearTimer(measurement.timer, this.clearTimeoutFn);
5258
- this.measurements.delete(params.requestId);
5598
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5599
+ this.records.delete(requestId);
5259
5600
  }
5260
5601
 
5261
5602
  markUploadProgress(requestId: string): void {
5262
- const measurement = this.measurements.get(requestId);
5263
- if (!measurement) return;
5264
- if (measurement.stuckReason !== 'upload_no_progress') return;
5603
+ const record = this.records.get(requestId);
5604
+ if (!record) return;
5605
+ if (!record.measure.usesUploadProgress) return;
5265
5606
 
5266
- clearTimer(measurement.timer, this.clearTimeoutFn);
5267
- measurement.timer = this.setTimeoutFn(() => this.detect(requestId), FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS);
5607
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5608
+ record.measure.timer = setTimeout(() => this.detect(requestId), FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS);
5268
5609
  }
5269
5610
 
5270
5611
  clear(): void {
5271
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5272
- this.measurements.clear();
5612
+ this.records.forEach((record) => {
5613
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5614
+ });
5615
+ this.records.clear();
5273
5616
  }
5274
5617
 
5275
5618
  clearChannel(channelUrl?: string): void {
5276
5619
  if (!channelUrl) return;
5277
- this.measurements.forEach((measurement, requestId) => {
5278
- if (measurement.channelUrl !== channelUrl) return;
5279
- clearTimer(measurement.timer, this.clearTimeoutFn);
5280
- this.measurements.delete(requestId);
5620
+ this.records.forEach((record, requestId) => {
5621
+ if (record.measure.channelUrl !== channelUrl) return;
5622
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5623
+ this.records.delete(requestId);
5281
5624
  });
5282
5625
  }
5283
5626
 
5284
5627
  private detect(requestId: string): void {
5285
- const measurement = this.measurements.get(requestId);
5286
- if (!measurement) return;
5628
+ const record = this.records.get(requestId);
5629
+ if (!record) return;
5287
5630
 
5288
- this.measurements.delete(requestId);
5289
- this.commit(requestId, measurement);
5290
- }
5291
-
5292
- private commit(requestId: string, measurement: PendingMeasurement): void {
5293
- append(this.callback, METRIC_KEY_MESSAGE_PENDING_STUCK, measurement.stuckReason, {
5294
- extra: {
5295
- message_type: measurement.messageType,
5296
- request_id: requestId,
5297
- },
5298
- });
5631
+ this.records.delete(requestId);
5632
+ record.stats.commit();
5299
5633
  }
5300
5634
  }
5301
5635
 
@@ -5515,6 +5849,10 @@ declare type MultiSelectField = {
5515
5849
  };
5516
5850
  };
5517
5851
 
5852
+ declare abstract class MultiStatsTrackerBase<TStats, TMeasure> extends StatsTrackerBase {
5853
+ protected readonly records = new Map<string | number, StatsRecord<TStats, TMeasure>>();
5854
+ }
5855
+
5518
5856
  declare interface NetworkStateAdapter {
5519
5857
  /** Returns true if network is currently online */
5520
5858
  isOnline: () => boolean;
@@ -5522,6 +5860,13 @@ declare interface NetworkStateAdapter {
5522
5860
  subscribe: (callback: (isOnline: boolean) => void) => () => void;
5523
5861
  }
5524
5862
 
5863
+ export declare interface NudgeMessageResponse {
5864
+ /** Message to display above the launcher. */
5865
+ message: string;
5866
+ /** Message to send as the first user message when the nudge is clicked. */
5867
+ callbackUserMessage?: string;
5868
+ }
5869
+
5525
5870
  declare type NumberField = {
5526
5871
  key: string;
5527
5872
  type: 'number';
@@ -5665,6 +6010,14 @@ declare type OutgoingMessageUnion =
5665
6010
 
5666
6011
  declare function OutgoingTextBody(props: OutgoingMessageBodyProps): JSX.Element;
5667
6012
 
6013
+ declare type PageRenderState =
6014
+ | 'initialized'
6015
+ | 'channel_fetching'
6016
+ | 'channel_fetched'
6017
+ | 'channel_failed'
6018
+ | 'messages_fetching'
6019
+ | 'messages_fetching_completed';
6020
+
5668
6021
  declare interface PaletteColor {
5669
6022
  extraDark: string;
5670
6023
  dark: string;
@@ -5713,19 +6066,16 @@ declare type PendingAIResponseStart = {
5713
6066
  startedAt: number;
5714
6067
  };
5715
6068
 
5716
- declare type PendingMeasurement = {
6069
+ declare type PendingMeasure = {
5717
6070
  channelUrl?: string;
5718
- messageType: PendingMessageType;
5719
- stuckReason: PendingStuckReason;
5720
6071
  timer: TimeoutId | null;
6072
+ usesUploadProgress: boolean;
5721
6073
  };
5722
6074
 
5723
6075
  declare type PendingMessageType = 'text' | 'file_image' | 'file_video' | 'file_document' | 'file_audio';
5724
6076
 
5725
6077
  declare type PendingStuckReason = 'pending_too_long' | 'upload_no_progress';
5726
6078
 
5727
- declare type PendingStuckTrackerOptions = Pick<TimedTrackerOptions, 'setTimeout' | 'clearTimeout'>;
5728
-
5729
6079
  declare type PickMessageProps<
5730
6080
  Union,
5731
6081
  T extends Union extends { messageType: infer MT } ? MT : never = Union extends { messageType: infer MT } ? MT : never,
@@ -5863,74 +6213,113 @@ declare type Props_2 = {
5863
6213
 
5864
6214
  declare type ReactOnlyIconName = 'expand' | 'collapse' | 'chevron-right' | 'attach' | 'close-filled' | 'actionbook' | 'function' | 'confluence' | 'zendesk' | 'salesforce' | 'sprinklr' | 'website' | 'snippet' | 'template' | 'show' | 'mute' | 'activity';
5865
6215
 
5866
- declare type RecoveryMeasurement = {
5867
- timer: TimeoutId | null;
5868
- startedAt: number | null;
5869
- triggerType: RecoveryTriggerType | null;
6216
+ declare type RecoveryMeasure = {
5870
6217
  inputEnabled: boolean;
6218
+ started: boolean;
6219
+ timer: TimeoutId | null;
5871
6220
  };
5872
6221
 
5873
- declare class RecoveryNotInteractiveTimeoutStatsTracker {
5874
- private readonly callback: StatsAppendCallback;
5875
- private readonly clock: StatsClock;
5876
- private readonly setTimeoutFn: typeof setTimeout;
5877
- private readonly clearTimeoutFn: typeof clearTimeout;
5878
- private readonly measurements = new Map<string, RecoveryMeasurement>();
6222
+ declare class RecoveryNotInteractiveTimeoutStats extends AIAgentBaseStats {
6223
+ private triggerType?: RecoveryTriggerType;
6224
+
6225
+ setTriggerType(triggerType: RecoveryTriggerType): this {
6226
+ this.triggerType = triggerType;
6227
+ return this;
6228
+ }
6229
+
6230
+ protected getMetricKey(): string {
6231
+ return METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT;
6232
+ }
6233
+
6234
+ protected buildPayload(): AIAgentStatPayload | null {
6235
+ const duration = this.getTimer(DurationKey.TIMEOUT).duration;
6236
+ if (duration === null || this.triggerType === undefined) return null;
5879
6237
 
5880
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
5881
- this.callback = callback;
5882
- this.clock = getClock(options);
5883
- this.setTimeoutFn = getSetTimeout(options);
5884
- this.clearTimeoutFn = getClearTimeout(options);
6238
+ return this.createPayload(duration, {
6239
+ extra: {
6240
+ trigger_type: this.triggerType,
6241
+ },
6242
+ });
5885
6243
  }
6244
+ }
5886
6245
 
6246
+ /**
6247
+ * Spec: recovery_not_interactive_timeout
6248
+ *
6249
+ * Detects a Conversation that does not become interactive within 10s after a
6250
+ * recovery trigger.
6251
+ *
6252
+ * Timeline:
6253
+ * - Start after foreground_resume when the app was backgrounded for at least
6254
+ * 5s, or after ws_reconnected.
6255
+ * - Skip the measurement when the input is already enabled.
6256
+ * - Cancel when input becomes enabled before the timeout.
6257
+ * - Commit only if the 10s timeout fires while input is still unavailable.
6258
+ *
6259
+ * Payload:
6260
+ * - value: total_duration_ms.
6261
+ * - extra.trigger_type: foreground_resume | ws_reconnected.
6262
+ *
6263
+ * Policy:
6264
+ * - Recovery-cycle scoped; one active measurement per Conversation/recovery
6265
+ * trigger.
6266
+ * - Successful recovery is not recorded by this metric.
6267
+ */
6268
+ declare class RecoveryNotInteractiveTimeoutStatsTracker extends MultiStatsTrackerBase<
6269
+ RecoveryNotInteractiveTimeoutStats,
6270
+ RecoveryMeasure
6271
+ > {
5887
6272
  start(triggerType: RecoveryTriggerType, channelUrl?: string): void {
5888
6273
  const key = this.getKey(channelUrl);
5889
- let measurement = this.measurements.get(key);
5890
- if (measurement && measurement.startedAt !== null) return;
5891
- if (measurement?.inputEnabled) return;
5892
-
5893
- measurement = {
5894
- startedAt: this.clock.now(),
5895
- triggerType,
5896
- inputEnabled: measurement?.inputEnabled ?? false,
5897
- timer: this.setTimeoutFn(() => this.commitTimeout(key), 10_000),
5898
- };
5899
- this.measurements.set(key, measurement);
6274
+ let record = this.records.get(key);
6275
+ if (record?.measure.started) return;
6276
+ if (record?.measure.inputEnabled) return;
6277
+
6278
+ if (!record) {
6279
+ record = this.createRecord(false);
6280
+ }
6281
+
6282
+ record.measure.started = true;
6283
+ record.stats = new RecoveryNotInteractiveTimeoutStats()
6284
+ .setTriggerType(triggerType)
6285
+ .setCommitCallback(this.commitCallback)
6286
+ .startTimer(DurationKey.TIMEOUT);
6287
+ record.measure.timer = setTimeout(() => this.commitTimeout(key), 10_000);
6288
+ this.records.set(key, record);
5900
6289
  }
5901
6290
 
5902
6291
  setInputEnabled(enabled: boolean, channelUrl?: string): void {
5903
6292
  const key = this.getKey(channelUrl);
5904
- let measurement = this.measurements.get(key);
6293
+ let record = this.records.get(key);
5905
6294
 
5906
- if (!measurement) {
6295
+ if (!record) {
5907
6296
  if (!enabled) return;
5908
- measurement = {
5909
- startedAt: null,
5910
- triggerType: null,
5911
- inputEnabled: enabled,
5912
- timer: null,
5913
- };
5914
- this.measurements.set(key, measurement);
6297
+ record = this.createRecord(enabled);
6298
+ this.records.set(key, record);
6299
+ return;
5915
6300
  }
5916
6301
 
5917
- measurement.inputEnabled = enabled;
6302
+ record.measure.inputEnabled = enabled;
5918
6303
  if (enabled) {
5919
- this.cancelMeasurement(measurement);
6304
+ this.cancelRecord(record);
6305
+ } else if (!record.measure.started) {
6306
+ this.records.delete(key);
5920
6307
  }
5921
6308
  }
5922
6309
 
5923
6310
  cancelPending(channelUrl: string): void {
5924
6311
  const key = this.getKey(channelUrl);
5925
- const measurement = this.measurements.get(key);
5926
- if (!measurement) return;
5927
- this.cancelMeasurement(measurement);
5928
- if (!measurement.inputEnabled) this.measurements.delete(key);
6312
+ const record = this.records.get(key);
6313
+ if (!record) return;
6314
+ this.cancelRecord(record);
6315
+ if (!record.measure.inputEnabled) this.records.delete(key);
5929
6316
  }
5930
6317
 
5931
6318
  clear(): void {
5932
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5933
- this.measurements.clear();
6319
+ this.records.forEach((record) => {
6320
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6321
+ });
6322
+ this.records.clear();
5934
6323
  }
5935
6324
 
5936
6325
  clearChannel(channelUrl?: string): void {
@@ -5939,35 +6328,41 @@ declare class RecoveryNotInteractiveTimeoutStatsTracker {
5939
6328
  }
5940
6329
 
5941
6330
  private clearKey(key: string): void {
5942
- const measurement = this.measurements.get(key);
5943
- if (!measurement) return;
5944
- clearTimer(measurement.timer, this.clearTimeoutFn);
5945
- this.measurements.delete(key);
6331
+ const record = this.records.get(key);
6332
+ if (!record) return;
6333
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6334
+ this.records.delete(key);
5946
6335
  }
5947
6336
 
5948
6337
  private commitTimeout(key: string): void {
5949
- const measurement = this.measurements.get(key);
5950
- if (!measurement || measurement.startedAt === null || measurement.triggerType === null) return;
5951
- if (measurement.inputEnabled) {
5952
- this.cancelMeasurement(measurement);
6338
+ const record = this.records.get(key);
6339
+ if (!record || !record.measure.started) return;
6340
+ if (record.measure.inputEnabled) {
6341
+ this.cancelRecord(record);
5953
6342
  return;
5954
6343
  }
5955
- const duration = this.clock.now() - measurement.startedAt;
5956
- const triggerType = measurement.triggerType;
6344
+ record.stats.stopTimer(DurationKey.TIMEOUT);
5957
6345
  // Drop the entry so a subsequent trigger can record a fresh cycle.
5958
- this.measurements.delete(key);
5959
- append(this.callback, METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT, duration, {
5960
- extra: {
5961
- trigger_type: triggerType,
5962
- },
5963
- });
6346
+ this.records.delete(key);
6347
+ record.stats.commit();
6348
+ }
6349
+
6350
+ private cancelRecord(record: StatsRecord<RecoveryNotInteractiveTimeoutStats, RecoveryMeasure>): void {
6351
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6352
+ record.measure.timer = null;
6353
+ record.measure.started = false;
6354
+ record.stats = new RecoveryNotInteractiveTimeoutStats().setCommitCallback(this.commitCallback);
5964
6355
  }
5965
6356
 
5966
- private cancelMeasurement(measurement: RecoveryMeasurement): void {
5967
- clearTimer(measurement.timer, this.clearTimeoutFn);
5968
- measurement.timer = null;
5969
- measurement.startedAt = null;
5970
- measurement.triggerType = null;
6357
+ private createRecord(inputEnabled: boolean): StatsRecord<RecoveryNotInteractiveTimeoutStats, RecoveryMeasure> {
6358
+ return {
6359
+ stats: new RecoveryNotInteractiveTimeoutStats().setCommitCallback(this.commitCallback),
6360
+ measure: {
6361
+ inputEnabled,
6362
+ started: false,
6363
+ timer: null,
6364
+ },
6365
+ };
5971
6366
  }
5972
6367
 
5973
6368
  private getKey(channelUrl?: string): string {
@@ -5987,39 +6382,79 @@ declare type ReleaseDisabledByValue =
5987
6382
  | 'reconnecting'
5988
6383
  | 'handoff_pending';
5989
6384
 
5990
- declare class RenderedContentUnusableStatsTracker {
5991
- private readonly callback: StatsAppendCallback;
5992
- private readonly committed = new Set<string>();
6385
+ declare type RenderedContentError = {
6386
+ code?: number;
6387
+ message?: string;
6388
+ };
5993
6389
 
5994
- constructor(callback: StatsAppendCallback) {
5995
- this.callback = callback;
5996
- }
6390
+ declare type RenderedContentErrorDomain = 'empty_render' | 'template_error' | 'form_error';
6391
+
6392
+ declare type RenderedContentKind = 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
6393
+
6394
+ declare type RenderedContentUnusableMeasure = {
6395
+ committed: Set<string>;
6396
+ };
6397
+
6398
+ /**
6399
+ * Spec: rendered_content_unusable
6400
+ *
6401
+ * Records AI response content that reaches the render pipeline but cannot be
6402
+ * presented as usable visible content.
6403
+ *
6404
+ * Timeline:
6405
+ * - Trigger on final render pipeline failures such as empty_render or
6406
+ * template_error.
6407
+ * - Commit immediately when the SDK-owned renderer determines the content is
6408
+ * unusable.
6409
+ *
6410
+ * Payload:
6411
+ * - value: error_domain.
6412
+ * - extra.content_kind, message_id.
6413
+ *
6414
+ * Policy:
6415
+ * - Message/content scoped; duplicate failures for the same message, content
6416
+ * kind, and domain are suppressed.
6417
+ * - Transient network failures and retries that later render successfully are
6418
+ * not the target of this metric.
6419
+ */
6420
+ declare class RenderedContentUnusableStatsTracker extends StatsTrackerBase {
6421
+ private readonly measure: RenderedContentUnusableMeasure = {
6422
+ committed: new Set<string>(),
6423
+ };
5997
6424
 
5998
6425
  commit(params: {
5999
6426
  messageId: string | number;
6000
- contentKind: 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
6001
- errorDomain: 'empty_render' | 'template_error' | 'form_error';
6002
- error?: ErrorLike;
6427
+ contentKind: RenderedContentKind;
6428
+ errorDomain: RenderedContentErrorDomain;
6429
+ error?: RenderedContentError;
6003
6430
  }): void {
6004
6431
  const key = `${params.messageId}:${params.contentKind}:${params.errorDomain}`;
6005
- if (this.committed.has(key)) return;
6006
- evictOldestIfAtLimit(this.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
6007
- this.committed.add(key);
6432
+ if (this.measure.committed.has(key)) return;
6433
+ evictOldestIfAtLimit(this.measure.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
6434
+ this.measure.committed.add(key);
6008
6435
 
6009
- append(this.callback, METRIC_KEY_RENDERED_CONTENT_UNUSABLE, params.errorDomain, {
6010
- error: params.error ? { code: params.error.code, message: params.errorDomain } : undefined,
6011
- extra: {
6012
- content_kind: params.contentKind,
6013
- message_id: String(params.messageId),
6014
- },
6015
- });
6436
+ const stats = new RenderedContentUnusableStats()
6437
+ .setMessageId(params.messageId)
6438
+ .setContentKind(params.contentKind)
6439
+ .setErrorDomain(params.errorDomain)
6440
+ .setError(params.error ? { code: params.error.code, message: params.errorDomain } : undefined)
6441
+ .setCommitCallback(this.commitCallback);
6442
+
6443
+ stats.commit();
6016
6444
  }
6017
6445
 
6018
6446
  clear(): void {
6019
- this.committed.clear();
6447
+ this.measure.committed.clear();
6020
6448
  }
6021
6449
  }
6022
6450
 
6451
+ export declare interface RequestNudgeMessageParams {
6452
+ /** Event key that triggers the nudge message. */
6453
+ key: string;
6454
+ /** Additional context for the server to generate the message. */
6455
+ context?: Record<string, string>;
6456
+ }
6457
+
6023
6458
  declare interface ResolvedHandlers extends AgentClientHandlers {
6024
6459
  onClickLink: (params: {
6025
6460
  url: string;
@@ -6059,17 +6494,95 @@ declare type SingleSelectField = {
6059
6494
  layout: 'default';
6060
6495
  };
6061
6496
 
6062
- declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
6497
+ declare abstract class SpanStatsTrackerBase<
6498
+ TSpan extends { onVisibilityChanged(visible: boolean): void },
6499
+ > extends StatsTrackerBase {
6500
+ protected activeSpan: TSpan | null = null;
6501
+ // Screen visibility is cross-attempt state (it outlives supersessions), so the
6502
+ // tracker owns it and stamps it into each new span, like presentMethod.
6503
+ protected visible: boolean = true;
6063
6504
 
6064
- declare interface StatsClock {
6065
- now: () => number;
6505
+ setVisible(visible: boolean): void {
6506
+ this.visible = visible;
6507
+ this.activeSpan?.onVisibilityChanged(visible);
6508
+ }
6066
6509
  }
6067
6510
 
6068
- declare type StatsPayloadError = {
6069
- code?: number;
6070
- message?: string;
6511
+ declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
6512
+
6513
+ declare type StatsPayloadParams = {
6514
+ conversationId?: number;
6515
+ extra?: Record<string, unknown>;
6071
6516
  };
6072
6517
 
6518
+ declare type StatsRecord<TStats, TMeasure> = { stats: TStats; measure: TMeasure };
6519
+
6520
+ /**
6521
+ * One measurement attempt issued by a tracker (`startSpan`), reified as a handle.
6522
+ *
6523
+ * A span is a StatsRecord with lifecycle: `stats` is the metric instance and `measure`
6524
+ * is the tracking metadata, exactly like a keyed record in MultiStatsTrackerBase. The
6525
+ * two idioms differ only in how the record is held — looked up by an entity key versus
6526
+ * captured as a handle at the moment the measured work starts, so late async events
6527
+ * land on the span that owns them instead of whichever measurement is currently active.
6528
+ *
6529
+ * Cancelling severs the metric reference: a superseded or abandoned span becomes
6530
+ * non-recording and can never emit, without any caller-side staleness guards.
6531
+ *
6532
+ * Emission is visibility-gated: the measurement runs regardless of screen
6533
+ * visibility, but a completion reached while hidden (commitOrDefer) is held and
6534
+ * flushed by onVisibilityChanged when the screen becomes visible.
6535
+ */
6536
+ declare abstract class StatsSpanBase<TStats extends AIAgentBaseStats, TMeasure> {
6537
+ protected stats: TStats | null;
6538
+ protected abstract measure: TMeasure;
6539
+ private visible: boolean;
6540
+ private completedHidden = false;
6541
+
6542
+ constructor(stats: TStats | null, visible: boolean) {
6543
+ this.stats = stats;
6544
+ this.visible = visible;
6545
+ }
6546
+
6547
+ get isRecording(): boolean {
6548
+ return this.stats !== null && !this.stats.isCommitted();
6549
+ }
6550
+
6551
+ onVisibilityChanged(visible: boolean): void {
6552
+ if (!this.stats) return;
6553
+
6554
+ this.visible = visible;
6555
+ if (visible && this.completedHidden) {
6556
+ this.stats.commit();
6557
+ this.stats = null;
6558
+ }
6559
+ }
6560
+
6561
+ /** Commit now when the screen is visible; otherwise hold the frozen measurement until it becomes visible. */
6562
+ protected commitOrDefer(): void {
6563
+ if (!this.stats) return;
6564
+
6565
+ if (!this.visible) {
6566
+ this.completedHidden = true;
6567
+ return;
6568
+ }
6569
+
6570
+ this.stats.commit();
6571
+ this.stats = null;
6572
+ }
6573
+
6574
+ cancel(): void {
6575
+ this.stats = null;
6576
+ }
6577
+ }
6578
+
6579
+ declare abstract class StatsTrackerBase {
6580
+ protected readonly commitCallback: StatsAppendCallback;
6581
+ constructor(commitCallback: StatsAppendCallback) {
6582
+ this.commitCallback = commitCallback;
6583
+ }
6584
+ }
6585
+
6073
6586
  /** Per-event override for the platform `announceStatus` wrappers — same shape as `AnnounceOptions`. */
6074
6587
  declare type StatusAnnouncementOptions = AnnounceOptions;
6075
6588
 
@@ -6123,20 +6636,75 @@ declare enum StewardTaskStatus {
6123
6636
  CANCELED = 'CANCELED',
6124
6637
  }
6125
6638
 
6126
- declare class StreamStalledStatsTracker {
6127
- private readonly callback: StatsAppendCallback;
6128
- private readonly clock: StatsClock;
6129
- private readonly setTimeoutFn: typeof setTimeout;
6130
- private readonly clearTimeoutFn: typeof clearTimeout;
6131
- private readonly streams = new Map<number, StreamState>();
6132
- private readonly emittedMessageChannels = new Map<number, string | undefined>();
6639
+ declare type StreamMeasure = {
6640
+ channelUrl?: string;
6641
+ deliveryTimer: TimeoutId | null;
6642
+ lastRawAt: number;
6643
+ lastRawLength: number;
6644
+ lastRenderAt: number;
6645
+ lastRenderedLength: number;
6646
+ renderTimer: TimeoutId | null;
6647
+ };
6648
+
6649
+ declare class StreamStalledStats extends AIAgentBaseStats {
6650
+ private durationMs?: number;
6651
+ private messageId?: number;
6652
+ private stallKind?: StreamStallKind;
6653
+
6654
+ setMessageId(messageId: number): this {
6655
+ this.messageId = messageId;
6656
+ return this;
6657
+ }
6658
+
6659
+ setStall(params: { stallKind: StreamStallKind; durationMs: number }): this {
6660
+ this.durationMs = params.durationMs;
6661
+ this.stallKind = params.stallKind;
6662
+ return this;
6663
+ }
6664
+
6665
+ protected getMetricKey(): string {
6666
+ return METRIC_KEY_STREAM_STALLED;
6667
+ }
6668
+
6669
+ protected buildPayload(): AIAgentStatPayload | null {
6670
+ if (this.durationMs === undefined || this.messageId === undefined || this.stallKind === undefined) return null;
6133
6671
 
6134
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6135
- this.callback = callback;
6136
- this.clock = getClock(options);
6137
- this.setTimeoutFn = getSetTimeout(options);
6138
- this.clearTimeoutFn = getClearTimeout(options);
6672
+ return this.createPayload(this.durationMs, {
6673
+ extra: {
6674
+ message_id: String(this.messageId),
6675
+ stall_kind: this.stallKind,
6676
+ },
6677
+ });
6139
6678
  }
6679
+ }
6680
+
6681
+ /**
6682
+ * Spec: stream_stalled
6683
+ *
6684
+ * Detects AI response stream stalls and classifies the stalled area. Tracking
6685
+ * is keyed by message_id because multiple AI response messages can be active
6686
+ * independently.
6687
+ *
6688
+ * Timeline:
6689
+ * - Start when a streaming AI response produces raw data for a message_id.
6690
+ * - delivery stall: raw response data does not advance for 10s.
6691
+ * - render stall: raw response data is ahead/arriving, but visible render
6692
+ * progress does not advance for 5s.
6693
+ * - Raw progress refreshes the delivery checkpoint; rendered progress refreshes
6694
+ * the render checkpoint.
6695
+ *
6696
+ * Payload:
6697
+ * - value: stall_duration_ms.
6698
+ * - extra.stall_kind: delivery | render.
6699
+ * - extra.message_id: tracked AI response message id.
6700
+ *
6701
+ * Policy:
6702
+ * - Message-scoped; first stall only per message_id.
6703
+ * - Delivery and render stalls are mutually exclusive for the same turn.
6704
+ * - A short response completing before either threshold is not recorded.
6705
+ */
6706
+ declare class StreamStalledStatsTracker extends MultiStatsTrackerBase<StreamStalledStats, StreamMeasure> {
6707
+ private readonly emittedMessageChannels = new Map<number, string | undefined>();
6140
6708
 
6141
6709
  markRawProgress(params: {
6142
6710
  messageId: number;
@@ -6151,54 +6719,52 @@ declare class StreamStalledStatsTracker {
6151
6719
 
6152
6720
  if (this.emittedMessageChannels.has(params.messageId)) return;
6153
6721
 
6154
- const stream = this.getOrCreate(params.messageId, params.channelUrl);
6155
-
6156
- if (params.textLength > stream.lastRawLength) {
6157
- stream.chunksReceived += 1;
6158
- stream.lastRawLength = params.textLength;
6159
- stream.lastRawAt = this.clock.now();
6722
+ const record = this.getOrCreate(params.messageId, params.channelUrl);
6723
+ const measure = record.measure;
6160
6724
 
6161
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6162
- stream.deliveryTimer = null;
6163
- this.scheduleDeliveryStallCheck(params.messageId, stream);
6725
+ if (this.markRawProgressForRecord(record, params.textLength, performance.now())) {
6726
+ if (measure.deliveryTimer !== null) clearTimeout(measure.deliveryTimer);
6727
+ measure.deliveryTimer = null;
6728
+ this.scheduleDeliveryStallCheck(params.messageId, record);
6164
6729
  } else {
6165
- this.scheduleDeliveryStallCheck(params.messageId, stream);
6730
+ this.scheduleDeliveryStallCheck(params.messageId, record);
6166
6731
  }
6167
6732
 
6168
- if (stream.lastRenderedLength < stream.lastRawLength && stream.renderTimer === null) {
6169
- stream.lastRenderAt = this.clock.now();
6170
- this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6733
+ if (this.isRenderPending(record) && measure.renderTimer === null) {
6734
+ measure.lastRenderAt = performance.now();
6735
+ this.scheduleRenderStallCheck(params.messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6171
6736
  }
6172
6737
  }
6173
6738
 
6174
6739
  markRenderedProgress(params: { messageId: number; displayedLength: number }): void {
6175
- const stream = this.streams.get(params.messageId);
6176
- if (!stream || params.displayedLength <= stream.lastRenderedLength) return;
6740
+ const record = this.records.get(params.messageId);
6741
+ if (!record) return;
6742
+ const measure = record.measure;
6177
6743
 
6178
- stream.lastRenderedLength = params.displayedLength;
6179
- stream.lastRenderAt = this.clock.now();
6744
+ const progressed = this.markRenderedProgressForRecord(record, params.displayedLength, performance.now());
6745
+ if (!progressed) return;
6180
6746
 
6181
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6182
- if (stream.deliveryTimer === null) {
6183
- this.clearStream(params.messageId, stream);
6747
+ if (!this.isRenderPending(record)) {
6748
+ if (measure.deliveryTimer === null) {
6749
+ this.clearRecord(params.messageId, record);
6184
6750
  return;
6185
6751
  }
6186
6752
 
6187
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6188
- stream.renderTimer = null;
6753
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6754
+ measure.renderTimer = null;
6189
6755
  return;
6190
6756
  }
6191
6757
 
6192
- this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6758
+ this.scheduleRenderStallCheck(params.messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6193
6759
  }
6194
6760
 
6195
6761
  hasPendingStream(messageId: number): boolean {
6196
- return this.streams.has(messageId);
6762
+ return this.records.has(messageId);
6197
6763
  }
6198
6764
 
6199
6765
  complete(messageId: number): void {
6200
- const stream = this.streams.get(messageId);
6201
- if (stream) this.clearStream(messageId, stream);
6766
+ const record = this.records.get(messageId);
6767
+ if (record) this.clearRecord(messageId, record);
6202
6768
  }
6203
6769
 
6204
6770
  // Final stream update: no more raw chunks are coming, so stop delivery-stall
@@ -6208,52 +6774,53 @@ declare class StreamStalledStatsTracker {
6208
6774
  // raw length — otherwise a renderer that freezes mid-catch-up never emits a
6209
6775
  // 'render' stall.
6210
6776
  private completeDelivery(messageId: number, finalRawLength: number): void {
6211
- const stream = this.streams.get(messageId);
6212
- if (!stream) return;
6777
+ const record = this.records.get(messageId);
6778
+ if (!record) return;
6779
+ const measure = record.measure;
6213
6780
 
6214
6781
  // The final stream:false payload can carry text beyond the last streamed
6215
6782
  // chunk; fold it into the raw length first so the render watch targets the
6216
6783
  // full text the animation still has to reveal (otherwise the stream could be
6217
6784
  // cleared while a freeze on that trailing text would go unreported).
6218
- if (finalRawLength > stream.lastRawLength) {
6219
- stream.lastRawLength = finalRawLength;
6220
- stream.lastRawAt = this.clock.now();
6785
+ if (finalRawLength > measure.lastRawLength) {
6786
+ measure.lastRawLength = finalRawLength;
6787
+ measure.lastRawAt = performance.now();
6221
6788
  }
6222
6789
 
6223
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6224
- stream.deliveryTimer = null;
6790
+ if (measure.deliveryTimer !== null) clearTimeout(measure.deliveryTimer);
6791
+ measure.deliveryTimer = null;
6225
6792
 
6226
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6227
- this.clearStream(messageId, stream);
6793
+ if (!this.isRenderPending(record)) {
6794
+ this.clearRecord(messageId, record);
6228
6795
  return;
6229
6796
  }
6230
6797
 
6231
- if (stream.renderTimer === null) {
6232
- stream.lastRenderAt = this.clock.now();
6233
- this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6798
+ if (measure.renderTimer === null) {
6799
+ measure.lastRenderAt = performance.now();
6800
+ this.scheduleRenderStallCheck(messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6234
6801
  }
6235
6802
  }
6236
6803
 
6237
6804
  clearMessage(messageId: number): void {
6238
- const stream = this.streams.get(messageId);
6239
- if (stream) this.clearStream(messageId, stream);
6805
+ const record = this.records.get(messageId);
6806
+ if (record) this.clearRecord(messageId, record);
6240
6807
  this.emittedMessageChannels.delete(messageId);
6241
6808
  }
6242
6809
 
6243
6810
  clear(): void {
6244
- this.streams.forEach((stream) => {
6245
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6246
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6811
+ this.records.forEach((record) => {
6812
+ if (record.measure.deliveryTimer !== null) clearTimeout(record.measure.deliveryTimer);
6813
+ if (record.measure.renderTimer !== null) clearTimeout(record.measure.renderTimer);
6247
6814
  });
6248
- this.streams.clear();
6815
+ this.records.clear();
6249
6816
  this.emittedMessageChannels.clear();
6250
6817
  }
6251
6818
 
6252
6819
  clearChannel(channelUrl?: string): void {
6253
6820
  if (!channelUrl) return;
6254
- this.streams.forEach((stream, messageId) => {
6255
- if (stream.channelUrl !== channelUrl && stream.channelUrl !== undefined) return;
6256
- this.clearStream(messageId, stream);
6821
+ this.records.forEach((record, messageId) => {
6822
+ if (record.measure.channelUrl !== channelUrl && record.measure.channelUrl !== undefined) return;
6823
+ this.clearRecord(messageId, record);
6257
6824
  });
6258
6825
  this.emittedMessageChannels.forEach((emittedChannelUrl, messageId) => {
6259
6826
  if (emittedChannelUrl === channelUrl || emittedChannelUrl === undefined)
@@ -6262,83 +6829,95 @@ declare class StreamStalledStatsTracker {
6262
6829
  }
6263
6830
 
6264
6831
  private detectDelivery(messageId: number): void {
6265
- const stream = this.streams.get(messageId);
6266
- if (!stream) return;
6267
- stream.deliveryTimer = null;
6832
+ const record = this.records.get(messageId);
6833
+ if (!record) return;
6834
+ const measure = record.measure;
6835
+ measure.deliveryTimer = null;
6268
6836
  // Mutex: cancel any in-flight render stall check so delivery wins this turn.
6269
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6270
- stream.renderTimer = null;
6271
- this.emit(messageId, stream, 'delivery');
6837
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6838
+ measure.renderTimer = null;
6839
+ this.emit(messageId, record, 'delivery');
6272
6840
  }
6273
6841
 
6274
6842
  private detectRender(messageId: number): void {
6275
- const stream = this.streams.get(messageId);
6276
- if (!stream) return;
6277
- stream.renderTimer = null;
6278
- if (stream.lastRenderedLength >= stream.lastRawLength) return;
6843
+ const record = this.records.get(messageId);
6844
+ if (!record) return;
6845
+ const measure = record.measure;
6846
+ measure.renderTimer = null;
6847
+ if (!this.isRenderPending(record)) return;
6279
6848
 
6280
- const elapsedSinceRender = this.clock.now() - stream.lastRenderAt;
6849
+ const elapsedSinceRender = performance.now() - measure.lastRenderAt;
6281
6850
  if (elapsedSinceRender < STREAM_RENDER_STALL_THRESHOLD_MS) {
6282
- this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6851
+ this.scheduleRenderStallCheck(messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6283
6852
  return;
6284
6853
  }
6285
6854
 
6286
6855
  // Mutex rule: when raw progress has been silent for at least the render threshold,
6287
6856
  // delivery is the root cause and will win. Defer the render emit until delivery has
6288
6857
  // had a chance to fire so we don't surface render as the symptom.
6289
- const elapsedSinceRaw = this.clock.now() - stream.lastRawAt;
6290
- if (elapsedSinceRaw >= STREAM_RENDER_STALL_THRESHOLD_MS && stream.deliveryTimer !== null) {
6858
+ const elapsedSinceRaw = performance.now() - measure.lastRawAt;
6859
+ if (elapsedSinceRaw >= STREAM_RENDER_STALL_THRESHOLD_MS && measure.deliveryTimer !== null) {
6291
6860
  const untilDelivery = STREAM_DELIVERY_STALL_THRESHOLD_MS - elapsedSinceRaw;
6292
6861
  if (untilDelivery > 0) {
6293
- this.scheduleRenderStallCheck(messageId, stream, untilDelivery);
6862
+ this.scheduleRenderStallCheck(messageId, record, untilDelivery);
6294
6863
  return;
6295
6864
  }
6296
6865
  }
6297
6866
 
6298
- this.emit(messageId, stream, 'render');
6867
+ this.emit(messageId, record, 'render');
6299
6868
  }
6300
6869
 
6301
- private scheduleRenderStallCheck(messageId: number, stream: StreamState, delayMs: number): void {
6302
- if (stream.renderTimer !== null) return;
6303
- stream.renderTimer = this.setTimeoutFn(() => this.detectRender(messageId), delayMs);
6870
+ private scheduleRenderStallCheck(
6871
+ messageId: number,
6872
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6873
+ delayMs: number,
6874
+ ): void {
6875
+ if (record.measure.renderTimer !== null) return;
6876
+ record.measure.renderTimer = setTimeout(() => this.detectRender(messageId), delayMs);
6304
6877
  }
6305
6878
 
6306
- private scheduleDeliveryStallCheck(messageId: number, stream: StreamState): void {
6307
- if (stream.deliveryTimer !== null) return;
6308
- stream.deliveryTimer = this.setTimeoutFn(() => this.detectDelivery(messageId), STREAM_DELIVERY_STALL_THRESHOLD_MS);
6879
+ private scheduleDeliveryStallCheck(messageId: number, record: StatsRecord<StreamStalledStats, StreamMeasure>): void {
6880
+ if (record.measure.deliveryTimer !== null) return;
6881
+ record.measure.deliveryTimer = setTimeout(() => this.detectDelivery(messageId), STREAM_DELIVERY_STALL_THRESHOLD_MS);
6309
6882
  }
6310
6883
 
6311
- private emit(messageId: number, stream: StreamState, stallKind: StreamStallKind): void {
6884
+ private emit(
6885
+ messageId: number,
6886
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6887
+ stallKind: StreamStallKind,
6888
+ ): void {
6312
6889
  if (this.emittedMessageChannels.has(messageId)) return;
6313
6890
 
6314
- const stallStartedAt = stallKind === 'delivery' ? stream.lastRawAt : stream.lastRenderAt;
6315
- append(this.callback, METRIC_KEY_STREAM_STALLED, this.clock.now() - stallStartedAt, {
6316
- extra: {
6317
- message_id: String(messageId),
6318
- stall_kind: stallKind,
6319
- },
6320
- });
6321
- this.rememberEmitted(messageId, stream.channelUrl);
6322
- this.clearStream(messageId, stream);
6891
+ const stalledAt = performance.now();
6892
+ const stallStartedAt = stallKind === 'delivery' ? record.measure.lastRawAt : record.measure.lastRenderAt;
6893
+ record.stats.setStall({ stallKind, durationMs: stalledAt - stallStartedAt }).commit();
6894
+ this.rememberEmitted(messageId, record.measure.channelUrl);
6895
+ this.clearRecord(messageId, record);
6323
6896
  }
6324
6897
 
6325
- private getOrCreate(messageId: number, channelUrl: string | undefined): StreamState {
6326
- const existing = this.streams.get(messageId);
6898
+ private getOrCreate(
6899
+ messageId: number,
6900
+ channelUrl: string | undefined,
6901
+ ): StatsRecord<StreamStalledStats, StreamMeasure> {
6902
+ const existing = this.records.get(messageId);
6327
6903
  if (existing) return existing;
6328
6904
 
6329
- const now = this.clock.now();
6330
- const stream: StreamState = {
6331
- channelUrl,
6332
- chunksReceived: 0,
6333
- lastRawAt: now,
6334
- lastRenderAt: now,
6335
- lastRawLength: 0,
6336
- lastRenderedLength: 0,
6337
- deliveryTimer: null,
6338
- renderTimer: null,
6905
+ const startedAt = performance.now();
6906
+
6907
+ const record: StatsRecord<StreamStalledStats, StreamMeasure> = {
6908
+ stats: new StreamStalledStats().setMessageId(messageId).setCommitCallback(this.commitCallback),
6909
+ measure: {
6910
+ channelUrl,
6911
+ deliveryTimer: null,
6912
+ lastRawAt: startedAt,
6913
+ lastRawLength: 0,
6914
+ lastRenderAt: startedAt,
6915
+ lastRenderedLength: 0,
6916
+ renderTimer: null,
6917
+ },
6339
6918
  };
6340
- this.streams.set(messageId, stream);
6341
- return stream;
6919
+ this.records.set(messageId, record);
6920
+ return record;
6342
6921
  }
6343
6922
 
6344
6923
  private rememberEmitted(messageId: number, channelUrl: string | undefined): void {
@@ -6348,26 +6927,43 @@ declare class StreamStalledStatsTracker {
6348
6927
  this.emittedMessageChannels.set(messageId, channelUrl);
6349
6928
  }
6350
6929
 
6351
- private clearStream(messageId: number, stream: StreamState): void {
6352
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6353
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6354
- this.streams.delete(messageId);
6930
+ private clearRecord(messageId: string | number, record: StatsRecord<StreamStalledStats, StreamMeasure>): void {
6931
+ if (record.measure.deliveryTimer !== null) clearTimeout(record.measure.deliveryTimer);
6932
+ if (record.measure.renderTimer !== null) clearTimeout(record.measure.renderTimer);
6933
+ this.records.delete(messageId);
6934
+ }
6935
+
6936
+ private isRenderPending(record: StatsRecord<StreamStalledStats, StreamMeasure>): boolean {
6937
+ return record.measure.lastRenderedLength < record.measure.lastRawLength;
6938
+ }
6939
+
6940
+ private markRawProgressForRecord(
6941
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6942
+ textLength: number,
6943
+ rawAt: number,
6944
+ ): boolean {
6945
+ if (textLength <= record.measure.lastRawLength) return false;
6946
+
6947
+ record.measure.lastRawLength = textLength;
6948
+ record.measure.lastRawAt = rawAt;
6949
+ return true;
6950
+ }
6951
+
6952
+ private markRenderedProgressForRecord(
6953
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6954
+ displayedLength: number,
6955
+ renderedAt: number,
6956
+ ): boolean {
6957
+ if (displayedLength <= record.measure.lastRenderedLength) return false;
6958
+
6959
+ record.measure.lastRenderedLength = displayedLength;
6960
+ record.measure.lastRenderAt = renderedAt;
6961
+ return true;
6355
6962
  }
6356
6963
  }
6357
6964
 
6358
6965
  declare type StreamStallKind = 'delivery' | 'render';
6359
6966
 
6360
- declare type StreamState = {
6361
- channelUrl?: string;
6362
- chunksReceived: number;
6363
- lastRawAt: number;
6364
- lastRenderAt: number;
6365
- lastRawLength: number;
6366
- lastRenderedLength: number;
6367
- deliveryTimer: TimeoutId | null;
6368
- renderTimer: TimeoutId | null;
6369
- };
6370
-
6371
6967
  /**
6372
6968
  * StringSet type for React components
6373
6969
  * Uses SNAKE_CASE keys for backward compatibility
@@ -6513,6 +7109,7 @@ export declare type StringSet = {
6513
7109
  A11Y_CONVERSATION_ITEM: (agent: string, status: string, time: string, count: number) => string;
6514
7110
  A11Y_CONNECT_AGENT: string;
6515
7111
  A11Y_CLOSE_MESSENGER: string;
7112
+ A11Y_DISMISS_NUDGE_MESSAGE: string;
6516
7113
  A11Y_EXPAND_MESSENGER: string;
6517
7114
  A11Y_COLLAPSE_MESSENGER: string;
6518
7115
  A11Y_HINT_SEND_MESSAGE: string;
@@ -6642,11 +7239,6 @@ declare type TextField = {
6642
7239
  };
6643
7240
  };
6644
7241
 
6645
- declare type TimedTrackerOptions = TrackerOptions & {
6646
- setTimeout?: typeof setTimeout;
6647
- clearTimeout?: typeof clearTimeout;
6648
- };
6649
-
6650
7242
  declare type TimeoutId = ReturnType<typeof setTimeout>;
6651
7243
 
6652
7244
  declare interface TimerData {
@@ -6654,42 +7246,94 @@ declare interface TimerData {
6654
7246
  endTime: number | null;
6655
7247
  }
6656
7248
 
6657
- declare type TrackerOptions = {
6658
- clock?: StatsClock;
7249
+ /** A never-started timer reads as all-null; duration stays null until the timer stops. */
7250
+ declare type TimerReading = {
7251
+ startAt: number | null;
7252
+ endedAt: number | null;
7253
+ duration: number | null;
6659
7254
  };
6660
7255
 
6661
- declare class TypingIndicatorAbsentTimeoutStatsTracker {
6662
- private readonly callback: StatsAppendCallback;
6663
- private readonly clock: StatsClock;
6664
- private readonly setTimeoutFn: typeof setTimeout;
6665
- private readonly clearTimeoutFn: typeof clearTimeout;
6666
- private readonly turns = new Map<string, TypingTurn>();
6667
- private readonly pendingAIResponseStartsByUserMessageId = new Map<number, PendingAIResponseStart>();
7256
+ declare class TypingIndicatorAbsentTimeoutStats extends AIAgentBaseStats {
7257
+ private beforeTpstAbsentMs?: number;
7258
+ private afterTpenAbsentMs?: number;
6668
7259
 
6669
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6670
- this.callback = callback;
6671
- this.clock = getClock(options);
6672
- this.setTimeoutFn = getSetTimeout(options);
6673
- this.clearTimeoutFn = getClearTimeout(options);
7260
+ setBeforeTpstAbsentMs(duration: number): this {
7261
+ this.beforeTpstAbsentMs = duration;
7262
+ return this;
6674
7263
  }
6675
7264
 
7265
+ setAfterTpenAbsentMs(duration: number): this {
7266
+ this.afterTpenAbsentMs = duration;
7267
+ return this;
7268
+ }
7269
+
7270
+ protected getMetricKey(): string {
7271
+ return METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT;
7272
+ }
7273
+
7274
+ protected buildPayload(): AIAgentStatPayload | null {
7275
+ if (this.beforeTpstAbsentMs === undefined && this.afterTpenAbsentMs === undefined) return null;
7276
+
7277
+ return this.createPayload((this.beforeTpstAbsentMs ?? 0) + (this.afterTpenAbsentMs ?? 0), {
7278
+ extra: {
7279
+ [DurationKey.BEFORE_TPST_ABSENT]: this.beforeTpstAbsentMs,
7280
+ [DurationKey.AFTER_TPEN_ABSENT]: this.afterTpenAbsentMs,
7281
+ },
7282
+ });
7283
+ }
7284
+ }
7285
+
7286
+ /**
7287
+ * Spec: ai_typing_indicator_absent_timeout
7288
+ *
7289
+ * Detects server typing signal gaps during an AI turn. The metric is emitted
7290
+ * only for AI conversations and tracks two absent phases in the same turn.
7291
+ *
7292
+ * Timeline:
7293
+ * - Start before-TPST measurement when the user message send turn starts.
7294
+ * - Close before-TPST when TPST/typing-start is observed.
7295
+ * - Start after-TPEN measurement when TPEN/typing-end is observed.
7296
+ * - Close after-TPEN when the AI response message starts streaming.
7297
+ * - Commit when either phase exceeds the 3s absent threshold, or when the
7298
+ * 30s collection safety timeout is reached.
7299
+ *
7300
+ * Payload:
7301
+ * - value: absent_duration_ms, the total measured absent duration.
7302
+ * - extra.before_tpst_absent_ms: gap before TPST, when present.
7303
+ * - extra.after_tpen_absent_ms: gap after TPEN, when present.
7304
+ *
7305
+ * Policy:
7306
+ * - Turn-scoped; each user message turn is measured independently.
7307
+ * - The Phase 2 example sums both measured absent phases in value, even when
7308
+ * only one phase reaches the threshold.
7309
+ * - Human-agent handoff/abort clears the pending turn without emitting.
7310
+ */
7311
+ declare class TypingIndicatorAbsentTimeoutStatsTracker extends MultiStatsTrackerBase<
7312
+ TypingIndicatorAbsentTimeoutStats,
7313
+ TypingMeasure
7314
+ > {
7315
+ private readonly pendingAIResponseStartsByUserMessageId = new Map<number, PendingAIResponseStart>();
7316
+
6676
7317
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6677
7318
  this.cancelTurn(params.requestId);
6678
- const turn: TypingTurn = {
6679
- requestId: params.requestId,
6680
- channelUrl: params.channelUrl,
6681
- beforeTpstStartedAt: this.clock.now(),
6682
- afterTpenStartedAt: null,
6683
- timer: null,
7319
+ const record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure> = {
7320
+ stats: new TypingIndicatorAbsentTimeoutStats().setCommitCallback(this.commitCallback),
7321
+ measure: {
7322
+ afterTpenStartedAt: null,
7323
+ beforeTpstStartedAt: performance.now(),
7324
+ channelUrl: params.channelUrl,
7325
+ requestId: params.requestId,
7326
+ timer: null,
7327
+ },
6684
7328
  };
6685
- turn.timer = this.setTimeoutFn(() => this.commitTimeout(turn.requestId), TYPING_COLLECTION_TIMEOUT_MS);
6686
- this.turns.set(params.requestId, turn);
7329
+ record.measure.timer = setTimeout(() => this.commitTimeout(record.measure.requestId), TYPING_COLLECTION_TIMEOUT_MS);
7330
+ this.records.set(params.requestId, record);
6687
7331
  }
6688
7332
 
6689
7333
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6690
- const turn = this.turns.get(params.requestId);
6691
- if (!turn) return;
6692
- turn.userMessageId = params.messageId;
7334
+ const record = this.records.get(params.requestId);
7335
+ if (!record) return;
7336
+ record.measure.userMessageId = params.messageId;
6693
7337
 
6694
7338
  const pending = this.pendingAIResponseStartsByUserMessageId.get(params.messageId);
6695
7339
  if (!pending) return;
@@ -6703,12 +7347,12 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6703
7347
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6704
7348
  if (currentRequestId === nextRequestId) return;
6705
7349
 
6706
- const turn = this.turns.get(currentRequestId);
6707
- if (!turn) return;
7350
+ const record = this.records.get(currentRequestId);
7351
+ if (!record) return;
6708
7352
 
6709
- this.turns.delete(currentRequestId);
6710
- turn.requestId = nextRequestId;
6711
- this.turns.set(nextRequestId, turn);
7353
+ this.records.delete(currentRequestId);
7354
+ record.measure.requestId = nextRequestId;
7355
+ this.records.set(nextRequestId, record);
6712
7356
  }
6713
7357
 
6714
7358
  cancelTurn(requestId: string): void {
@@ -6716,20 +7360,20 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6716
7360
  }
6717
7361
 
6718
7362
  markTypingStarted(channelUrl?: string): void {
6719
- const now = this.clock.now();
6720
- this.turns.forEach((turn) => {
6721
- if (!matchesChannel(turn, channelUrl)) return;
6722
- this.recordAfterTpen(turn, now);
6723
- this.recordBeforeTpst(turn, now);
7363
+ const now = performance.now();
7364
+ this.records.forEach((record) => {
7365
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
7366
+ this.recordAfterTpen(record, now);
7367
+ this.recordBeforeTpst(record, now);
6724
7368
  });
6725
7369
  }
6726
7370
 
6727
7371
  markTypingEnded(channelUrl?: string): void {
6728
- const now = this.clock.now();
6729
- this.turns.forEach((turn) => {
6730
- if (!matchesChannel(turn, channelUrl)) return;
6731
- this.recordBeforeTpst(turn, now);
6732
- turn.afterTpenStartedAt = now;
7372
+ const now = performance.now();
7373
+ this.records.forEach((record) => {
7374
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
7375
+ this.recordBeforeTpst(record, now);
7376
+ record.measure.afterTpenStartedAt = now;
6733
7377
  });
6734
7378
  }
6735
7379
 
@@ -6737,9 +7381,15 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6737
7381
  const channelUrl = params.channelUrl;
6738
7382
  const userMessageId = params.userMessageId;
6739
7383
  const matchesUserMessage = userMessageId !== undefined;
6740
- const now = this.clock.now();
7384
+ const now = performance.now();
6741
7385
  const matched = this.applyAIResponseStarted({ channelUrl, matchesUserMessage, userMessageId }, now);
6742
- if (matched || !matchesUserMessage || userMessageId === undefined || !this.hasUnackedTurn(channelUrl)) return;
7386
+ if (
7387
+ matched ||
7388
+ !matchesUserMessage ||
7389
+ userMessageId === undefined ||
7390
+ !hasUnackedChannelTurn(this.records.values(), channelUrl)
7391
+ )
7392
+ return;
6743
7393
 
6744
7394
  if (!this.pendingAIResponseStartsByUserMessageId.has(userMessageId)) {
6745
7395
  evictOldestIfAtLimit(this.pendingAIResponseStartsByUserMessageId, 100);
@@ -6754,16 +7404,16 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6754
7404
  if (!params.matchesUserMessage) {
6755
7405
  const match = this.findSingleTurn(params.channelUrl);
6756
7406
  if (!match) return false;
6757
- this.completeTurn(match.requestId, match.turn, startedAt);
7407
+ this.completeTurn(match.requestId, match.record, startedAt);
6758
7408
  return true;
6759
7409
  }
6760
7410
 
6761
7411
  let matched = false;
6762
- this.turns.forEach((turn, requestId) => {
6763
- if (!matchesChannel(turn, params.channelUrl)) return;
6764
- if (params.matchesUserMessage && turn.userMessageId !== params.userMessageId) return;
7412
+ this.records.forEach((record) => {
7413
+ if (!matchesChannel(record.measure.channelUrl, params.channelUrl)) return;
7414
+ if (params.matchesUserMessage && record.measure.userMessageId !== params.userMessageId) return;
6765
7415
  matched = true;
6766
- this.completeTurn(requestId, turn, startedAt);
7416
+ this.completeTurn(record.measure.requestId, record, startedAt);
6767
7417
  });
6768
7418
  return matched;
6769
7419
  }
@@ -6771,38 +7421,38 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6771
7421
  private findSingleTurn(channelUrl?: string): TypingTurnEntry | undefined {
6772
7422
  let match: TypingTurnEntry | undefined;
6773
7423
  let matchedCount = 0;
6774
- this.turns.forEach((turn, requestId) => {
6775
- if (!matchesChannel(turn, channelUrl)) return;
7424
+ this.records.forEach((record) => {
7425
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
6776
7426
  matchedCount += 1;
6777
7427
  if (matchedCount > 1) return;
6778
- match = { requestId, turn };
7428
+ match = { requestId: record.measure.requestId, record };
6779
7429
  });
6780
7430
  return matchedCount === 1 ? match : undefined;
6781
7431
  }
6782
7432
 
6783
- private completeTurn(requestId: string, turn: TypingTurn, startedAt: number): void {
6784
- this.recordBeforeTpst(turn, startedAt);
6785
- this.recordAfterTpen(turn, startedAt);
6786
- this.commitIfAbsent(requestId, turn);
6787
- }
6788
-
6789
- private hasUnackedTurn(channelUrl?: string): boolean {
6790
- return hasUnackedChannelTurn(this.turns.values(), channelUrl);
7433
+ private completeTurn(
7434
+ requestId: string,
7435
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
7436
+ startedAt: number,
7437
+ ): void {
7438
+ this.recordBeforeTpst(record, startedAt);
7439
+ this.recordAfterTpen(record, startedAt);
7440
+ this.commitIfAbsent(requestId, record);
6791
7441
  }
6792
7442
 
6793
7443
  clear(): void {
6794
- this.turns.forEach((turn) => {
6795
- clearTimer(turn.timer, this.clearTimeoutFn);
7444
+ this.records.forEach((record) => {
7445
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6796
7446
  });
6797
- this.turns.clear();
7447
+ this.records.clear();
6798
7448
  this.pendingAIResponseStartsByUserMessageId.clear();
6799
7449
  }
6800
7450
 
6801
7451
  clearChannel(channelUrl?: string): void {
6802
7452
  if (!channelUrl) return;
6803
- this.turns.forEach((turn, requestId) => {
6804
- if (turn.channelUrl !== channelUrl) return;
6805
- this.cleanupTurn(requestId);
7453
+ this.records.forEach((record) => {
7454
+ if (record.measure.channelUrl !== channelUrl) return;
7455
+ this.cleanupTurn(record.measure.requestId);
6806
7456
  });
6807
7457
  this.pendingAIResponseStartsByUserMessageId.forEach((pending, userMessageId) => {
6808
7458
  if (pending.channelUrl === channelUrl) this.pendingAIResponseStartsByUserMessageId.delete(userMessageId);
@@ -6810,78 +7460,62 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6810
7460
  }
6811
7461
 
6812
7462
  private commitTimeout(requestId: string): void {
6813
- const turn = this.turns.get(requestId);
6814
- if (!turn) return;
6815
- const now = this.clock.now();
6816
- this.recordBeforeTpst(turn, now);
6817
- this.recordAfterTpen(turn, now);
6818
- this.commitIfAbsent(requestId, turn);
7463
+ const record = this.records.get(requestId);
7464
+ if (!record) return;
7465
+ const now = performance.now();
7466
+ this.recordBeforeTpst(record, now);
7467
+ this.recordAfterTpen(record, now);
7468
+ this.commitIfAbsent(requestId, record);
7469
+ }
7470
+
7471
+ private commitIfAbsent(
7472
+ requestId: string,
7473
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
7474
+ ): void {
7475
+ record.stats.commit();
7476
+ this.cleanupTurn(requestId);
7477
+ }
7478
+
7479
+ private cleanupTurn(requestId: string): void {
7480
+ const record = this.records.get(requestId);
7481
+ if (!record) return;
7482
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
7483
+ this.records.delete(requestId);
6819
7484
  }
6820
7485
 
6821
7486
  private recordBeforeTpst(
6822
- turn: { beforeTpstStartedAt: number | null; beforeTpstAbsentMs?: number },
7487
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6823
7488
  endedAt: number,
6824
7489
  ): void {
6825
- if (turn.beforeTpstStartedAt === null) return;
6826
- const duration = endedAt - turn.beforeTpstStartedAt;
6827
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.beforeTpstAbsentMs = duration;
6828
- turn.beforeTpstStartedAt = null;
7490
+ if (record.measure.beforeTpstStartedAt === null) return;
7491
+ const duration = endedAt - record.measure.beforeTpstStartedAt;
7492
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) record.stats.setBeforeTpstAbsentMs(duration);
7493
+ record.measure.beforeTpstStartedAt = null;
6829
7494
  }
6830
7495
 
6831
7496
  private recordAfterTpen(
6832
- turn: { afterTpenStartedAt: number | null; afterTpenAbsentMs?: number },
7497
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6833
7498
  endedAt: number,
6834
7499
  ): void {
6835
- if (turn.afterTpenStartedAt === null) return;
6836
- const duration = endedAt - turn.afterTpenStartedAt;
6837
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.afterTpenAbsentMs = duration;
6838
- turn.afterTpenStartedAt = null;
6839
- }
6840
-
6841
- private commitIfAbsent(requestId: string, turn: { beforeTpstAbsentMs?: number; afterTpenAbsentMs?: number }): void {
6842
- const beforeTpstAbsentMs = turn.beforeTpstAbsentMs;
6843
- const afterTpenAbsentMs = turn.afterTpenAbsentMs;
6844
- if (beforeTpstAbsentMs === undefined && afterTpenAbsentMs === undefined) {
6845
- this.cleanupTurn(requestId);
6846
- return;
6847
- }
6848
-
6849
- append(
6850
- this.callback,
6851
- METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT,
6852
- (beforeTpstAbsentMs ?? 0) + (afterTpenAbsentMs ?? 0),
6853
- {
6854
- extra: {
6855
- ...(beforeTpstAbsentMs !== undefined && { before_tpst_absent_ms: beforeTpstAbsentMs }),
6856
- ...(afterTpenAbsentMs !== undefined && { after_tpen_absent_ms: afterTpenAbsentMs }),
6857
- },
6858
- },
6859
- );
6860
- this.cleanupTurn(requestId);
6861
- }
6862
-
6863
- private cleanupTurn(requestId: string): void {
6864
- const turn = this.turns.get(requestId);
6865
- if (!turn) return;
6866
- clearTimer(turn.timer, this.clearTimeoutFn);
6867
- this.turns.delete(requestId);
7500
+ if (record.measure.afterTpenStartedAt === null) return;
7501
+ const duration = endedAt - record.measure.afterTpenStartedAt;
7502
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) record.stats.setAfterTpenAbsentMs(duration);
7503
+ record.measure.afterTpenStartedAt = null;
6868
7504
  }
6869
7505
  }
6870
7506
 
6871
- declare type TypingTurn = {
6872
- requestId: string;
6873
- channelUrl?: string;
6874
- userMessageId?: number;
6875
- beforeTpstStartedAt: number | null;
7507
+ declare type TypingMeasure = {
6876
7508
  afterTpenStartedAt: number | null;
6877
- beforeTpstAbsentMs?: number;
6878
- afterTpenAbsentMs?: number;
7509
+ beforeTpstStartedAt: number | null;
7510
+ channelUrl?: string;
7511
+ requestId: string;
6879
7512
  timer: TimeoutId | null;
7513
+ userMessageId?: number;
6880
7514
  };
6881
7515
 
6882
7516
  declare type TypingTurnEntry = {
6883
7517
  requestId: string;
6884
- turn: TypingTurn;
7518
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>;
6885
7519
  };
6886
7520
 
6887
7521
  declare interface TypographyShape {
@@ -6959,76 +7593,163 @@ export declare type UserActionStatus =
6959
7593
 
6960
7594
  export declare const useRefreshActiveChannel: (updater: () => Promise<void>) => () => Promise<void>;
6961
7595
 
6962
- declare class UserPerceivedResponseTimeStatsTracker {
6963
- private readonly callback: StatsAppendCallback;
6964
- private readonly clock: StatsClock;
6965
- private readonly random: () => number;
6966
- private readonly sampledByRequestId = new Map<string, UserTurn>();
6967
- private readonly sampledByAIMessageId = new Map<number, UserTurn>();
7596
+ declare type UserPerceivedResponseTimeMeasure = {
7597
+ ackAt?: number;
7598
+ aiMessageId?: number;
7599
+ channelUrl?: string;
7600
+ firstChunkAt?: number;
7601
+ isStreaming?: boolean;
7602
+ requestId: string;
7603
+ startedAt: number;
7604
+ userMessageId?: number;
7605
+ };
7606
+
7607
+ declare class UserPerceivedResponseTimeStats extends AIAgentBaseStats {
7608
+ private ackToFirstChunkMs?: number;
7609
+ private firstChunkToRenderMs?: number;
7610
+ private isStreaming?: boolean;
7611
+ private sendToAckMs?: number;
7612
+ private totalDurationMs?: number;
7613
+
7614
+ setDurations(params: {
7615
+ ackToFirstChunkMs: number;
7616
+ firstChunkToRenderMs: number;
7617
+ isStreaming: boolean;
7618
+ sendToAckMs: number;
7619
+ totalDurationMs: number;
7620
+ }): this {
7621
+ this.ackToFirstChunkMs = params.ackToFirstChunkMs;
7622
+ this.firstChunkToRenderMs = params.firstChunkToRenderMs;
7623
+ this.isStreaming = params.isStreaming;
7624
+ this.sendToAckMs = params.sendToAckMs;
7625
+ this.totalDurationMs = params.totalDurationMs;
7626
+ return this;
7627
+ }
7628
+
7629
+ protected getMetricKey(): string {
7630
+ return METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME;
7631
+ }
7632
+
7633
+ protected buildPayload(): AIAgentStatPayload | null {
7634
+ if (
7635
+ this.ackToFirstChunkMs === undefined ||
7636
+ this.firstChunkToRenderMs === undefined ||
7637
+ this.isStreaming === undefined ||
7638
+ this.sendToAckMs === undefined ||
7639
+ this.totalDurationMs === undefined
7640
+ ) {
7641
+ return null;
7642
+ }
7643
+
7644
+ return this.createPayload(this.totalDurationMs, {
7645
+ extra: {
7646
+ is_streaming: this.isStreaming,
7647
+ [DurationKey.ACK_TO_FIRST_CHUNK]: this.ackToFirstChunkMs,
7648
+ [DurationKey.FIRST_CHUNK_TO_RENDER]: this.firstChunkToRenderMs,
7649
+ [DurationKey.SEND_TO_ACK]: this.sendToAckMs,
7650
+ },
7651
+ });
7652
+ }
7653
+ }
7654
+
7655
+ /**
7656
+ * Spec: user_perceived_response_time
7657
+ *
7658
+ * Measures the user-visible latency from SDK send to the first AI response
7659
+ * frame on screen. Sampling is per user message: 15% independent probability
7660
+ * by default, capped at 10 collected turns per runtime session.
7661
+ *
7662
+ * Timeline:
7663
+ * - T0: SDK send API is called, not the UI tap time.
7664
+ * - T1: send completion handler runs.
7665
+ * - T2: first raw AI response chunk reaches the SDK.
7666
+ * - T3: the first response frame is rendered in the visible message cell.
7667
+ *
7668
+ * Payload:
7669
+ * - value: total_perceived_ms = T3 - T0.
7670
+ * - extra.send_to_ack_ms = T1 - T0.
7671
+ * - extra.ack_to_first_chunk_ms = T2 - T1.
7672
+ * - extra.first_chunk_to_render_ms = T3 - T2.
7673
+ * - extra.is_streaming: whether the AI response is streaming.
7674
+ *
7675
+ * Policy:
7676
+ * - Turn-scoped; commit only after T3.
7677
+ * - Incomplete or aborted turns are not recorded.
7678
+ * - Human-agent handoff/abort clears pending measurements without emitting.
7679
+ */
7680
+ declare class UserPerceivedResponseTimeStatsTracker extends MultiStatsTrackerBase<
7681
+ UserPerceivedResponseTimeStats,
7682
+ UserPerceivedResponseTimeMeasure
7683
+ > {
7684
+ private readonly samplingRate: number;
6968
7685
  private readonly pendingAIResponseByUserMessageId = new Map<number, PendingAIResponseRaw>();
6969
7686
  private collectedCount = 0;
6970
7687
 
6971
- constructor(callback: StatsAppendCallback, options?: TrackerOptions & { random?: () => number }) {
6972
- this.callback = callback;
6973
- this.clock = getClock(options);
6974
- this.random = options?.random ?? Math.random;
7688
+ constructor(callback: StatsAppendCallback, options?: { samplingRate?: number }) {
7689
+ super(callback);
7690
+ this.samplingRate = options?.samplingRate ?? USER_PERCEIVED_RESPONSE_TIME_SAMPLING_RATE;
6975
7691
  }
6976
7692
 
6977
7693
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6978
- if (this.collectedCount >= 10) return;
6979
- if (this.random() >= 0.15) return;
7694
+ if (this.collectedCount >= MAX_COLLECTED_COUNT) return;
7695
+ if (!isIncludedInSample(this.samplingRate)) return;
6980
7696
  this.collectedCount += 1;
6981
- this.sampledByRequestId.set(params.requestId, {
6982
- requestId: params.requestId,
6983
- channelUrl: params.channelUrl,
6984
- startedAt: this.clock.now(),
6985
- });
7697
+
7698
+ const record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> = {
7699
+ stats: new UserPerceivedResponseTimeStats().setCommitCallback(this.commitCallback),
7700
+ measure: {
7701
+ channelUrl: params.channelUrl,
7702
+ requestId: params.requestId,
7703
+ startedAt: performance.now(),
7704
+ },
7705
+ };
7706
+ this.records.set(params.requestId, record);
6986
7707
  }
6987
7708
 
6988
7709
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6989
- const turn = this.sampledByRequestId.get(params.requestId);
6990
- if (!turn) return;
7710
+ const record = this.records.get(params.requestId);
7711
+ if (!record) return;
6991
7712
 
6992
- turn.ackAt = this.clock.now();
6993
- turn.userMessageId = params.messageId;
7713
+ record.measure.ackAt = performance.now();
7714
+ record.measure.userMessageId = params.messageId;
6994
7715
 
6995
7716
  const pending = this.pendingAIResponseByUserMessageId.get(params.messageId);
6996
7717
  if (!pending) return;
6997
7718
  this.pendingAIResponseByUserMessageId.delete(params.messageId);
6998
- this.applyAIResponseRaw(turn, pending);
7719
+ this.applyAIResponseRaw(record, pending);
6999
7720
  }
7000
7721
 
7001
7722
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
7002
7723
  if (currentRequestId === nextRequestId) return;
7003
7724
 
7004
- const turn = this.sampledByRequestId.get(currentRequestId);
7005
- if (!turn) return;
7725
+ const record = this.records.get(currentRequestId);
7726
+ if (!record) return;
7006
7727
 
7007
- this.sampledByRequestId.delete(currentRequestId);
7008
- turn.requestId = nextRequestId;
7009
- this.sampledByRequestId.set(nextRequestId, turn);
7728
+ this.records.delete(currentRequestId);
7729
+ record.measure.requestId = nextRequestId;
7730
+ this.records.set(nextRequestId, record);
7010
7731
  }
7011
7732
 
7012
7733
  cancelTurn(requestId: string): void {
7013
- const turn = this.sampledByRequestId.get(requestId);
7014
- if (!turn) return;
7015
- this.deleteTurn(turn);
7734
+ const record = this.records.get(requestId);
7735
+ if (!record) return;
7736
+ this.deleteRecord(record);
7016
7737
  // Return the unused sample slot so cancelled turns don't burn the per-session cap.
7017
7738
  if (this.collectedCount > 0) this.collectedCount -= 1;
7018
7739
  }
7019
7740
 
7020
7741
  markAIResponseRaw(params: { messageId: number; channelUrl?: string; aiResponse: AIResponseMessageMetadata }): void {
7021
- const firstChunkAt = this.clock.now();
7742
+ const firstChunkAt = performance.now();
7022
7743
  const userMessageId = params.aiResponse.userMessageId;
7023
- if (userMessageId === undefined && this.hasUnackedTurn(params.channelUrl)) return;
7744
+ if (userMessageId === undefined && hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7024
7745
 
7025
- const turn = this.findPendingTurn(params.channelUrl, userMessageId);
7026
- if (turn) {
7027
- this.applyAIResponseRaw(turn, { ...params, firstChunkAt });
7746
+ const record = this.findPendingRecord(params.channelUrl, userMessageId);
7747
+ if (record) {
7748
+ this.applyAIResponseRaw(record, { ...params, firstChunkAt });
7028
7749
  return;
7029
7750
  }
7030
7751
 
7031
- if (userMessageId === undefined || !this.hasUnackedTurn(params.channelUrl)) return;
7752
+ if (userMessageId === undefined || !hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7032
7753
  this.bufferAIResponseRaw(
7033
7754
  {
7034
7755
  messageId: params.messageId,
@@ -7041,26 +7762,31 @@ declare class UserPerceivedResponseTimeStatsTracker {
7041
7762
  }
7042
7763
 
7043
7764
  markAIResponseVisible(aiMessageId: number): boolean {
7044
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7045
- if (!turn || turn.ackAt === undefined || turn.firstChunkAt === undefined || turn.userMessageId === undefined)
7046
- return false;
7765
+ const record = this.findRecordByAIMessageId(aiMessageId);
7766
+ if (!record) return false;
7047
7767
 
7048
- const renderedAt = this.clock.now();
7049
- append(this.callback, METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME, renderedAt - turn.startedAt, {
7050
- extra: {
7051
- ack_to_first_chunk_ms: turn.firstChunkAt - turn.ackAt,
7052
- first_chunk_to_render_ms: renderedAt - turn.firstChunkAt,
7053
- is_streaming: Boolean(turn.isStreaming),
7054
- send_to_ack_ms: turn.ackAt - turn.startedAt,
7055
- },
7056
- });
7768
+ const measure = record.measure;
7769
+ if (measure.ackAt === undefined || measure.firstChunkAt === undefined || measure.userMessageId === undefined) {
7770
+ return false;
7771
+ }
7057
7772
 
7058
- this.deleteTurn(turn);
7773
+ const renderedAt = performance.now();
7774
+ record.stats
7775
+ .setDurations({
7776
+ ackToFirstChunkMs: measure.firstChunkAt - measure.ackAt,
7777
+ firstChunkToRenderMs: renderedAt - measure.firstChunkAt,
7778
+ isStreaming: Boolean(measure.isStreaming),
7779
+ sendToAckMs: measure.ackAt - measure.startedAt,
7780
+ totalDurationMs: renderedAt - measure.startedAt,
7781
+ })
7782
+ .commit();
7783
+
7784
+ this.deleteRecord(record);
7059
7785
  return true;
7060
7786
  }
7061
7787
 
7062
7788
  hasPendingAIResponse(aiMessageId: number): boolean {
7063
- return this.sampledByAIMessageId.has(aiMessageId);
7789
+ return this.findRecordByAIMessageId(aiMessageId) !== undefined;
7064
7790
  }
7065
7791
 
7066
7792
  clearMessage(aiMessageId: number): void {
@@ -7068,18 +7794,18 @@ declare class UserPerceivedResponseTimeStatsTracker {
7068
7794
  if (pending.messageId === aiMessageId) this.pendingAIResponseByUserMessageId.delete(userMessageId);
7069
7795
  });
7070
7796
 
7071
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7072
- if (!turn) return;
7797
+ const record = this.findRecordByAIMessageId(aiMessageId);
7798
+ if (!record) return;
7073
7799
 
7074
- this.deleteTurn(turn);
7800
+ this.deleteRecord(record);
7075
7801
  if (this.collectedCount > 0) this.collectedCount -= 1;
7076
7802
  }
7077
7803
 
7078
7804
  clearChannel(channelUrl?: string): void {
7079
7805
  if (!channelUrl) return;
7080
- this.sampledByRequestId.forEach((turn) => {
7081
- if (turn.channelUrl !== channelUrl) return;
7082
- this.deleteTurn(turn);
7806
+ this.records.forEach((record) => {
7807
+ if (record.measure.channelUrl !== channelUrl) return;
7808
+ this.deleteRecord(record);
7083
7809
  if (this.collectedCount > 0) this.collectedCount -= 1;
7084
7810
  });
7085
7811
  this.pendingAIResponseByUserMessageId.forEach((pending, userMessageId) => {
@@ -7088,40 +7814,54 @@ declare class UserPerceivedResponseTimeStatsTracker {
7088
7814
  }
7089
7815
 
7090
7816
  clear(): void {
7091
- this.sampledByRequestId.clear();
7092
- this.sampledByAIMessageId.clear();
7817
+ this.records.clear();
7093
7818
  this.pendingAIResponseByUserMessageId.clear();
7094
7819
  this.collectedCount = 0;
7095
7820
  }
7096
7821
 
7097
- private findPendingTurn(channelUrl?: string, userMessageId?: number): UserTurn | undefined {
7822
+ private findRecordByAIMessageId(
7823
+ aiMessageId: number,
7824
+ ): StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined {
7825
+ for (const record of this.records.values()) {
7826
+ if (record.measure.aiMessageId === aiMessageId) return record;
7827
+ }
7828
+ return undefined;
7829
+ }
7830
+
7831
+ private findPendingRecord(
7832
+ channelUrl?: string,
7833
+ userMessageId?: number,
7834
+ ): StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined {
7098
7835
  if (userMessageId !== undefined) {
7099
- for (const turn of this.sampledByRequestId.values()) {
7100
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7101
- if (!matchesChannel(turn, channelUrl)) continue;
7102
- if (turn.userMessageId === userMessageId) return turn;
7836
+ for (const record of this.records.values()) {
7837
+ if (record.measure.ackAt === undefined || record.measure.firstChunkAt !== undefined) continue;
7838
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) continue;
7839
+ if (record.measure.userMessageId === userMessageId) return record;
7103
7840
  }
7104
7841
  return undefined;
7105
7842
  }
7106
7843
 
7107
- let match: UserTurn | undefined;
7108
- for (const turn of this.sampledByRequestId.values()) {
7109
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7110
- if (!matchesChannel(turn, channelUrl)) continue;
7844
+ let match: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined;
7845
+ for (const record of this.records.values()) {
7846
+ if (record.measure.ackAt === undefined || record.measure.firstChunkAt !== undefined) continue;
7847
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) continue;
7111
7848
  if (match) return undefined;
7112
- match = turn;
7849
+ match = record;
7113
7850
  }
7114
7851
  return match;
7115
7852
  }
7116
7853
 
7117
- private applyAIResponseRaw(turn: UserTurn, params: PendingAIResponseRaw): void {
7118
- if (turn.firstChunkAt !== undefined) return;
7854
+ private applyAIResponseRaw(
7855
+ record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure>,
7856
+ params: PendingAIResponseRaw,
7857
+ ): void {
7858
+ if (record.measure.firstChunkAt !== undefined) return;
7119
7859
 
7120
- turn.firstChunkAt = turn.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, turn.ackAt);
7121
- turn.aiMessageId = params.messageId;
7122
- turn.channelUrl = turn.channelUrl ?? params.channelUrl;
7123
- turn.isStreaming = params.aiResponse.isStreaming;
7124
- this.sampledByAIMessageId.set(params.messageId, turn);
7860
+ record.measure.firstChunkAt =
7861
+ record.measure.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, record.measure.ackAt);
7862
+ record.measure.aiMessageId = params.messageId;
7863
+ record.measure.channelUrl = record.measure.channelUrl ?? params.channelUrl;
7864
+ record.measure.isStreaming = params.aiResponse.isStreaming;
7125
7865
  }
7126
7866
 
7127
7867
  private bufferAIResponseRaw(params: PendingAIResponseRaw, userMessageId: number): void {
@@ -7131,14 +7871,10 @@ declare class UserPerceivedResponseTimeStatsTracker {
7131
7871
  }
7132
7872
  }
7133
7873
 
7134
- private hasUnackedTurn(channelUrl?: string): boolean {
7135
- return hasUnackedChannelTurn(this.sampledByRequestId.values(), channelUrl);
7136
- }
7137
-
7138
- private deleteTurn(turn: UserTurn): void {
7139
- this.sampledByRequestId.delete(turn.requestId);
7140
- if (turn.aiMessageId !== undefined) this.sampledByAIMessageId.delete(turn.aiMessageId);
7141
- if (turn.userMessageId !== undefined) this.pendingAIResponseByUserMessageId.delete(turn.userMessageId);
7874
+ private deleteRecord(record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure>): void {
7875
+ this.records.delete(record.measure.requestId);
7876
+ if (record.measure.userMessageId !== undefined)
7877
+ this.pendingAIResponseByUserMessageId.delete(record.measure.userMessageId);
7142
7878
  }
7143
7879
  }
7144
7880
 
@@ -7159,17 +7895,6 @@ declare interface UserSessionInfo {
7159
7895
  sessionHandler: AIAgentSessionHandler;
7160
7896
  }
7161
7897
 
7162
- declare type UserTurn = {
7163
- requestId: string;
7164
- channelUrl?: string;
7165
- startedAt: number;
7166
- ackAt?: number;
7167
- firstChunkAt?: number;
7168
- userMessageId?: number;
7169
- aiMessageId?: number;
7170
- isStreaming?: boolean;
7171
- };
7172
-
7173
7898
  export { }
7174
7899
 
7175
7900