@oxvo/ai-live-assist 7.4.37 → 7.4.49

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.
@@ -551,9 +551,71 @@ const versionAtLeast = (current, minimum) => {
551
551
  };
552
552
  export const resolveAiGuideProtocolVersion = (bootstrap) => bootstrap.experienceMode === "voice_presence" ||
553
553
  (bootstrap.minimumProtocolVersion ?? 1) >= 2 ||
554
- bootstrap.protocol.min >= 2
554
+ bootstrap.protocol.min >= 2 ||
555
+ bootstrap.protocol.max >= 2
555
556
  ? 2
556
557
  : 1;
558
+ const resumeProtocolIsSupported = (record, bootstrap) => {
559
+ const experienceMode = bootstrap.experienceMode ?? "standard";
560
+ if (record.experienceMode !== experienceMode)
561
+ return false;
562
+ if (record.protocolVersion === resolveAiGuideProtocolVersion(bootstrap)) {
563
+ return true;
564
+ }
565
+ // Prefer V2 for fresh standard launches without abandoning a V1 session
566
+ // while the published configuration and runtime still admit V1.
567
+ return (record.experienceMode === "standard" &&
568
+ record.protocolVersion === 1 &&
569
+ (bootstrap.minimumProtocolVersion ?? 1) <= 1 &&
570
+ bootstrap.protocol.min <= 1 &&
571
+ bootstrap.protocol.max >= 1);
572
+ };
573
+ const protocolVersionIsAdmitted = (protocolVersion, bootstrap) => protocolVersion >= (bootstrap.minimumProtocolVersion ?? 1) &&
574
+ protocolVersion >= bootstrap.protocol.min &&
575
+ protocolVersion <= bootstrap.protocol.max;
576
+ const pendingLaunchIsBound = (pendingLaunch) => (pendingLaunch.protocolVersion === 1 ||
577
+ pendingLaunch.protocolVersion === 2) &&
578
+ (pendingLaunch.experienceMode === "standard" ||
579
+ pendingLaunch.experienceMode === "voice_presence");
580
+ const bindPendingLaunchIntent = (pendingLaunch, bootstrap) => {
581
+ const effectiveExperienceMode = bootstrap.experienceMode ?? "standard";
582
+ let protocolVersion = pendingLaunch.protocolVersion;
583
+ let experienceMode = pendingLaunch.experienceMode;
584
+ const hasProtocolVersion = protocolVersion === 1 || protocolVersion === 2;
585
+ const hasExperienceMode = experienceMode === "standard" || experienceMode === "voice_presence";
586
+ if (hasProtocolVersion !== hasExperienceMode)
587
+ return null;
588
+ if (!hasProtocolVersion && !hasExperienceMode) {
589
+ // A legacy record does not preserve its original protocol body. Current
590
+ // configuration can never prove the historical request, even when the
591
+ // present experience is voice presence, so its idempotency key must not be
592
+ // replayed with inferred authority.
593
+ return null;
594
+ }
595
+ if ((protocolVersion !== 1 && protocolVersion !== 2) ||
596
+ (experienceMode !== "standard" && experienceMode !== "voice_presence") ||
597
+ experienceMode !== effectiveExperienceMode ||
598
+ !protocolVersionIsAdmitted(protocolVersion, bootstrap) ||
599
+ (protocolVersion === 1 && experienceMode !== "standard") ||
600
+ (experienceMode === "voice_presence" &&
601
+ (protocolVersion !== 2 || pendingLaunch.mode !== "voice"))) {
602
+ return null;
603
+ }
604
+ return {
605
+ mode: pendingLaunch.mode,
606
+ scopes: [...pendingLaunch.scopes],
607
+ idempotencyKey: pendingLaunch.idempotencyKey,
608
+ protocolVersion,
609
+ experienceMode,
610
+ };
611
+ };
612
+ const freshPendingLaunchIntent = (mode, scopes, idempotencyKey, bootstrap) => ({
613
+ mode,
614
+ scopes: [...scopes],
615
+ idempotencyKey,
616
+ protocolVersion: resolveAiGuideProtocolVersion(bootstrap),
617
+ experienceMode: bootstrap.experienceMode ?? "standard",
618
+ });
557
619
  const parseTerminalEndIntent = (value) => {
558
620
  if (!value || typeof value !== "object" || Array.isArray(value))
559
621
  return null;
@@ -588,9 +650,13 @@ const parseFreshAiReturnProvisional = (value) => {
588
650
  if (!value || typeof value !== "object" || Array.isArray(value))
589
651
  return null;
590
652
  const raw = value;
591
- if (Object.keys(raw).sort().join(",") !==
592
- "conversationDisplayId,expiresAt,launchIdempotencyKey,linkId,mode,phase,sessionId,v" ||
593
- raw.v !== 1 ||
653
+ const recordVersion = raw.v === 1 || raw.v === 2 ? raw.v : null;
654
+ const scopes = Array.isArray(raw.scopes) ? raw.scopes : [];
655
+ const expectedKeys = recordVersion === 1
656
+ ? "conversationDisplayId,expiresAt,launchIdempotencyKey,linkId,mode,phase,sessionId,v"
657
+ : "conversationDisplayId,experienceMode,expiresAt,launchIdempotencyKey,linkId,mode,phase,protocolVersion,scopes,sessionId,v";
658
+ if (recordVersion === null ||
659
+ Object.keys(raw).sort().join(",") !== expectedKeys ||
594
660
  !PUBLIC_REQUEST_ID_PATTERN.test(raw.linkId ?? "") ||
595
661
  !/^[1-9]\d{0,18}$/.test(raw.conversationDisplayId ?? "") ||
596
662
  (raw.mode !== "text" && raw.mode !== "voice") ||
@@ -605,19 +671,42 @@ const parseFreshAiReturnProvisional = (value) => {
605
671
  (raw.launchIdempotencyKey === null || raw.sessionId !== null)) ||
606
672
  (raw.phase === "launched" &&
607
673
  (raw.launchIdempotencyKey === null || raw.sessionId === null)) ||
674
+ (recordVersion === 2 &&
675
+ raw.phase === "released" &&
676
+ (raw.protocolVersion !== null ||
677
+ raw.experienceMode !== null ||
678
+ raw.scopes !== null)) ||
679
+ (recordVersion === 2 &&
680
+ raw.phase !== "released" &&
681
+ ((raw.protocolVersion !== 1 && raw.protocolVersion !== 2) ||
682
+ (raw.experienceMode !== "standard" &&
683
+ raw.experienceMode !== "voice_presence") ||
684
+ (raw.protocolVersion === 1 && raw.experienceMode !== "standard") ||
685
+ (raw.experienceMode === "voice_presence" &&
686
+ (raw.protocolVersion !== 2 || raw.mode !== "voice")) ||
687
+ !Array.isArray(raw.scopes) ||
688
+ scopes.length > CONSENT_SCOPES.size ||
689
+ scopes.some((scope) => typeof scope !== "string" ||
690
+ !CONSENT_SCOPES.has(scope)) ||
691
+ new Set(scopes).size !== scopes.length)) ||
608
692
  !Number.isSafeInteger(raw.expiresAt) ||
609
693
  (raw.expiresAt ?? 0) <= Date.now() ||
610
694
  (raw.expiresAt ?? 0) > Date.now() + STORAGE_TTL_MS) {
611
695
  return null;
612
696
  }
613
697
  return {
614
- v: 1,
698
+ v: recordVersion,
615
699
  linkId: raw.linkId,
616
700
  conversationDisplayId: raw.conversationDisplayId,
617
701
  mode: raw.mode,
618
702
  phase: raw.phase,
619
703
  launchIdempotencyKey: raw.launchIdempotencyKey ?? null,
620
704
  sessionId: raw.sessionId ?? null,
705
+ protocolVersion: recordVersion === 2 ? (raw.protocolVersion ?? null) : null,
706
+ experienceMode: recordVersion === 2 ? (raw.experienceMode ?? null) : null,
707
+ scopes: recordVersion === 2 && raw.phase !== "released"
708
+ ? [...scopes]
709
+ : null,
621
710
  expiresAt: raw.expiresAt,
622
711
  };
623
712
  };
@@ -654,6 +743,16 @@ const parseResumeRecord = (value) => {
654
743
  const record = JSON.parse(value);
655
744
  const scopes = Array.isArray(record.scopes) ? record.scopes : [];
656
745
  const locale = typeof record.locale === "string" ? canonicalLocale(record.locale) : null;
746
+ const protocolFieldsAbsent = record.protocolVersion === undefined &&
747
+ record.experienceMode === undefined;
748
+ const protocolBindingValid = protocolFieldsAbsent ||
749
+ ((record.protocolVersion === 1 || record.protocolVersion === 2) &&
750
+ (record.experienceMode === "standard" ||
751
+ record.experienceMode === "voice_presence") &&
752
+ (record.protocolVersion !== 1 ||
753
+ record.experienceMode === "standard") &&
754
+ (record.experienceMode !== "voice_presence" ||
755
+ (record.protocolVersion === 2 && record.mode === "voice")));
657
756
  if (!PUBLIC_REQUEST_ID_PATTERN.test(record.sessionId ?? "") ||
658
757
  !isValidAssistSessionGrant(record.resumeCredential) ||
659
758
  !["text", "voice"].includes(record.mode ?? "") ||
@@ -662,6 +761,7 @@ const parseResumeRecord = (value) => {
662
761
  scopes.some((scope) => typeof scope !== "string" ||
663
762
  !CONSENT_SCOPES.has(scope)) ||
664
763
  new Set(scopes).size !== scopes.length ||
764
+ !protocolBindingValid ||
665
765
  !locale ||
666
766
  typeof record.expiresAt !== "number" ||
667
767
  !Number.isFinite(record.expiresAt) ||
@@ -895,10 +995,10 @@ const parseResumeRecord = (value) => {
895
995
  ? record.visualConsentGranted
896
996
  : record.scopes.includes("visual_context"),
897
997
  manualTurn: record.manualTurn === true,
898
- protocolVersion: record.protocolVersion === 2 ? 2 : 1,
899
- experienceMode: record.experienceMode === "voice_presence"
900
- ? "voice_presence"
901
- : "standard",
998
+ protocolVersion: protocolFieldsAbsent ? 1 : record.protocolVersion,
999
+ experienceMode: protocolFieldsAbsent
1000
+ ? "standard"
1001
+ : record.experienceMode,
902
1002
  fallbackIntent: record.fallbackIntent === "standard_assist" ||
903
1003
  record.fallbackIntent === "human_handoff"
904
1004
  ? record.fallbackIntent
@@ -977,11 +1077,62 @@ const parseTranscript = (value) => {
977
1077
  }
978
1078
  return entries;
979
1079
  };
1080
+ const parsePendingLaunchIntent = (value, recordVersion) => {
1081
+ if (!value || typeof value !== "object" || Array.isArray(value))
1082
+ return null;
1083
+ const candidate = value;
1084
+ const scopes = Array.isArray(candidate.scopes) ? candidate.scopes : [];
1085
+ const expectedKeys = recordVersion === 1
1086
+ ? ["idempotencyKey", "mode", "scopes"]
1087
+ : [
1088
+ "experienceMode",
1089
+ "idempotencyKey",
1090
+ "mode",
1091
+ "protocolVersion",
1092
+ "scopes",
1093
+ ];
1094
+ if (canonicalJson(Object.keys(candidate).sort()) !==
1095
+ canonicalJson(expectedKeys) ||
1096
+ (candidate.mode !== "text" && candidate.mode !== "voice") ||
1097
+ !Array.isArray(candidate.scopes) ||
1098
+ typeof candidate.idempotencyKey !== "string" ||
1099
+ !PUBLIC_REQUEST_ID_PATTERN.test(candidate.idempotencyKey) ||
1100
+ scopes.length > CONSENT_SCOPES.size ||
1101
+ scopes.some((scope) => typeof scope !== "string" || !CONSENT_SCOPES.has(scope)) ||
1102
+ new Set(scopes).size !== scopes.length) {
1103
+ return null;
1104
+ }
1105
+ const base = {
1106
+ mode: candidate.mode,
1107
+ scopes: [...scopes],
1108
+ idempotencyKey: candidate.idempotencyKey,
1109
+ };
1110
+ if (recordVersion === 1) {
1111
+ return { ...base, protocolVersion: null, experienceMode: null };
1112
+ }
1113
+ const protocolVersion = candidate.protocolVersion;
1114
+ const experienceMode = candidate.experienceMode;
1115
+ if ((protocolVersion !== 1 && protocolVersion !== 2) ||
1116
+ (experienceMode !== "standard" && experienceMode !== "voice_presence") ||
1117
+ (protocolVersion === 1 && experienceMode !== "standard") ||
1118
+ (experienceMode === "voice_presence" &&
1119
+ (protocolVersion !== 2 || candidate.mode !== "voice"))) {
1120
+ return null;
1121
+ }
1122
+ return { ...base, protocolVersion, experienceMode };
1123
+ };
980
1124
  const parseFocusedHandoffPayload = (value) => {
981
1125
  if (!value || typeof value !== "object" || Array.isArray(value))
982
1126
  return null;
983
1127
  const raw = value;
984
- if (raw.v === 1) {
1128
+ if (raw.v === 1 || raw.v === 2) {
1129
+ const expectedKeys = ["resume", "transcript", "v"];
1130
+ if (raw.pendingLaunch !== undefined)
1131
+ expectedKeys.push("pendingLaunch");
1132
+ if (canonicalJson(Object.keys(raw).sort()) !==
1133
+ canonicalJson(expectedKeys.sort())) {
1134
+ return null;
1135
+ }
985
1136
  const resume = raw.resume === null
986
1137
  ? null
987
1138
  : parseResumeRecord(JSON.stringify(raw.resume ?? null));
@@ -989,30 +1140,17 @@ const parseFocusedHandoffPayload = (value) => {
989
1140
  return null;
990
1141
  let pendingLaunch;
991
1142
  if (raw.pendingLaunch !== undefined) {
992
- if (!raw.pendingLaunch ||
993
- typeof raw.pendingLaunch !== "object" ||
994
- Array.isArray(raw.pendingLaunch)) {
1143
+ pendingLaunch =
1144
+ parsePendingLaunchIntent(raw.pendingLaunch, raw.v) ?? undefined;
1145
+ if (resume || !pendingLaunch)
995
1146
  return null;
996
- }
997
- const candidate = raw.pendingLaunch;
998
- const scopes = Array.isArray(candidate.scopes) ? candidate.scopes : [];
999
- if (resume ||
1000
- (candidate.mode !== "text" && candidate.mode !== "voice") ||
1001
- !PUBLIC_REQUEST_ID_PATTERN.test(String(candidate.idempotencyKey ?? "")) ||
1002
- scopes.length > CONSENT_SCOPES.size ||
1003
- scopes.some((scope) => typeof scope !== "string" ||
1004
- !CONSENT_SCOPES.has(scope)) ||
1005
- new Set(scopes).size !== scopes.length) {
1006
- return null;
1007
- }
1008
- pendingLaunch = {
1009
- mode: candidate.mode,
1010
- scopes: [...scopes],
1011
- idempotencyKey: candidate.idempotencyKey,
1012
- };
1147
+ }
1148
+ if (raw.v === 2 &&
1149
+ (!pendingLaunch || !pendingLaunchIsBound(pendingLaunch))) {
1150
+ return null;
1013
1151
  }
1014
1152
  return {
1015
- v: 1,
1153
+ v: raw.v,
1016
1154
  resume,
1017
1155
  transcript: parseTranscript(raw.transcript),
1018
1156
  ...(pendingLaunch ? { pendingLaunch } : {}),
@@ -1060,19 +1198,15 @@ const parseStagedPendingLaunch = (value) => {
1060
1198
  const claimAuthority = raw.claimAuthority === null
1061
1199
  ? null
1062
1200
  : parseTransferAuthority(JSON.stringify(raw.claimAuthority ?? null));
1201
+ const recordVersion = raw.v === 1 || raw.v === 2 ? raw.v : null;
1063
1202
  const payload = parseFocusedHandoffPayload({
1064
- v: 1,
1203
+ v: recordVersion,
1065
1204
  resume: null,
1066
1205
  transcript: [],
1067
1206
  pendingLaunch: raw.pendingLaunch,
1068
1207
  });
1069
1208
  const expiresAt = raw.expiresAt;
1070
1209
  const topLevelKeys = Object.keys(raw).sort();
1071
- const pendingKeys = raw.pendingLaunch &&
1072
- typeof raw.pendingLaunch === "object" &&
1073
- !Array.isArray(raw.pendingLaunch)
1074
- ? Object.keys(raw.pendingLaunch).sort()
1075
- : [];
1076
1210
  const authorityKeys = raw.authority &&
1077
1211
  typeof raw.authority === "object" &&
1078
1212
  !Array.isArray(raw.authority)
@@ -1093,14 +1227,12 @@ const parseStagedPendingLaunch = (value) => {
1093
1227
  "state",
1094
1228
  "v",
1095
1229
  ]) ||
1096
- canonicalJson(pendingKeys) !==
1097
- canonicalJson(["idempotencyKey", "mode", "scopes"]) ||
1098
1230
  canonicalJson(authorityKeys) !==
1099
1231
  canonicalJson(["ownerId", "revision", "term"]) ||
1100
1232
  (raw.claimAuthority !== null &&
1101
1233
  canonicalJson(claimAuthorityKeys) !==
1102
1234
  canonicalJson(["ownerId", "revision", "term"])) ||
1103
- raw.v !== 1 ||
1235
+ recordVersion === null ||
1104
1236
  typeof raw.sourceOwnerId !== "string" ||
1105
1237
  !PUBLIC_REQUEST_ID_PATTERN.test(raw.sourceOwnerId) ||
1106
1238
  !authority ||
@@ -1124,7 +1256,7 @@ const parseStagedPendingLaunch = (value) => {
1124
1256
  return null;
1125
1257
  }
1126
1258
  return {
1127
- v: 1,
1259
+ v: recordVersion,
1128
1260
  sourceOwnerId: raw.sourceOwnerId,
1129
1261
  authority,
1130
1262
  state: raw.state,
@@ -1517,10 +1649,12 @@ export default class AiLiveAssist {
1517
1649
  this.storageKey = `__oxvo_ai_live_assist_resume_${this.siteKey}`;
1518
1650
  this.transcriptStorageKey = `__oxvo_ai_live_assist_transcript_${this.siteKey}`;
1519
1651
  this.transferAuthorityStorageKey = `__oxvo_ai_live_assist_transfer_authority_${this.siteKey}`;
1520
- this.stagedPendingLaunchStorageKey = `__oxvo_ai_live_assist_pending_launch_${this.siteKey}`;
1652
+ this.legacyStagedPendingLaunchStorageKey = `__oxvo_ai_live_assist_pending_launch_${this.siteKey}`;
1653
+ this.stagedPendingLaunchStorageKey = `__oxvo_ai_live_assist_pending_launch_v2_${this.siteKey}`;
1521
1654
  this.terminalEndStorageKey = `__oxvo_ai_live_assist_terminal_end_${this.siteKey}`;
1522
1655
  this.messengerReturnEndStorageKey = `__oxvo_ai_live_assist_handoff_terminal_${this.siteKey}`;
1523
- this.freshAiReturnProvisionalStorageKey = `__oxvo_ai_live_assist_fresh_return_${this.siteKey}`;
1656
+ this.legacyFreshAiReturnProvisionalStorageKey = `__oxvo_ai_live_assist_fresh_return_${this.siteKey}`;
1657
+ this.freshAiReturnProvisionalStorageKey = `__oxvo_ai_live_assist_fresh_return_v2_${this.siteKey}`;
1524
1658
  this.pageAssistanceTrackerIdentityStorageKey = `__oxvo_ai_live_assist_page_assistance_tracker_${this.siteKey}`;
1525
1659
  this.transcript = this.readTranscript();
1526
1660
  this.visitorIdentity = this.resolveVisitorId();
@@ -1719,8 +1853,7 @@ export default class AiLiveAssist {
1719
1853
  pendingLaunch &&
1720
1854
  (!staged ||
1721
1855
  (staged.state === "in_flight" &&
1722
- staged.pendingLaunch.idempotencyKey ===
1723
- pendingLaunch.idempotencyKey))
1856
+ this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch)))
1724
1857
  ? { ...pendingLaunch, scopes: [...pendingLaunch.scopes] }
1725
1858
  : null;
1726
1859
  const record = this.readResume();
@@ -1735,8 +1868,12 @@ export default class AiLiveAssist {
1735
1868
  this.persistTerminalEndIntent(intent);
1736
1869
  if (record)
1737
1870
  this.storeTerminalResume(record, intent);
1738
- else if (this.result)
1739
- this.captureTerminalAuthorizedResult(this.result);
1871
+ else if (this.result) {
1872
+ const retainedResult = this.result;
1873
+ if (!this.captureTerminalAuthorizedResult(retainedResult)) {
1874
+ this.rejectTerminalAuthorizedResult(retainedResult);
1875
+ }
1876
+ }
1740
1877
  }
1741
1878
  this.terminalRequestPending = true;
1742
1879
  if (!this.relinquishModeUpgradeForRecovery(false)) {
@@ -1900,8 +2037,7 @@ export default class AiLiveAssist {
1900
2037
  if (resume &&
1901
2038
  !terminalIntent &&
1902
2039
  !resumedFallback &&
1903
- (resume.experienceMode !== experienceMode ||
1904
- resume.protocolVersion !== requestedProtocol ||
2040
+ (!resumeProtocolIsSupported(resume, bootstrap) ||
1905
2041
  resume.fallbackIntent !== null)) {
1906
2042
  this.clearResume();
1907
2043
  resume = null;
@@ -2625,9 +2761,6 @@ export default class AiLiveAssist {
2625
2761
  !this.messengerSupportsConfirmedHandoff(messenger)) {
2626
2762
  return false;
2627
2763
  }
2628
- bridgeActivationAttempted = true;
2629
- if (messenger.setAiGuideHandoffEnabled(true) !== true)
2630
- return false;
2631
2764
  if (messenger.ready) {
2632
2765
  await this.withMessengerTimeout(messenger.ready(), MESSENGER_LOAD_TIMEOUT_MS);
2633
2766
  }
@@ -2637,6 +2770,9 @@ export default class AiLiveAssist {
2637
2770
  this.resolveMessenger() !== messenger) {
2638
2771
  return false;
2639
2772
  }
2773
+ bridgeActivationAttempted = true;
2774
+ if (messenger.setAiGuideHandoffEnabled(true) !== true)
2775
+ return false;
2640
2776
  if (this.messengerInitialLauncherVisible === null) {
2641
2777
  this.messengerInitialLauncherVisible =
2642
2778
  messenger.launcherVisible !== false;
@@ -3865,13 +4001,16 @@ export default class AiLiveAssist {
3865
4001
  if (expiresAt <= Date.now())
3866
4002
  return null;
3867
4003
  return {
3868
- v: 1,
4004
+ v: 2,
3869
4005
  linkId: companion.linkId,
3870
4006
  conversationDisplayId: conversationId,
3871
4007
  mode,
3872
4008
  phase: "released",
3873
4009
  launchIdempotencyKey: null,
3874
4010
  sessionId: null,
4011
+ protocolVersion: null,
4012
+ experienceMode: null,
4013
+ scopes: null,
3875
4014
  expiresAt,
3876
4015
  };
3877
4016
  }
@@ -3888,8 +4027,12 @@ export default class AiLiveAssist {
3888
4027
  }
3889
4028
  this.persistFreshAiReturnProvisional({
3890
4029
  ...provisional,
4030
+ v: 2,
3891
4031
  phase: "released",
3892
4032
  launchIdempotencyKey: null,
4033
+ protocolVersion: null,
4034
+ experienceMode: null,
4035
+ scopes: null,
3893
4036
  });
3894
4037
  }
3895
4038
  quiesceProvisionalFreshAiReturn() {
@@ -3903,22 +4046,59 @@ export default class AiLiveAssist {
3903
4046
  this.view?.setPaused(true);
3904
4047
  this.cleanupTransport();
3905
4048
  }
4049
+ boundFreshAiReturnProvisional(provisional) {
4050
+ if (provisional.phase === "released" ||
4051
+ provisional.launchIdempotencyKey === null ||
4052
+ provisional.protocolVersion === null ||
4053
+ provisional.experienceMode === null ||
4054
+ provisional.scopes === null ||
4055
+ !this.bootstrap) {
4056
+ return null;
4057
+ }
4058
+ const pendingLaunch = {
4059
+ mode: provisional.mode,
4060
+ scopes: [...provisional.scopes],
4061
+ idempotencyKey: provisional.launchIdempotencyKey,
4062
+ protocolVersion: provisional.protocolVersion,
4063
+ experienceMode: provisional.experienceMode,
4064
+ };
4065
+ const bound = bindPendingLaunchIntent(pendingLaunch, this.bootstrap);
4066
+ return bound && this.pendingLaunchMatches(bound, pendingLaunch)
4067
+ ? bound
4068
+ : null;
4069
+ }
4070
+ resumeMatchesFreshAiReturn(resume, pendingLaunch) {
4071
+ return (resume.protocolVersion === pendingLaunch.protocolVersion &&
4072
+ resume.experienceMode === pendingLaunch.experienceMode &&
4073
+ resume.mode === pendingLaunch.mode &&
4074
+ canonicalJson(resume.scopes) === canonicalJson(pendingLaunch.scopes));
4075
+ }
4076
+ resultMatchesFreshAiReturn(result, pendingLaunch) {
4077
+ return (result.schemaVersion === pendingLaunch.protocolVersion &&
4078
+ result.protocolVersion === pendingLaunch.protocolVersion &&
4079
+ result.experienceMode === pendingLaunch.experienceMode &&
4080
+ (result.mode === undefined || result.mode === pendingLaunch.mode));
4081
+ }
3906
4082
  async retireProvisionalFreshAiReturn(provisional) {
3907
4083
  if (provisional.phase === "released")
3908
4084
  return true;
4085
+ const pendingLaunch = this.boundFreshAiReturnProvisional(provisional);
4086
+ if (!pendingLaunch)
4087
+ return false;
3909
4088
  const resume = this.readResume();
3910
4089
  const result = this.result;
3911
4090
  const sessionId = provisional.sessionId ?? resume?.sessionId ?? result?.sessionId ?? null;
3912
4091
  if (!sessionId)
3913
4092
  return false;
3914
- const authority = resume?.sessionId === sessionId
4093
+ const authority = resume?.sessionId === sessionId &&
4094
+ this.resumeMatchesFreshAiReturn(resume, pendingLaunch)
3915
4095
  ? {
3916
4096
  resumeCredential: resume.resumeCredential,
3917
4097
  protocolVersion: resume.protocolVersion,
3918
4098
  }
3919
4099
  : result?.sessionId === sessionId &&
3920
4100
  typeof result.resumeCredential === "string" &&
3921
- (result.protocolVersion === 1 || result.protocolVersion === 2)
4101
+ this.resultMatchesFreshAiReturn(result, pendingLaunch)
3922
4102
  ? {
3923
4103
  resumeCredential: result.resumeCredential,
3924
4104
  protocolVersion: result.protocolVersion,
@@ -3976,9 +4156,13 @@ export default class AiLiveAssist {
3976
4156
  provisional.conversationDisplayId;
3977
4157
  const released = {
3978
4158
  ...provisional,
4159
+ v: 2,
3979
4160
  phase: "released",
3980
4161
  launchIdempotencyKey: null,
3981
4162
  sessionId: null,
4163
+ protocolVersion: null,
4164
+ experienceMode: null,
4165
+ scopes: null,
3982
4166
  };
3983
4167
  return this.persistFreshAiReturnProvisional(released);
3984
4168
  }
@@ -3999,6 +4183,46 @@ export default class AiLiveAssist {
3999
4183
  // interrupted must never be replaced by a second fresh operation.
4000
4184
  return provisional.phase === "launching" ? "launch" : "blocked";
4001
4185
  }
4186
+ if (provisional.protocolVersion === null ||
4187
+ provisional.experienceMode === null ||
4188
+ provisional.scopes === null) {
4189
+ if (provisional.launchIdempotencyKey === null ||
4190
+ !resume ||
4191
+ resume.sessionId !== sessionId) {
4192
+ return "blocked";
4193
+ }
4194
+ const upgraded = {
4195
+ ...provisional,
4196
+ v: 2,
4197
+ sessionId,
4198
+ protocolVersion: resume.protocolVersion,
4199
+ experienceMode: resume.experienceMode,
4200
+ scopes: [...resume.scopes],
4201
+ };
4202
+ if (!this.boundFreshAiReturnProvisional(upgraded) ||
4203
+ !this.persistFreshAiReturnProvisional(upgraded)) {
4204
+ return "blocked";
4205
+ }
4206
+ provisional = upgraded;
4207
+ }
4208
+ const pendingLaunch = this.boundFreshAiReturnProvisional(provisional);
4209
+ if (!pendingLaunch)
4210
+ return "blocked";
4211
+ let hasSessionAuthority = false;
4212
+ if (resume?.sessionId === sessionId) {
4213
+ if (!this.resumeMatchesFreshAiReturn(resume, pendingLaunch)) {
4214
+ return "blocked";
4215
+ }
4216
+ hasSessionAuthority = true;
4217
+ }
4218
+ if (this.result?.sessionId === sessionId) {
4219
+ if (!this.resultMatchesFreshAiReturn(this.result, pendingLaunch)) {
4220
+ return "blocked";
4221
+ }
4222
+ hasSessionAuthority = true;
4223
+ }
4224
+ if (!hasSessionAuthority)
4225
+ return "blocked";
4002
4226
  if (provisional.phase === "launching") {
4003
4227
  provisional = {
4004
4228
  ...provisional,
@@ -4200,17 +4424,61 @@ export default class AiLiveAssist {
4200
4424
  const currentProvisional = this.freshAiReturnProvisionalFor(conversationId);
4201
4425
  if (!currentProvisional)
4202
4426
  return false;
4427
+ if (currentProvisional.phase === "launching" &&
4428
+ currentProvisional.mode !== mode) {
4429
+ return false;
4430
+ }
4431
+ const persistedLaunchScopes = currentProvisional.phase === "launching"
4432
+ ? currentProvisional.scopes
4433
+ : null;
4434
+ if (currentProvisional.phase === "launching" &&
4435
+ (currentProvisional.protocolVersion === null ||
4436
+ currentProvisional.experienceMode === null ||
4437
+ persistedLaunchScopes === null)) {
4438
+ // A legacy Fresh-return record does not contain the complete request
4439
+ // body. Reusing its idempotency key with current bootstrap scopes or
4440
+ // protocol negotiation would create a different operation.
4441
+ return false;
4442
+ }
4203
4443
  const launchIdempotencyKey = currentProvisional.phase === "launching"
4204
4444
  ? currentProvisional.launchIdempotencyKey
4205
4445
  : idempotencyKey("launch");
4206
4446
  if (!launchIdempotencyKey)
4207
4447
  return false;
4448
+ const launchScopes = persistedLaunchScopes
4449
+ ? [...persistedLaunchScopes]
4450
+ : [...scopes];
4451
+ const provisionalPendingLaunch = currentProvisional.protocolVersion !== null &&
4452
+ currentProvisional.experienceMode !== null
4453
+ ? {
4454
+ mode: currentProvisional.mode,
4455
+ scopes: launchScopes,
4456
+ idempotencyKey: launchIdempotencyKey,
4457
+ protocolVersion: currentProvisional.protocolVersion,
4458
+ experienceMode: currentProvisional.experienceMode,
4459
+ }
4460
+ : {
4461
+ mode: currentProvisional.mode,
4462
+ scopes: launchScopes,
4463
+ idempotencyKey: launchIdempotencyKey,
4464
+ protocolVersion: null,
4465
+ experienceMode: null,
4466
+ };
4467
+ const pendingLaunch = currentProvisional.phase === "launching"
4468
+ ? bindPendingLaunchIntent(provisionalPendingLaunch, bootstrap)
4469
+ : freshPendingLaunchIntent(mode, scopes, launchIdempotencyKey, bootstrap);
4470
+ if (!pendingLaunch)
4471
+ return false;
4208
4472
  const launching = {
4209
4473
  ...currentProvisional,
4210
- mode,
4474
+ v: 2,
4475
+ mode: pendingLaunch.mode,
4211
4476
  phase: "launching",
4212
4477
  launchIdempotencyKey,
4213
4478
  sessionId: null,
4479
+ protocolVersion: pendingLaunch.protocolVersion,
4480
+ experienceMode: pendingLaunch.experienceMode,
4481
+ scopes: [...pendingLaunch.scopes],
4214
4482
  };
4215
4483
  if (!this.persistFreshAiReturnProvisional(launching))
4216
4484
  return false;
@@ -4226,7 +4494,7 @@ export default class AiLiveAssist {
4226
4494
  inboxId: messengerDescriptor.inboxId,
4227
4495
  linkId: this.companion.linkId,
4228
4496
  launchIdempotencyKey,
4229
- mode,
4497
+ mode: pendingLaunch.mode,
4230
4498
  previousAiSessionId: this.companion.currentAiSessionId,
4231
4499
  surface: this.messengerTransferredSurface,
4232
4500
  }
@@ -4234,10 +4502,11 @@ export default class AiLiveAssist {
4234
4502
  if (!messengerReturnAuthority)
4235
4503
  return false;
4236
4504
  this.messengerReturnRetryRemaining = 1;
4237
- await this.launch(mode, scopes, launchIdempotencyKey, {
4238
- startMuted: mode === "voice",
4505
+ await this.launch(pendingLaunch.mode, pendingLaunch.scopes, launchIdempotencyKey, {
4506
+ startMuted: pendingLaunch.mode === "voice",
4239
4507
  associateCompanion: false,
4240
4508
  messengerReturnAuthority,
4509
+ pendingLaunch,
4241
4510
  });
4242
4511
  this.messengerReturnRetryRemaining = null;
4243
4512
  const sessionId = this.result?.sessionId;
@@ -4432,6 +4701,7 @@ export default class AiLiveAssist {
4432
4701
  if (this.pushActive)
4433
4702
  this.media?.beginPushToTalk();
4434
4703
  this.closeMessengerBeforeOpen();
4704
+ await this.prepareMessengerIntegration();
4435
4705
  return true;
4436
4706
  }
4437
4707
  async associateFreshCompanionSession(mode, sessionId) {
@@ -4555,11 +4825,6 @@ export default class AiLiveAssist {
4555
4825
  const prepared = await this.withMessengerTimeout(messenger.openHandoffConversation(displayId, options), MESSENGER_LOAD_TIMEOUT_MS);
4556
4826
  if (!prepared || !surfaceCurrent())
4557
4827
  return;
4558
- const returnActivated = await this.createMessengerReturnActivationPromise(messenger, displayId);
4559
- if (!returnActivated || !surfaceCurrent()) {
4560
- this.closeMessengerBeforeOpen();
4561
- return;
4562
- }
4563
4828
  const switched = await this.requestCompanionSurface("messenger", mode, idempotencyKey("companion_switch"), result.sessionId);
4564
4829
  const switchedCurrent = () => generation === this.sessionEffectGeneration &&
4565
4830
  this.result === result &&
@@ -4627,6 +4892,11 @@ export default class AiLiveAssist {
4627
4892
  await this.restoreAiAfterFailedSupportSwitch(mode, result.sessionId, previousMuted, previousPushActive);
4628
4893
  return;
4629
4894
  }
4895
+ const returnActivated = await this.createMessengerReturnActivationPromise(messenger, displayId);
4896
+ if (!returnActivated || !switchedCurrent()) {
4897
+ await this.restoreAiAfterFailedSupportSwitch(mode, result.sessionId, previousMuted, previousPushActive);
4898
+ return;
4899
+ }
4630
4900
  this.messenger = messenger;
4631
4901
  this.messengerSurfaceActive = true;
4632
4902
  this.messengerTransferredConversationId = displayId;
@@ -6189,9 +6459,27 @@ export default class AiLiveAssist {
6189
6459
  releaseMessengerReturnIfPreRuntime();
6190
6460
  return;
6191
6461
  }
6192
- const experienceMode = bootstrap.experienceMode ?? "standard";
6462
+ const retryIntent = options.pendingLaunch;
6463
+ if (retryIntent &&
6464
+ canonicalJson({ mode, scopes, idempotencyKey: launchIdempotencyKey }) !==
6465
+ canonicalJson({
6466
+ mode: retryIntent.mode,
6467
+ scopes: retryIntent.scopes,
6468
+ idempotencyKey: retryIntent.idempotencyKey,
6469
+ })) {
6470
+ releaseMessengerReturnIfPreRuntime();
6471
+ return;
6472
+ }
6473
+ let durableLaunch = retryIntent
6474
+ ? bindPendingLaunchIntent(retryIntent, bootstrap)
6475
+ : freshPendingLaunchIntent(mode, scopes, launchIdempotencyKey, bootstrap);
6476
+ if (!durableLaunch) {
6477
+ this.view.showError(this.view.message("reconnectFailed"), false, false);
6478
+ releaseMessengerReturnIfPreRuntime();
6479
+ return;
6480
+ }
6481
+ const { experienceMode, protocolVersion } = durableLaunch;
6193
6482
  const voicePresence = experienceMode === "voice_presence";
6194
- const protocolVersion = resolveAiGuideProtocolVersion(bootstrap);
6195
6483
  if (voicePresence && mode !== "voice") {
6196
6484
  this.view.showError("Voice assist requires microphone access.", false, false);
6197
6485
  releaseMessengerReturnIfPreRuntime();
@@ -6212,11 +6500,7 @@ export default class AiLiveAssist {
6212
6500
  this.visitorActiveTakeoverOnly = false;
6213
6501
  }
6214
6502
  if (this.visitorActiveTakeoverOnly) {
6215
- const pendingLaunch = {
6216
- mode,
6217
- scopes: [...scopes],
6218
- idempotencyKey: launchIdempotencyKey,
6219
- };
6503
+ const pendingLaunch = durableLaunch;
6220
6504
  this.lastLaunch = pendingLaunch;
6221
6505
  this.view.showActiveElsewhere(false);
6222
6506
  this.view.showConnecting(true, mode);
@@ -6230,11 +6514,7 @@ export default class AiLiveAssist {
6230
6514
  }
6231
6515
  this.pageAssistanceAuthorityGeneration += 1;
6232
6516
  this.rotatePageAssistanceBindingGeneration();
6233
- this.lastLaunch = {
6234
- mode,
6235
- scopes: [...scopes],
6236
- idempotencyKey: launchIdempotencyKey,
6237
- };
6517
+ this.lastLaunch = { ...durableLaunch, scopes: [...durableLaunch.scopes] };
6238
6518
  this.launchInFlight = true;
6239
6519
  if (!this.lock.acquire()) {
6240
6520
  this.view.showActiveElsewhere(false);
@@ -6292,7 +6572,8 @@ export default class AiLiveAssist {
6292
6572
  if (voicePresence ||
6293
6573
  mode !== "voice" ||
6294
6574
  !bootstrap.capabilities?.text ||
6295
- options.messengerReturnAuthority !== undefined) {
6575
+ options.messengerReturnAuthority !== undefined ||
6576
+ retryIntent !== undefined) {
6296
6577
  throw error;
6297
6578
  }
6298
6579
  this.releaseMedia(media);
@@ -6322,22 +6603,20 @@ export default class AiLiveAssist {
6322
6603
  this.navigationConsentResetCandidate = null;
6323
6604
  this.navigationConsentReset = null;
6324
6605
  this.navigationConsentResetReady = null;
6325
- this.lastLaunch = {
6326
- mode: activeMode,
6327
- scopes: [...activeScopes],
6328
- idempotencyKey: launchIdempotencyKey,
6329
- };
6606
+ if (!retryIntent) {
6607
+ durableLaunch = {
6608
+ ...durableLaunch,
6609
+ mode: activeMode,
6610
+ scopes: [...activeScopes],
6611
+ };
6612
+ }
6613
+ this.lastLaunch = { ...durableLaunch, scopes: [...durableLaunch.scopes] };
6330
6614
  this.visualConsentGranted = activeScopes.includes("visual_context");
6331
6615
  assertConnectionCurrent();
6332
6616
  if (this.lock.ownsLatestFocus?.() === false &&
6333
6617
  this.relinquishForNewerFocus()) {
6334
6618
  return;
6335
6619
  }
6336
- const durableLaunch = {
6337
- mode: activeMode,
6338
- scopes: [...activeScopes],
6339
- idempotencyKey: launchIdempotencyKey,
6340
- };
6341
6620
  if (!this.markStagedPendingLaunchInFlight(durableLaunch)) {
6342
6621
  this.releaseMedia(media);
6343
6622
  this.cleanupTransport();
@@ -6382,16 +6661,19 @@ export default class AiLiveAssist {
6382
6661
  }
6383
6662
  }
6384
6663
  }
6664
+ if (result.schemaVersion !== protocolVersion ||
6665
+ result.protocolVersion !== protocolVersion ||
6666
+ result.experienceMode !== experienceMode) {
6667
+ throw new RuntimeClientError("PROTOCOL_INVALID", "The voice assist runtime returned an incompatible session.", false, null, 502);
6668
+ }
6385
6669
  if (this.terminalRequestPending) {
6386
- this.captureTerminalAuthorizedResult(result);
6670
+ if (!this.captureTerminalAuthorizedResult(result)) {
6671
+ throw new RuntimeClientError("PROTOCOL_INVALID", "The voice assist runtime returned an incompatible session.", false, null, 502);
6672
+ }
6387
6673
  runtimeSessionCreated = true;
6388
6674
  return;
6389
6675
  }
6390
6676
  assertConnectionCurrent();
6391
- if (result.experienceMode !== experienceMode ||
6392
- result.protocolVersion !== protocolVersion) {
6393
- throw new RuntimeClientError("PROTOCOL_INVALID", "The voice assist runtime returned an incompatible session.", false, null, 502);
6394
- }
6395
6677
  if (!this.commitAuthorizedRuntimePath(requestedLaunchPath, true)) {
6396
6678
  throw new RuntimeClientError("PROTOCOL_INVALID", "The voice assist runtime authorized an invalid page path.", false, null, 502);
6397
6679
  }
@@ -6526,7 +6808,8 @@ export default class AiLiveAssist {
6526
6808
  this.terminalRequestPending ||
6527
6809
  this.ended ||
6528
6810
  this.destroyed ||
6529
- this.lastLaunch?.idempotencyKey !== pendingLaunch.idempotencyKey ||
6811
+ !this.lastLaunch ||
6812
+ !this.pendingLaunchMatches(this.lastLaunch, pendingLaunch) ||
6530
6813
  this.readResume()) {
6531
6814
  return;
6532
6815
  }
@@ -6558,7 +6841,8 @@ export default class AiLiveAssist {
6558
6841
  this.visitorActiveEligibilityRecheckKey = pendingLaunch.idempotencyKey;
6559
6842
  const pendingIntentIsCurrent = () => launchGeneration === this.launchGeneration &&
6560
6843
  this.visitorActiveEligibilityRecheckController === controller &&
6561
- this.lastLaunch?.idempotencyKey === pendingLaunch.idempotencyKey &&
6844
+ Boolean(this.lastLaunch &&
6845
+ this.pendingLaunchMatches(this.lastLaunch, pendingLaunch)) &&
6562
6846
  !this.terminalRequestPending &&
6563
6847
  !this.ended &&
6564
6848
  !this.destroyed &&
@@ -6618,7 +6902,7 @@ export default class AiLiveAssist {
6618
6902
  this.visitorActiveTakeoverOnly = false;
6619
6903
  this.lastLaunch = null;
6620
6904
  this.view?.showActiveElsewhere(false);
6621
- await this.launch(pendingLaunch.mode, pendingLaunch.scopes, pendingLaunch.idempotencyKey);
6905
+ await this.launch(pendingLaunch.mode, pendingLaunch.scopes, pendingLaunch.idempotencyKey, { pendingLaunch });
6622
6906
  return;
6623
6907
  }
6624
6908
  if (status === "visitor_limited") {
@@ -6650,7 +6934,8 @@ export default class AiLiveAssist {
6650
6934
  this.visitorActiveEligibilityRecheck = operation;
6651
6935
  }
6652
6936
  finishVisitorActiveEligibilityRecheck(pendingLaunch, messageKey) {
6653
- if (this.lastLaunch?.idempotencyKey !== pendingLaunch.idempotencyKey) {
6937
+ if (!this.lastLaunch ||
6938
+ !this.pendingLaunchMatches(this.lastLaunch, pendingLaunch)) {
6654
6939
  return;
6655
6940
  }
6656
6941
  this.lastLaunch = null;
@@ -6807,8 +7092,15 @@ export default class AiLiveAssist {
6807
7092
  }
6808
7093
  }
6809
7094
  }
7095
+ if (result.schemaVersion !== record.protocolVersion ||
7096
+ result.protocolVersion !== record.protocolVersion ||
7097
+ result.experienceMode !== record.experienceMode) {
7098
+ throw new RuntimeClientError("PROTOCOL_INVALID", "The saved voice assist session is no longer compatible.", false, null, 409);
7099
+ }
6810
7100
  if (this.terminalRequestPending) {
6811
- this.captureTerminalAuthorizedResult(result, record);
7101
+ if (!this.captureTerminalAuthorizedResult(result, record)) {
7102
+ throw new RuntimeClientError("PROTOCOL_INVALID", "The saved voice assist session is no longer compatible.", false, null, 409);
7103
+ }
6812
7104
  if (modeUpgrade)
6813
7105
  this.modeUpgradeTerminalResult = result;
6814
7106
  attemptedCredentials.add(result.resumeCredential);
@@ -6818,8 +7110,6 @@ export default class AiLiveAssist {
6818
7110
  return;
6819
7111
  assertConnectionCurrent();
6820
7112
  if (result.sessionId !== record.sessionId ||
6821
- result.protocolVersion !== record.protocolVersion ||
6822
- result.experienceMode !== record.experienceMode ||
6823
7113
  (result.mode !== undefined &&
6824
7114
  result.mode !== "text" &&
6825
7115
  result.mode !== "voice") ||
@@ -13097,7 +13387,7 @@ export default class AiLiveAssist {
13097
13387
  return;
13098
13388
  }
13099
13389
  this.messengerReturnRetryRemaining -= 1;
13100
- const retryAttempt = this.launch(this.lastLaunch.mode, this.lastLaunch.scopes, this.lastLaunch.idempotencyKey);
13390
+ const retryAttempt = this.launch(this.lastLaunch.mode, this.lastLaunch.scopes, this.lastLaunch.idempotencyKey, { pendingLaunch: this.lastLaunch });
13101
13391
  return retryAttempt.finally(() => {
13102
13392
  if (this.result && !this.ended) {
13103
13393
  this.messengerReturnRetryRemaining = null;
@@ -13117,7 +13407,7 @@ export default class AiLiveAssist {
13117
13407
  return this.resume(resume);
13118
13408
  }
13119
13409
  if (this.lastLaunch) {
13120
- return this.launch(this.lastLaunch.mode, this.lastLaunch.scopes, this.lastLaunch.idempotencyKey);
13410
+ return this.launch(this.lastLaunch.mode, this.lastLaunch.scopes, this.lastLaunch.idempotencyKey, { pendingLaunch: this.lastLaunch });
13121
13411
  }
13122
13412
  }
13123
13413
  scheduleReconnect(force = false) {
@@ -13447,6 +13737,18 @@ export default class AiLiveAssist {
13447
13737
  canonicalJson(left.messengerBinding) ===
13448
13738
  canonicalJson(right.messengerBinding));
13449
13739
  }
13740
+ sameResumeAuthorityIdentity(left, right) {
13741
+ const credentialRotated = left.resumeCredential !== right.resumeCredential;
13742
+ if (!credentialRotated && left.expiresAt !== right.expiresAt)
13743
+ return false;
13744
+ const { resumeCredential: _leftCredential, expiresAt: _leftCredentialExpiry, ...leftAuthority } = left;
13745
+ const { resumeCredential: _rightCredential, expiresAt: _rightCredentialExpiry, ...rightAuthority } = right;
13746
+ void _leftCredential;
13747
+ void _rightCredential;
13748
+ void _leftCredentialExpiry;
13749
+ void _rightCredentialExpiry;
13750
+ return canonicalJson(leftAuthority) === canonicalJson(rightAuthority);
13751
+ }
13450
13752
  sameModeUpgradeIntent(left, right) {
13451
13753
  return (left?.requestId === right?.requestId &&
13452
13754
  left?.resumeIdempotencyKey === right?.resumeIdempotencyKey &&
@@ -13815,10 +14117,22 @@ export default class AiLiveAssist {
13815
14117
  this.state("ended");
13816
14118
  }
13817
14119
  captureTerminalAuthorizedResult(result, sourceRecord = null) {
13818
- if (sourceRecord &&
13819
- (result.sessionId !== sourceRecord.sessionId ||
13820
- result.protocolVersion !== sourceRecord.protocolVersion ||
13821
- result.experienceMode !== sourceRecord.experienceMode)) {
14120
+ const pendingLaunch = this.terminalPendingLaunch &&
14121
+ pendingLaunchIsBound(this.terminalPendingLaunch)
14122
+ ? this.terminalPendingLaunch
14123
+ : null;
14124
+ const expectedProtocolVersion = sourceRecord?.protocolVersion ??
14125
+ pendingLaunch?.protocolVersion ??
14126
+ result.protocolVersion;
14127
+ const expectedExperienceMode = sourceRecord?.experienceMode ??
14128
+ pendingLaunch?.experienceMode ??
14129
+ result.experienceMode;
14130
+ if (result.schemaVersion !== expectedProtocolVersion ||
14131
+ result.protocolVersion !== expectedProtocolVersion ||
14132
+ result.experienceMode !== expectedExperienceMode ||
14133
+ (sourceRecord &&
14134
+ (result.sessionId !== sourceRecord.sessionId ||
14135
+ result.protocolVersion !== sourceRecord.protocolVersion))) {
13822
14136
  return false;
13823
14137
  }
13824
14138
  const currentIntent = this.terminalEndIntent;
@@ -13844,6 +14158,22 @@ export default class AiLiveAssist {
13844
14158
  }
13845
14159
  return true;
13846
14160
  }
14161
+ retryRejectedTerminalAuthorizedResult(result) {
14162
+ const error = this.rejectTerminalAuthorizedResult(result);
14163
+ return this.retryTerminalEnd(error);
14164
+ }
14165
+ rejectTerminalAuthorizedResult(result) {
14166
+ if (this.result === result)
14167
+ this.result = null;
14168
+ if (this.modeUpgradeTerminalResult === result) {
14169
+ this.modeUpgradeTerminalResult = null;
14170
+ }
14171
+ this.terminalSessionAuthorization = null;
14172
+ this.terminalSessionAuthorizationController = null;
14173
+ const error = new RuntimeClientError("PROTOCOL_INVALID", "The assist runtime returned an incompatible terminal session.", false, null, 409);
14174
+ this.terminalSessionAuthorizationFailure = error;
14175
+ return error;
14176
+ }
13847
14177
  completeRuntimeEnd() {
13848
14178
  if (this.ended)
13849
14179
  return Promise.resolve();
@@ -13874,8 +14204,9 @@ export default class AiLiveAssist {
13874
14204
  if (terminalAuthorization) {
13875
14205
  return this.waitForTerminalRequestDeadline(terminalAuthorization, this.terminalSessionAuthorizationController).then((authorized) => {
13876
14206
  const result = authorized ?? this.result ?? this.modeUpgradeTerminalResult;
13877
- if (result) {
13878
- this.captureTerminalAuthorizedResult(result, this.terminalResumeRecord);
14207
+ if (result &&
14208
+ !this.captureTerminalAuthorizedResult(result, this.terminalResumeRecord)) {
14209
+ return this.retryRejectedTerminalAuthorizedResult(result);
13879
14210
  }
13880
14211
  return this.finishRuntimeEndAttempt(result);
13881
14212
  }, (error) => {
@@ -13892,7 +14223,9 @@ export default class AiLiveAssist {
13892
14223
  }
13893
14224
  if (this.terminalPendingLaunch) {
13894
14225
  return this.requestTerminalPendingLaunch(this.terminalPendingLaunch).then((result) => {
13895
- this.captureTerminalAuthorizedResult(result);
14226
+ if (!this.captureTerminalAuthorizedResult(result)) {
14227
+ return this.retryRejectedTerminalAuthorizedResult(result);
14228
+ }
13896
14229
  return this.finishRuntimeEndAttempt(result);
13897
14230
  }, (recoveryError) => this.retryTerminalEnd(recoveryError));
13898
14231
  }
@@ -13919,7 +14252,9 @@ export default class AiLiveAssist {
13919
14252
  return this.finishRuntimeEndAttempt(null);
13920
14253
  if (this.terminalPendingLaunch) {
13921
14254
  return this.requestTerminalPendingLaunch(this.terminalPendingLaunch).then((result) => {
13922
- this.captureTerminalAuthorizedResult(result);
14255
+ if (!this.captureTerminalAuthorizedResult(result)) {
14256
+ return this.retryRejectedTerminalAuthorizedResult(result);
14257
+ }
13923
14258
  return this.finishRuntimeEndAttempt(result);
13924
14259
  }, (error) => this.retryTerminalEnd(error));
13925
14260
  }
@@ -14076,13 +14411,21 @@ export default class AiLiveAssist {
14076
14411
  requestTerminalPendingLaunch(pendingLaunch) {
14077
14412
  const intent = this.terminalEndIntent;
14078
14413
  if (!intent ||
14079
- intent.launchIdempotencyKey !== pendingLaunch.idempotencyKey ||
14080
- !this.markStagedPendingLaunchInFlight(pendingLaunch)) {
14414
+ intent.launchIdempotencyKey !== pendingLaunch.idempotencyKey) {
14081
14415
  return Promise.reject(new TerminalRequestDeferredError());
14082
14416
  }
14083
14417
  const controller = new AbortController();
14084
14418
  const request = (async () => {
14085
14419
  const bootstrap = await this.terminalPendingLaunchBootstrap(controller.signal);
14420
+ const boundPendingLaunch = bindPendingLaunchIntent(pendingLaunch, bootstrap);
14421
+ if (!boundPendingLaunch ||
14422
+ !this.markStagedPendingLaunchInFlight(boundPendingLaunch)) {
14423
+ throw new TerminalRequestDeferredError();
14424
+ }
14425
+ this.terminalPendingLaunch = {
14426
+ ...boundPendingLaunch,
14427
+ scopes: [...boundPendingLaunch.scopes],
14428
+ };
14086
14429
  const replay = await this.replayReadiness.wait();
14087
14430
  if (this.destroyed ||
14088
14431
  this.ended ||
@@ -14091,28 +14434,34 @@ export default class AiLiveAssist {
14091
14434
  throw new TerminalRequestDeferredError();
14092
14435
  }
14093
14436
  this.replayReadiness.assertCurrent(replay);
14094
- return this.runtime.launch({
14437
+ const result = await this.runtime.launch({
14095
14438
  siteKey: this.siteKey,
14096
14439
  configRevision: bootstrap.configRevision,
14097
14440
  path: path(),
14098
14441
  tabId: this.tabId,
14099
- mode: pendingLaunch.mode,
14442
+ mode: boundPendingLaunch.mode,
14100
14443
  anonymousId: this.visitorId(),
14101
14444
  replay,
14102
14445
  disclosureChecksum: bootstrap.consent.disclosureChecksum,
14103
- scopes: pendingLaunch.scopes,
14446
+ scopes: boundPendingLaunch.scopes,
14104
14447
  locale: this.locale,
14105
- microphone: pendingLaunch.mode === "voice",
14448
+ microphone: boundPendingLaunch.mode === "voice",
14106
14449
  reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)")
14107
14450
  .matches,
14108
- protocolVersion: resolveAiGuideProtocolVersion(bootstrap),
14109
- experienceMode: bootstrap.experienceMode ?? "standard",
14451
+ protocolVersion: boundPendingLaunch.protocolVersion,
14452
+ experienceMode: boundPendingLaunch.experienceMode,
14110
14453
  ...(this.activeFallback
14111
14454
  ? { fallbackIntent: this.activeFallback }
14112
14455
  : {}),
14113
14456
  lowPower: this.lowPowerMode(),
14114
- idempotencyKey: pendingLaunch.idempotencyKey,
14457
+ idempotencyKey: boundPendingLaunch.idempotencyKey,
14115
14458
  }, controller.signal);
14459
+ if (result.schemaVersion !== boundPendingLaunch.protocolVersion ||
14460
+ result.protocolVersion !== boundPendingLaunch.protocolVersion ||
14461
+ result.experienceMode !== boundPendingLaunch.experienceMode) {
14462
+ throw new RuntimeClientError("PROTOCOL_INVALID", "The voice assist runtime returned an incompatible session.", false, null, 502);
14463
+ }
14464
+ return result;
14116
14465
  })();
14117
14466
  return this.waitForTerminalRequestDeadline(request, controller);
14118
14467
  }
@@ -14276,8 +14625,7 @@ export default class AiLiveAssist {
14276
14625
  const retainTerminalLaunch = Boolean(this.terminalSessionAuthorization &&
14277
14626
  this.terminalPendingLaunch &&
14278
14627
  staged?.state === "in_flight" &&
14279
- staged.pendingLaunch.idempotencyKey ===
14280
- this.terminalPendingLaunch.idempotencyKey);
14628
+ this.pendingLaunchMatches(staged.pendingLaunch, this.terminalPendingLaunch));
14281
14629
  if (!retainTerminalLaunch)
14282
14630
  this.clearOwnedStagedPendingLaunch();
14283
14631
  this.lock?.cancelPendingHandoff?.();
@@ -14701,15 +15049,18 @@ export default class AiLiveAssist {
14701
15049
  focusedHandoffPayload() {
14702
15050
  this.transcript ?? (this.transcript = []);
14703
15051
  const resume = this.readResume();
15052
+ const pendingLaunch = !resume && this.lastLaunch && this.bootstrap
15053
+ ? bindPendingLaunchIntent(this.lastLaunch, this.bootstrap)
15054
+ : null;
14704
15055
  return {
14705
- v: 1,
15056
+ v: pendingLaunch ? 2 : 1,
14706
15057
  resume,
14707
15058
  transcript: this.transcript.map((entry) => ({ ...entry })),
14708
- ...(!resume && this.lastLaunch
15059
+ ...(pendingLaunch
14709
15060
  ? {
14710
15061
  pendingLaunch: {
14711
- ...this.lastLaunch,
14712
- scopes: [...this.lastLaunch.scopes],
15062
+ ...pendingLaunch,
15063
+ scopes: [...pendingLaunch.scopes],
14713
15064
  },
14714
15065
  }
14715
15066
  : {}),
@@ -14724,33 +15075,157 @@ export default class AiLiveAssist {
14724
15075
  }
14725
15076
  }
14726
15077
  readStagedPendingLaunch() {
14727
- let raw = null;
14728
15078
  try {
14729
- raw = localStorage.getItem(this.stagedPendingLaunchStorageKey);
14730
- const staged = parseStagedPendingLaunch(raw);
14731
- if (raw && !staged) {
15079
+ const v2Raw = localStorage.getItem(this.stagedPendingLaunchStorageKey);
15080
+ const parsedV2 = parseStagedPendingLaunch(v2Raw);
15081
+ const v2 = parsedV2?.v === 2 ? parsedV2 : null;
15082
+ if (v2Raw && !v2) {
14732
15083
  localStorage.removeItem(this.stagedPendingLaunchStorageKey);
14733
15084
  }
14734
- return staged;
15085
+ const legacyRaw = localStorage.getItem(this.legacyStagedPendingLaunchStorageKey);
15086
+ const parsedLegacy = parseStagedPendingLaunch(legacyRaw);
15087
+ const legacy = parsedLegacy?.v === 1 ? parsedLegacy : null;
15088
+ if (legacyRaw && !legacy) {
15089
+ localStorage.removeItem(this.legacyStagedPendingLaunchStorageKey);
15090
+ }
15091
+ if (!v2)
15092
+ return legacy;
15093
+ if (!legacy)
15094
+ return v2;
15095
+ const authorityOrder = compareTransferAuthority(v2.authority, legacy.authority);
15096
+ if (authorityOrder !== 0)
15097
+ return authorityOrder > 0 ? v2 : legacy;
15098
+ if (v2.sourceOwnerId !== legacy.sourceOwnerId ||
15099
+ !this.pendingLaunchBaseMatches(v2.pendingLaunch, legacy.pendingLaunch)) {
15100
+ return null;
15101
+ }
15102
+ // A V2 record is the only copy that preserves the exact launch body.
15103
+ // Prefer it for an equal authority; the legacy copy is cleaned only
15104
+ // after a matching V2 write has been verified.
15105
+ return v2;
14735
15106
  }
14736
15107
  catch {
14737
15108
  return null;
14738
15109
  }
14739
15110
  }
14740
15111
  persistStagedPendingLaunch(staged) {
15112
+ let rollback = null;
14741
15113
  try {
14742
- localStorage.setItem(this.stagedPendingLaunchStorageKey, JSON.stringify(staged));
14743
- return (canonicalJson(this.readStagedPendingLaunch()) === canonicalJson(staged));
15114
+ const serializedPendingLaunch = staged.v === 1
15115
+ ? {
15116
+ mode: staged.pendingLaunch.mode,
15117
+ scopes: [...staged.pendingLaunch.scopes],
15118
+ idempotencyKey: staged.pendingLaunch.idempotencyKey,
15119
+ }
15120
+ : staged.pendingLaunch;
15121
+ const storageKey = staged.v === 2
15122
+ ? this.stagedPendingLaunchStorageKey
15123
+ : this.legacyStagedPendingLaunchStorageKey;
15124
+ const serialized = JSON.stringify({
15125
+ ...staged,
15126
+ pendingLaunch: serializedPendingLaunch,
15127
+ });
15128
+ const previousSerialized = localStorage.getItem(storageKey);
15129
+ const previousLegacySerialized = localStorage.getItem(this.legacyStagedPendingLaunchStorageKey);
15130
+ let removedMatchingLegacy = false;
15131
+ rollback = () => {
15132
+ if (localStorage.getItem(storageKey) === serialized) {
15133
+ if (previousSerialized === null)
15134
+ localStorage.removeItem(storageKey);
15135
+ else
15136
+ localStorage.setItem(storageKey, previousSerialized);
15137
+ }
15138
+ if (removedMatchingLegacy &&
15139
+ previousLegacySerialized !== null &&
15140
+ localStorage.getItem(this.legacyStagedPendingLaunchStorageKey) ===
15141
+ null) {
15142
+ localStorage.setItem(this.legacyStagedPendingLaunchStorageKey, previousLegacySerialized);
15143
+ }
15144
+ };
15145
+ localStorage.setItem(storageKey, serialized);
15146
+ const normalizedPendingLaunch = staged.v === 1
15147
+ ? {
15148
+ ...serializedPendingLaunch,
15149
+ protocolVersion: null,
15150
+ experienceMode: null,
15151
+ }
15152
+ : staged.pendingLaunch;
15153
+ const expected = {
15154
+ ...staged,
15155
+ pendingLaunch: normalizedPendingLaunch,
15156
+ };
15157
+ const directReadback = parseStagedPendingLaunch(localStorage.getItem(storageKey));
15158
+ if (canonicalJson(directReadback) !== canonicalJson(expected)) {
15159
+ rollback();
15160
+ return false;
15161
+ }
15162
+ if (staged.v === 2) {
15163
+ const legacySerialized = localStorage.getItem(this.legacyStagedPendingLaunchStorageKey);
15164
+ const legacy = parseStagedPendingLaunch(legacySerialized);
15165
+ const equalAuthority = legacy?.v === 1 &&
15166
+ compareTransferAuthority(legacy.authority, staged.authority) === 0;
15167
+ if (equalAuthority &&
15168
+ (legacy.sourceOwnerId !== staged.sourceOwnerId ||
15169
+ !this.pendingLaunchBaseMatches(legacy.pendingLaunch, staged.pendingLaunch))) {
15170
+ rollback();
15171
+ return false;
15172
+ }
15173
+ if (legacy?.v === 1 &&
15174
+ legacy.sourceOwnerId === staged.sourceOwnerId &&
15175
+ equalAuthority &&
15176
+ this.pendingLaunchBaseMatches(legacy.pendingLaunch, staged.pendingLaunch) &&
15177
+ legacySerialized !== null &&
15178
+ localStorage.getItem(this.legacyStagedPendingLaunchStorageKey) ===
15179
+ legacySerialized) {
15180
+ localStorage.removeItem(this.legacyStagedPendingLaunchStorageKey);
15181
+ removedMatchingLegacy = true;
15182
+ }
15183
+ }
15184
+ const persisted = canonicalJson(this.readStagedPendingLaunch()) ===
15185
+ canonicalJson(expected);
15186
+ if (!persisted)
15187
+ rollback();
15188
+ return persisted;
14744
15189
  }
14745
15190
  catch {
15191
+ try {
15192
+ rollback?.();
15193
+ }
15194
+ catch {
15195
+ // Storage is already unavailable. Leave the launch unclaimed and
15196
+ // fail closed rather than trusting a partially observed write.
15197
+ }
14746
15198
  return false;
14747
15199
  }
14748
15200
  }
15201
+ pendingLaunchBaseMatches(left, right) {
15202
+ return (canonicalJson({
15203
+ mode: left.mode,
15204
+ scopes: left.scopes,
15205
+ idempotencyKey: left.idempotencyKey,
15206
+ }) ===
15207
+ canonicalJson({
15208
+ mode: right.mode,
15209
+ scopes: right.scopes,
15210
+ idempotencyKey: right.idempotencyKey,
15211
+ }));
15212
+ }
14749
15213
  pendingLaunchMatches(left, right) {
14750
- return canonicalJson(left) === canonicalJson(right);
15214
+ const leftBound = pendingLaunchIsBound(left);
15215
+ const rightBound = pendingLaunchIsBound(right);
15216
+ if (leftBound !== rightBound)
15217
+ return false;
15218
+ if (leftBound && rightBound) {
15219
+ return canonicalJson(left) === canonicalJson(right);
15220
+ }
15221
+ return this.pendingLaunchBaseMatches(left, right);
14751
15222
  }
14752
15223
  stagePendingLaunch(pendingLaunch, authority, sourceOwnerId) {
14753
- if (!PUBLIC_REQUEST_ID_PATTERN.test(sourceOwnerId) ||
15224
+ const boundPendingLaunch = this.bootstrap
15225
+ ? bindPendingLaunchIntent(pendingLaunch, this.bootstrap)
15226
+ : null;
15227
+ if (!boundPendingLaunch ||
15228
+ !PUBLIC_REQUEST_ID_PATTERN.test(sourceOwnerId) ||
14754
15229
  sourceOwnerId === authority.ownerId) {
14755
15230
  return false;
14756
15231
  }
@@ -14761,35 +15236,82 @@ export default class AiLiveAssist {
14761
15236
  if (authorityOrder < 0 ||
14762
15237
  (authorityOrder === 0 &&
14763
15238
  (existing?.sourceOwnerId !== sourceOwnerId ||
14764
- !this.pendingLaunchMatches(existing.pendingLaunch, pendingLaunch) ||
15239
+ !this.pendingLaunchMatches(!pendingLaunchIsBound(existing.pendingLaunch) && this.bootstrap
15240
+ ? (bindPendingLaunchIntent(existing.pendingLaunch, this.bootstrap) ?? existing.pendingLaunch)
15241
+ : existing.pendingLaunch, boundPendingLaunch) ||
14765
15242
  existing.state !== "prepared"))) {
14766
15243
  return false;
14767
15244
  }
14768
15245
  return this.persistStagedPendingLaunch({
14769
- v: 1,
15246
+ v: 2,
14770
15247
  sourceOwnerId,
14771
15248
  authority: { ...authority },
14772
15249
  state: "prepared",
14773
15250
  claimAuthority: null,
14774
15251
  expiresAt: Date.now() + STAGED_PENDING_LAUNCH_TTL_MS,
14775
15252
  pendingLaunch: {
14776
- ...pendingLaunch,
14777
- scopes: [...pendingLaunch.scopes],
15253
+ ...boundPendingLaunch,
15254
+ scopes: [...boundPendingLaunch.scopes],
14778
15255
  },
14779
15256
  });
14780
15257
  }
14781
15258
  clearStagedPendingLaunch(pendingLaunch, authority) {
14782
15259
  const staged = this.readStagedPendingLaunch();
15260
+ const comparablePending = this.bootstrap && !pendingLaunchIsBound(pendingLaunch)
15261
+ ? bindPendingLaunchIntent(pendingLaunch, this.bootstrap)
15262
+ : pendingLaunch;
15263
+ const comparableStaged = this.bootstrap && staged && !pendingLaunchIsBound(staged.pendingLaunch)
15264
+ ? bindPendingLaunchIntent(staged.pendingLaunch, this.bootstrap)
15265
+ : staged?.pendingLaunch;
14783
15266
  if (!staged ||
15267
+ !comparablePending ||
15268
+ !comparableStaged ||
14784
15269
  compareTransferAuthority(staged.authority, authority) !== 0 ||
14785
- !this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch)) {
14786
- return;
15270
+ !this.pendingLaunchMatches(comparableStaged, comparablePending)) {
15271
+ return false;
14787
15272
  }
14788
15273
  try {
14789
- localStorage.removeItem(this.stagedPendingLaunchStorageKey);
15274
+ const snapshots = [
15275
+ {
15276
+ key: this.stagedPendingLaunchStorageKey,
15277
+ raw: localStorage.getItem(this.stagedPendingLaunchStorageKey),
15278
+ version: 2,
15279
+ },
15280
+ {
15281
+ key: this.legacyStagedPendingLaunchStorageKey,
15282
+ raw: localStorage.getItem(this.legacyStagedPendingLaunchStorageKey),
15283
+ version: 1,
15284
+ },
15285
+ ];
15286
+ const removable = [];
15287
+ for (const snapshot of snapshots) {
15288
+ if (snapshot.raw === null)
15289
+ continue;
15290
+ const shadow = parseStagedPendingLaunch(snapshot.raw);
15291
+ if (!shadow || shadow.v !== snapshot.version)
15292
+ return false;
15293
+ const authorityOrder = compareTransferAuthority(shadow.authority, staged.authority);
15294
+ if (authorityOrder > 0)
15295
+ continue;
15296
+ if (authorityOrder === 0 &&
15297
+ (shadow.sourceOwnerId !== staged.sourceOwnerId ||
15298
+ !this.pendingLaunchBaseMatches(shadow.pendingLaunch, staged.pendingLaunch))) {
15299
+ return false;
15300
+ }
15301
+ removable.push(snapshot);
15302
+ }
15303
+ if (removable.some((snapshot) => localStorage.getItem(snapshot.key) !== snapshot.raw)) {
15304
+ return false;
15305
+ }
15306
+ for (const snapshot of removable) {
15307
+ if (localStorage.getItem(snapshot.key) !== snapshot.raw)
15308
+ return false;
15309
+ localStorage.removeItem(snapshot.key);
15310
+ }
15311
+ return removable.every((snapshot) => localStorage.getItem(snapshot.key) === null);
14790
15312
  }
14791
15313
  catch {
14792
- /* noop */
15314
+ return false;
14793
15315
  }
14794
15316
  }
14795
15317
  clearOwnedStagedPendingLaunch() {
@@ -14806,7 +15328,7 @@ export default class AiLiveAssist {
14806
15328
  const staged = this.readStagedPendingLaunch();
14807
15329
  if (!pendingLaunch ||
14808
15330
  !staged ||
14809
- staged.pendingLaunch.idempotencyKey !== pendingLaunch.idempotencyKey ||
15331
+ !this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch) ||
14810
15332
  this.terminalEndIntent?.launchIdempotencyKey !==
14811
15333
  pendingLaunch.idempotencyKey) {
14812
15334
  return;
@@ -14817,11 +15339,15 @@ export default class AiLiveAssist {
14817
15339
  const pendingLaunch = parseFocusedHandoffPayload(value)?.pendingLaunch;
14818
15340
  if (!pendingLaunch)
14819
15341
  return true;
15342
+ const boundPendingLaunch = this.bootstrap
15343
+ ? bindPendingLaunchIntent(pendingLaunch, this.bootstrap)
15344
+ : null;
14820
15345
  const staged = this.readStagedPendingLaunch();
14821
- if (!staged ||
15346
+ if (!boundPendingLaunch ||
15347
+ !staged ||
14822
15348
  staged.state !== "prepared" ||
14823
15349
  compareTransferAuthority(staged.authority, authority) !== 0 ||
14824
- !this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch)) {
15350
+ !this.pendingLaunchMatches(staged.pendingLaunch, boundPendingLaunch)) {
14825
15351
  return false;
14826
15352
  }
14827
15353
  return this.persistStagedPendingLaunch({
@@ -14865,6 +15391,12 @@ export default class AiLiveAssist {
14865
15391
  }
14866
15392
  markStagedPendingLaunchInFlight(pendingLaunch) {
14867
15393
  const staged = this.readStagedPendingLaunch();
15394
+ const boundPendingLaunch = pendingLaunchIsBound(pendingLaunch)
15395
+ ? pendingLaunch
15396
+ : this.bootstrap
15397
+ ? bindPendingLaunchIntent(pendingLaunch, this.bootstrap)
15398
+ : null;
15399
+ const durablePendingLaunch = boundPendingLaunch ?? pendingLaunch;
14868
15400
  // AiLiveAssist always installs a TabActivityLock with durable authority.
14869
15401
  // Keep prototype-level test harnesses that intentionally omit the lock
14870
15402
  // implementation isolated from the persistence contract.
@@ -14876,40 +15408,54 @@ export default class AiLiveAssist {
14876
15408
  }
14877
15409
  if (!staged) {
14878
15410
  return this.persistStagedPendingLaunch({
14879
- v: 1,
15411
+ v: boundPendingLaunch ? 2 : 1,
14880
15412
  sourceOwnerId: randomId("launch_source"),
14881
15413
  authority: { ...ownedAuthority },
14882
15414
  state: "in_flight",
14883
15415
  claimAuthority: { ...ownedAuthority },
14884
15416
  expiresAt: Date.now() + STAGED_PENDING_LAUNCH_TTL_MS,
14885
15417
  pendingLaunch: {
14886
- ...pendingLaunch,
14887
- scopes: [...pendingLaunch.scopes],
15418
+ ...durablePendingLaunch,
15419
+ scopes: [...durablePendingLaunch.scopes],
14888
15420
  },
14889
15421
  });
14890
15422
  }
15423
+ const comparableStagedPendingLaunch = pendingLaunchIsBound(staged.pendingLaunch)
15424
+ ? staged.pendingLaunch
15425
+ : this.bootstrap
15426
+ ? bindPendingLaunchIntent(staged.pendingLaunch, this.bootstrap)
15427
+ : null;
14891
15428
  if (staged.pendingLaunch.idempotencyKey !== pendingLaunch.idempotencyKey ||
14892
15429
  !staged.claimAuthority ||
14893
15430
  compareTransferAuthority(staged.claimAuthority, ownedAuthority) !== 0 ||
14894
- (staged.state !== "claimed" && staged.state !== "in_flight")) {
15431
+ (staged.state !== "claimed" && staged.state !== "in_flight") ||
15432
+ (boundPendingLaunch
15433
+ ? !comparableStagedPendingLaunch ||
15434
+ !this.pendingLaunchMatches(comparableStagedPendingLaunch, boundPendingLaunch)
15435
+ : !this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch))) {
14895
15436
  return false;
14896
15437
  }
14897
- if (staged.state === "in_flight")
15438
+ if (staged.state === "in_flight" &&
15439
+ staged.v === (boundPendingLaunch ? 2 : 1) &&
15440
+ this.pendingLaunchMatches(staged.pendingLaunch, durablePendingLaunch)) {
14898
15441
  return true;
15442
+ }
14899
15443
  return this.persistStagedPendingLaunch({
14900
15444
  ...staged,
15445
+ v: boundPendingLaunch ? 2 : 1,
14901
15446
  state: "in_flight",
14902
15447
  pendingLaunch: {
14903
- ...pendingLaunch,
14904
- scopes: [...pendingLaunch.scopes],
15448
+ ...durablePendingLaunch,
15449
+ scopes: [...durablePendingLaunch.scopes],
14905
15450
  },
14906
15451
  });
14907
15452
  }
14908
15453
  acceptStagedPendingLaunch(pendingLaunch, result) {
14909
15454
  const staged = this.readStagedPendingLaunch();
14910
- if (!staged ||
14911
- !this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch)) {
15455
+ if (!staged)
14912
15456
  return true;
15457
+ if (!this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch)) {
15458
+ return false;
14913
15459
  }
14914
15460
  const ownedAuthority = this.lock.ownedAuthority?.() ?? null;
14915
15461
  const stored = this.readResume();
@@ -14933,17 +15479,11 @@ export default class AiLiveAssist {
14933
15479
  })) {
14934
15480
  return false;
14935
15481
  }
14936
- try {
14937
- localStorage.removeItem(this.stagedPendingLaunchStorageKey);
14938
- return localStorage.getItem(this.stagedPendingLaunchStorageKey) === null;
14939
- }
14940
- catch {
14941
- return false;
14942
- }
15482
+ return this.clearStagedPendingLaunch(staged.pendingLaunch, staged.authority);
14943
15483
  }
14944
15484
  hasStagedPendingLaunch(pendingLaunch) {
14945
- return (this.readStagedPendingLaunch()?.pendingLaunch.idempotencyKey ===
14946
- pendingLaunch.idempotencyKey);
15485
+ const staged = this.readStagedPendingLaunch();
15486
+ return Boolean(staged && this.pendingLaunchMatches(staged.pendingLaunch, pendingLaunch));
14947
15487
  }
14948
15488
  storeTransferAuthority(authority) {
14949
15489
  const current = this.readTransferAuthority();
@@ -15021,12 +15561,21 @@ export default class AiLiveAssist {
15021
15561
  if (!this.freshAiReturnProvisionalStorageKey)
15022
15562
  return null;
15023
15563
  try {
15024
- const serialized = sessionStorage.getItem(this.freshAiReturnProvisionalStorageKey);
15025
- const provisional = parseFreshAiReturnProvisional(serialized ? JSON.parse(serialized) : null);
15026
- if (serialized && !provisional) {
15564
+ const v2Serialized = sessionStorage.getItem(this.freshAiReturnProvisionalStorageKey);
15565
+ const parsedV2 = parseFreshAiReturnProvisional(v2Serialized ? JSON.parse(v2Serialized) : null);
15566
+ const v2 = parsedV2?.v === 2 ? parsedV2 : null;
15567
+ if (v2Serialized && !v2) {
15027
15568
  sessionStorage.removeItem(this.freshAiReturnProvisionalStorageKey);
15028
15569
  }
15029
- return provisional;
15570
+ if (v2)
15571
+ return v2;
15572
+ const legacySerialized = sessionStorage.getItem(this.legacyFreshAiReturnProvisionalStorageKey);
15573
+ const parsedLegacy = parseFreshAiReturnProvisional(legacySerialized ? JSON.parse(legacySerialized) : null);
15574
+ const legacy = parsedLegacy?.v === 1 ? parsedLegacy : null;
15575
+ if (legacySerialized && !legacy) {
15576
+ sessionStorage.removeItem(this.legacyFreshAiReturnProvisionalStorageKey);
15577
+ }
15578
+ return legacy;
15030
15579
  }
15031
15580
  catch {
15032
15581
  return null;
@@ -15036,9 +15585,54 @@ export default class AiLiveAssist {
15036
15585
  if (!this.freshAiReturnProvisionalStorageKey)
15037
15586
  return false;
15038
15587
  try {
15039
- sessionStorage.setItem(this.freshAiReturnProvisionalStorageKey, JSON.stringify(provisional));
15588
+ const serialized = provisional.v === 1
15589
+ ? {
15590
+ v: 1,
15591
+ linkId: provisional.linkId,
15592
+ conversationDisplayId: provisional.conversationDisplayId,
15593
+ mode: provisional.mode,
15594
+ phase: provisional.phase,
15595
+ launchIdempotencyKey: provisional.launchIdempotencyKey,
15596
+ sessionId: provisional.sessionId,
15597
+ expiresAt: provisional.expiresAt,
15598
+ }
15599
+ : provisional;
15600
+ const storageKey = provisional.v === 2
15601
+ ? this.freshAiReturnProvisionalStorageKey
15602
+ : this.legacyFreshAiReturnProvisionalStorageKey;
15603
+ sessionStorage.setItem(storageKey, JSON.stringify(serialized));
15604
+ const normalized = provisional.v === 1
15605
+ ? {
15606
+ ...provisional,
15607
+ protocolVersion: null,
15608
+ experienceMode: null,
15609
+ scopes: null,
15610
+ }
15611
+ : provisional;
15612
+ const directReadback = parseFreshAiReturnProvisional(JSON.parse(sessionStorage.getItem(storageKey) ?? "null"));
15613
+ if (canonicalJson(directReadback) !== canonicalJson(normalized)) {
15614
+ return false;
15615
+ }
15616
+ if (provisional.v === 2) {
15617
+ const legacySerialized = sessionStorage.getItem(this.legacyFreshAiReturnProvisionalStorageKey);
15618
+ const legacy = parseFreshAiReturnProvisional(legacySerialized ? JSON.parse(legacySerialized) : null);
15619
+ const sameReturnLineage = legacy?.v === 1 &&
15620
+ legacy.linkId === provisional.linkId &&
15621
+ legacy.conversationDisplayId === provisional.conversationDisplayId &&
15622
+ legacy.mode === provisional.mode &&
15623
+ legacy.expiresAt === provisional.expiresAt;
15624
+ if (sameReturnLineage &&
15625
+ (legacy.phase === "released" ||
15626
+ provisional.phase === "released" ||
15627
+ (legacy.phase === provisional.phase &&
15628
+ legacy.launchIdempotencyKey ===
15629
+ provisional.launchIdempotencyKey &&
15630
+ legacy.sessionId === provisional.sessionId))) {
15631
+ sessionStorage.removeItem(this.legacyFreshAiReturnProvisionalStorageKey);
15632
+ }
15633
+ }
15040
15634
  return (canonicalJson(this.readFreshAiReturnProvisional()) ===
15041
- canonicalJson(provisional));
15635
+ canonicalJson(normalized));
15042
15636
  }
15043
15637
  catch {
15044
15638
  return false;
@@ -15048,19 +15642,21 @@ export default class AiLiveAssist {
15048
15642
  if (!this.freshAiReturnProvisionalStorageKey)
15049
15643
  return;
15050
15644
  try {
15051
- const serialized = sessionStorage.getItem(this.freshAiReturnProvisionalStorageKey);
15645
+ const storageKey = expected?.v === 1
15646
+ ? this.legacyFreshAiReturnProvisionalStorageKey
15647
+ : this.freshAiReturnProvisionalStorageKey;
15648
+ const serialized = sessionStorage.getItem(storageKey);
15052
15649
  if (!serialized)
15053
15650
  return;
15054
15651
  if (expected) {
15055
15652
  const stored = parseFreshAiReturnProvisional(JSON.parse(serialized));
15056
15653
  if (canonicalJson(stored) !== canonicalJson(expected))
15057
15654
  return;
15058
- if (sessionStorage.getItem(this.freshAiReturnProvisionalStorageKey) !==
15059
- serialized) {
15655
+ if (sessionStorage.getItem(storageKey) !== serialized) {
15060
15656
  return;
15061
15657
  }
15062
15658
  }
15063
- sessionStorage.removeItem(this.freshAiReturnProvisionalStorageKey);
15659
+ sessionStorage.removeItem(storageKey);
15064
15660
  }
15065
15661
  catch {
15066
15662
  /* noop */
@@ -15651,10 +16247,7 @@ export default class AiLiveAssist {
15651
16247
  !this.view ||
15652
16248
  (record &&
15653
16249
  (!record.tabId ||
15654
- record.protocolVersion !==
15655
- resolveAiGuideProtocolVersion(this.bootstrap) ||
15656
- record.experienceMode !==
15657
- (this.bootstrap.experienceMode ?? "standard"))) ||
16250
+ !resumeProtocolIsSupported(record, this.bootstrap))) ||
15658
16251
  (pendingLaunch &&
15659
16252
  (this.visitorActiveTakeoverOnly ||
15660
16253
  ((this.bootstrap.experienceMode ?? "standard") === "voice_presence" &&
@@ -15690,10 +16283,11 @@ export default class AiLiveAssist {
15690
16283
  this.lock.release();
15691
16284
  };
15692
16285
  const preparedMatches = record
15693
- ? prepared?.resume?.sessionId === record.sessionId
16286
+ ? Boolean(prepared?.resume &&
16287
+ this.sameResumeAuthorityIdentity(prepared.resume, record))
15694
16288
  : Boolean(pendingLaunch &&
15695
- prepared?.pendingLaunch?.idempotencyKey ===
15696
- pendingLaunch.idempotencyKey);
16289
+ prepared?.pendingLaunch &&
16290
+ this.pendingLaunchMatches(prepared.pendingLaunch, pendingLaunch));
15697
16291
  if (!payload ||
15698
16292
  (!record && !pendingLaunch) ||
15699
16293
  !this.bootstrap ||
@@ -15706,10 +16300,7 @@ export default class AiLiveAssist {
15706
16300
  !preparedMatches ||
15707
16301
  (record &&
15708
16302
  (!record.tabId ||
15709
- record.protocolVersion !==
15710
- resolveAiGuideProtocolVersion(this.bootstrap) ||
15711
- record.experienceMode !==
15712
- (this.bootstrap.experienceMode ?? "standard"))) ||
16303
+ !resumeProtocolIsSupported(record, this.bootstrap))) ||
15713
16304
  (pendingLaunch && this.visitorActiveTakeoverOnly)) {
15714
16305
  abandonUnusableCommit();
15715
16306
  return;
@@ -15721,7 +16312,7 @@ export default class AiLiveAssist {
15721
16312
  return;
15722
16313
  }
15723
16314
  if (this.lastLaunch &&
15724
- this.lastLaunch.idempotencyKey !== stagedPending.idempotencyKey) {
16315
+ !this.pendingLaunchMatches(this.lastLaunch, stagedPending)) {
15725
16316
  this.clearStagedPendingLaunch(stagedPending, authority);
15726
16317
  }
15727
16318
  else {
@@ -15733,6 +16324,12 @@ export default class AiLiveAssist {
15733
16324
  return;
15734
16325
  }
15735
16326
  const storedResume = record ? this.readResume() : null;
16327
+ if (record &&
16328
+ storedResume?.sessionId === record.sessionId &&
16329
+ !this.sameResumeAuthorityIdentity(record, storedResume)) {
16330
+ abandonUnusableCommit();
16331
+ return;
16332
+ }
15736
16333
  const effectivePayload = record &&
15737
16334
  storedResume?.sessionId === record.sessionId &&
15738
16335
  storedResume.resumeCredential !== record.resumeCredential
@@ -15773,11 +16370,11 @@ export default class AiLiveAssist {
15773
16370
  completeFocusedTabRelinquish(value) {
15774
16371
  const payload = parseFocusedHandoffPayload(value);
15775
16372
  const sameResume = Boolean(payload?.resume &&
15776
- this.focusedHandoffInFlight?.resume?.sessionId ===
15777
- payload.resume.sessionId);
16373
+ this.focusedHandoffInFlight?.resume &&
16374
+ this.sameResumeAuthorityIdentity(this.focusedHandoffInFlight.resume, payload.resume));
15778
16375
  const samePendingLaunch = Boolean(payload?.pendingLaunch &&
15779
- this.focusedHandoffInFlight?.pendingLaunch?.idempotencyKey ===
15780
- payload.pendingLaunch.idempotencyKey);
16376
+ this.focusedHandoffInFlight?.pendingLaunch &&
16377
+ this.pendingLaunchMatches(this.focusedHandoffInFlight.pendingLaunch, payload.pendingLaunch));
15781
16378
  if (!payload || (!sameResume && !samePendingLaunch)) {
15782
16379
  return;
15783
16380
  }
@@ -15808,8 +16405,8 @@ export default class AiLiveAssist {
15808
16405
  this.clearStagedPendingLaunch(pendingLaunch, authority);
15809
16406
  }
15810
16407
  if (pendingLaunch &&
15811
- this.focusedHandoffInFlight?.pendingLaunch?.idempotencyKey ===
15812
- pendingLaunch.idempotencyKey) {
16408
+ this.focusedHandoffInFlight?.pendingLaunch &&
16409
+ this.pendingLaunchMatches(this.focusedHandoffInFlight.pendingLaunch, pendingLaunch)) {
15813
16410
  this.focusedHandoffInFlight = null;
15814
16411
  if (this.terminalRequestPending ||
15815
16412
  this.ended ||
@@ -15831,7 +16428,7 @@ export default class AiLiveAssist {
15831
16428
  queueMicrotask(() => {
15832
16429
  const retry = this.lastLaunch;
15833
16430
  if (!retry ||
15834
- retry.idempotencyKey !== pendingLaunch.idempotencyKey ||
16431
+ !this.pendingLaunchMatches(retry, pendingLaunch) ||
15835
16432
  this.terminalRequestPending ||
15836
16433
  this.ended ||
15837
16434
  this.destroyed ||
@@ -15840,7 +16437,9 @@ export default class AiLiveAssist {
15840
16437
  return;
15841
16438
  }
15842
16439
  this.lastLaunch = null;
15843
- void this.launch(retry.mode, retry.scopes, retry.idempotencyKey);
16440
+ void this.launch(retry.mode, retry.scopes, retry.idempotencyKey, {
16441
+ pendingLaunch: retry,
16442
+ });
15844
16443
  });
15845
16444
  return;
15846
16445
  }
@@ -15906,7 +16505,7 @@ export default class AiLiveAssist {
15906
16505
  this.view &&
15907
16506
  !this.visitorActiveTakeoverOnly) {
15908
16507
  this.lastLaunch = null;
15909
- await this.launch(deferredLaunch.mode, deferredLaunch.scopes, deferredLaunch.idempotencyKey);
16508
+ await this.launch(deferredLaunch.mode, deferredLaunch.scopes, deferredLaunch.idempotencyKey, { pendingLaunch: deferredLaunch });
15910
16509
  return;
15911
16510
  }
15912
16511
  this.lock.release();