wtagent 0.2.2 → 0.4.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.
@@ -5,7 +5,6 @@ import { launchAndConnectCdpChrome } from "./cdp-browser.js";
5
5
  import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
6
6
  import { ensureDirectory } from "../platform/paths.js";
7
7
  import { BrowserAdapterError } from "../shared/errors.js";
8
- import { runModeSelection } from "./mode-selection.js";
9
8
 
10
9
  // Playwright error messages for a dead transport. The Chrome process itself is
11
10
  // usually still alive (e.g. the connection died while the Mac slept); these
@@ -30,6 +29,26 @@ export function isConnectionLostError(error) {
30
29
  const CHALLENGE_BODY_PATTERN =
31
30
  /just a moment|verify you are human|checking your browser|security check|attention required|请稍候|安全验证|正在验证|人机验证|少々お待ち|セキュリティ|ご本人確認|認証|잠시만 기다려|보안 확인|인증|un instant|vérification|einen moment|sicherheitsprüfung|verificando|um momento/i;
32
31
 
32
+ // A composer can mount well before an existing SPA conversation finishes
33
+ // hydrating. Require several seconds of unchanged URL + DOM state before either
34
+ // restored history or an empty new-chat page is trusted.
35
+ const RESTORATION_STABLE_CHECKS = 12;
36
+ const RESTORATION_STABLE_WINDOW_MS = (RESTORATION_STABLE_CHECKS - 1) * 250;
37
+
38
+ function normalizedPathname(value) {
39
+ const pathname = String(value || "/").replace(/\/+$/, "");
40
+ return pathname || "/";
41
+ }
42
+
43
+ function normalizedConversationUrl(value) {
44
+ try {
45
+ const parsed = value instanceof URL ? value : new URL(value);
46
+ return `${parsed.origin}${normalizedPathname(parsed.pathname)}`;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
33
52
  export async function firstVisible(locators) {
34
53
  for (const locator of locators) {
35
54
  const count = await locator.count().catch(() => 0);
@@ -61,14 +80,70 @@ export function hasCompleteAgentEnvelope(text) {
61
80
  }
62
81
 
63
82
  function sameConversationUrl(left, right) {
64
- try {
65
- const leftUrl = new URL(left);
66
- const rightUrl = new URL(right);
67
- return leftUrl.origin === rightUrl.origin
68
- && leftUrl.pathname === rightUrl.pathname;
69
- } catch {
83
+ const normalizedLeft = normalizedConversationUrl(left);
84
+ return normalizedLeft != null
85
+ && normalizedLeft === normalizedConversationUrl(right);
86
+ }
87
+
88
+ function comparableMessageText(value) {
89
+ return String(value ?? "").replace(/\s+/g, " ").trim();
90
+ }
91
+
92
+ const OUTBOUND_ID_PATTERN =
93
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
94
+ const OUTBOUND_MARKER_PREFIX =
95
+ "Opaque WTAgent transport correlation ID (do not repeat): ";
96
+
97
+ function renderedMessageContains(rendered, expected, outboundId = null) {
98
+ const normalizedRendered = comparableMessageText(rendered);
99
+ const normalizedExpected = comparableMessageText(expected);
100
+ const reminderStart = normalizedExpected.lastIndexOf("<system_reminder>");
101
+ const reminder = reminderStart < 0 ? "" : normalizedExpected.slice(reminderStart);
102
+ const correlation = reminder.match(
103
+ /(Opaque WTAgent transport correlation ID \(do not repeat\): ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.)<\/system_reminder>$/,
104
+ );
105
+ if (!correlation) {
106
+ return outboundId == null
107
+ && normalizedExpected.length > 0
108
+ && normalizedRendered.includes(normalizedExpected);
109
+ }
110
+ if (outboundId != null && correlation[2] !== outboundId) {
111
+ return false;
112
+ }
113
+ // Markdown can change the displayed payload. Correlate only the exact
114
+ // per-send nonce from its final reminder; URL/history guards remain separate.
115
+ const marker = correlation[1];
116
+ const index = normalizedRendered.indexOf(marker);
117
+ if (index < 0 || normalizedRendered.indexOf(marker, index + marker.length) !== -1) {
118
+ return false;
119
+ }
120
+ const renderedReminderStart = normalizedRendered.lastIndexOf("<system_reminder>", index);
121
+ if (
122
+ renderedReminderStart < 0
123
+ || normalizedRendered.slice(renderedReminderStart, index).includes("</system_reminder>")
124
+ ) {
125
+ return false;
126
+ }
127
+ const closingTag = "</system_reminder>";
128
+ const renderedSuffix = normalizedRendered.slice(index + marker.length);
129
+ if (!renderedSuffix.startsWith(closingTag)) {
130
+ return false;
131
+ }
132
+ const trailing = renderedSuffix.slice(closingTag.length);
133
+ return !trailing.includes("<system_reminder")
134
+ && !trailing.includes("</system_reminder>");
135
+ }
136
+
137
+ export function isValidOutboundCorrelationId(value) {
138
+ return typeof value === "string" && OUTBOUND_ID_PATTERN.test(value);
139
+ }
140
+
141
+ export function renderedMessageContainsOutboundMarker(rendered, outboundId) {
142
+ if (!isValidOutboundCorrelationId(outboundId)) {
70
143
  return false;
71
144
  }
145
+ const expected = `<system_reminder>${OUTBOUND_MARKER_PREFIX}${outboundId}.</system_reminder>`;
146
+ return renderedMessageContains(rendered, expected, outboundId);
72
147
  }
73
148
 
74
149
  // Provider-independent orchestration for a web-AI conversation driven over CDP.
@@ -76,9 +151,10 @@ function sameConversationUrl(left, right) {
76
151
  // The runtime talks to this class only through its public methods; everything
77
152
  // that varies between providers (ChatGPT, DeepSeek, …) is isolated behind the
78
153
  // overridable "primitive" methods below. A concrete provider subclass supplies
79
- // its base URL, DOM locators, message-identity extraction, and (optionally) a
80
- // model-switcher port; it must NOT re-implement the turn-completion loop, the
81
- // send/auth/reconnect flow, or the WTAgent <agent_response> protocol timing.
154
+ // its base URL, DOM locators, and message-identity extraction; it must NOT
155
+ // re-implement the turn-completion loop, send/auth/reconnect flow, or the
156
+ // WTAgent <agent_response> protocol timing. Model selection deliberately stays
157
+ // outside adapters: users choose their model directly on the provider website.
82
158
  //
83
159
  // IMPORTANT (JavaScript semantics): primitives that the base dispatches to a
84
160
  // provider are declared as ordinary (non-#private) methods. `#private` methods
@@ -108,12 +184,42 @@ export class BaseWebAdapter {
108
184
  this.context = null;
109
185
  this.cdpChrome = null;
110
186
  this.page = null;
187
+ this.preservedPreferredUrl = null;
188
+ this.observedConversationUrl = null;
189
+ this.observedConversationAliases = new Set();
190
+ this.pendingFreshConversationAliases = new Set();
191
+ this.pendingFreshEmptyAliases = new Map();
192
+ this.conversationIdentityListener = null;
193
+ this.pageNavigationListener = null;
194
+ this.lastObservedPageUrl = null;
195
+ this.pendingCanonicalUrl = null;
196
+ this.pendingCanonicalSignature = null;
197
+ this.pendingCanonicalStableSince = 0;
198
+ this.pendingCanonicalVerificationQueue = Promise.resolve();
199
+ this.conversationObservationArmed = false;
200
+ this.freshSendConfirmed = false;
201
+ this.navigationEpoch = 0;
202
+ this.sendEpoch = 0;
203
+ this.conversationScopeEpoch = 0;
204
+ this.activeSendProof = null;
205
+ this.activeTurnProof = null;
206
+ this.activeTurnConversationUrl = null;
207
+ this.allowInPlaceAssistantContinuation = false;
208
+ this.inPlaceAssistantGenerationObserved = false;
209
+ this.expectedRestorationAssistantId = null;
210
+ this.expectedRestorationUserId = null;
211
+ this.expectedAssistantCandidateId = null;
212
+ this.expectedAssistantCandidateTurn = null;
213
+ this.recoveredTurnTopologyRequired = false;
214
+ this.preferredTabAmbiguous = false;
111
215
  this.assistantIdsBeforeSend = new Set();
112
216
  this.assistantMaxTurnBeforeSend = null;
113
217
  this.assistantCountBeforeSend = 0;
114
218
  this.sentUserTurn = null;
219
+ this.lastUserMessageId = null;
115
220
  this.lastAssistantMessageId = null;
116
- this.lastModeSelection = null;
221
+ this.lastAssistantTurn = null;
222
+ this.lastSendStatus = "not-submitted";
117
223
  }
118
224
 
119
225
  // ---- provider primitives (override in subclasses) ----------------------
@@ -128,6 +234,48 @@ export class BaseWebAdapter {
128
234
  return null;
129
235
  }
130
236
 
237
+ freshConversationUrlPattern() {
238
+ // Optional extra provider routes that explicitly mean a blank new chat. The
239
+ // baseUrl pathname is always accepted; arbitrary other same-origin routes are
240
+ // not. For example, Claude exposes both / and /new.
241
+ return null;
242
+ }
243
+
244
+ // Classifies saved/current URLs without navigating to them. Providers may
245
+ // refine a matching conversation route into "provisional" when it is useful
246
+ // only as a locator for an already-open tab, not as a durable navigation URL.
247
+ // Only the provider's explicit base route is fresh; an arbitrary same-origin
248
+ // settings/project/error route is unknown and must never receive a prompt.
249
+ classifyConversationUrl(value) {
250
+ let parsed;
251
+ let base;
252
+ try {
253
+ parsed = value instanceof URL ? value : new URL(value);
254
+ base = new URL(this.baseUrl);
255
+ } catch {
256
+ return "invalid";
257
+ }
258
+ if (parsed.protocol !== "https:" || parsed.origin !== base.origin) {
259
+ return "invalid";
260
+ }
261
+
262
+ const pattern = this.conversationUrlPattern();
263
+ if (pattern?.test(parsed.pathname)) {
264
+ return "restorable";
265
+ }
266
+ const explicitFresh = normalizedPathname(parsed.pathname)
267
+ === normalizedPathname(base.pathname)
268
+ || this.freshConversationUrlPattern()?.test(parsed.pathname);
269
+ return explicitFresh ? "fresh" : "unknown";
270
+ }
271
+
272
+ // Existing attachment previews in a reused tab may belong to an abandoned
273
+ // draft. Providers with uploads override this with structural chip locators;
274
+ // the base refuses to send until no stale chips remain.
275
+ pendingAttachmentLocators() {
276
+ return [];
277
+ }
278
+
131
279
  composerLocators() {
132
280
  return [];
133
281
  }
@@ -188,6 +336,42 @@ export class BaseWebAdapter {
188
336
  return await message.innerText().catch(() => "");
189
337
  }
190
338
 
339
+ // Used only to correlate a post-submit bubble while the provider is assigning
340
+ // a new conversation URL. Stable URL bindings remain the primary guard.
341
+ async userMessageText(message) {
342
+ return await message.innerText().catch(() => "");
343
+ }
344
+
345
+ // Providers with a shared user/assistant turn order return one atomic snapshot.
346
+ // null keeps legacy normal-send behavior but cannot support ambiguous recovery.
347
+ async orderedConversationSnapshot() {
348
+ return null;
349
+ }
350
+
351
+ // Recovery-capable providers revalidate the correlated user/assistant suffix
352
+ // from one atomic DOM snapshot while the generic completion loop is running.
353
+ // The default is a no-op because providers without ordered snapshots never
354
+ // enter pending-outbound reconciliation.
355
+ async assertRecoveredTurnTopology(_candidate = {}) {}
356
+
357
+ // Providers that expose a single shared user/assistant order can require that
358
+ // atomic view when binding a fresh send. This prevents separately queried role
359
+ // locators from being combined across SPA hydration frames.
360
+ requiresOrderedConversationSnapshotForFreshSend() {
361
+ return false;
362
+ }
363
+
364
+ supportsPendingOutboundRecovery() {
365
+ return false;
366
+ }
367
+
368
+ async reconcilePendingOutbound() {
369
+ throw new BrowserAdapterError(
370
+ `${this.providerName} does not support pending outbound recovery.`,
371
+ { code: "OUTBOUND_RECOVERY_UNSUPPORTED", recoverable: false },
372
+ );
373
+ }
374
+
191
375
  // Returns a short human string when the message is a plan/usage-limit notice,
192
376
  // or null when it is an ordinary reply. Default: never a limit.
193
377
  async findUsageLimitMarker(_message) {
@@ -203,23 +387,6 @@ export class BaseWebAdapter {
203
387
  return null;
204
388
  }
205
389
 
206
- // Port consumed by runModeSelection(). Default reports no switcher, so
207
- // selectMode() resolves to "switcher_not_found" and never blocks a provider
208
- // that has no model picker.
209
- modeSelectionPort() {
210
- return {
211
- alreadyOnMode: async () => false,
212
- hasSwitcher: async () => false,
213
- openMenu: async () => {},
214
- readOptions: async () => [],
215
- clickOption: async () => false,
216
- waitClosed: async () => false,
217
- waitSelected: async () => false,
218
- closeMenu: async () => {},
219
- writeDiagnostics: async (label) => this.writeDiagnostics(label),
220
- };
221
- }
222
-
223
390
  // Best-effort composer file upload. Default: nothing attached.
224
391
  async attachFiles(_files) {
225
392
  return { attached: [], failed: [] };
@@ -300,11 +467,57 @@ export class BaseWebAdapter {
300
467
  return this.hasReliableCompletionSignal() ? 10_000 : 90_000;
301
468
  }
302
469
 
303
- // How long sendMessage() waits for the user bubble to appear. GLM can take
304
- // several seconds to commit a filled textarea, especially after a long tool
305
- // result, so providers may widen this.
306
- sentUserWaitAttempts() {
307
- return 50;
470
+ // One monotonic deadline covers submit, URL allocation, user-bubble discovery,
471
+ // and causal stabilization. Providers may widen it for unusually slow commits.
472
+ sendConfirmationTimeoutMs() {
473
+ return 20_000;
474
+ }
475
+
476
+ monotonicNow() {
477
+ return performance.now();
478
+ }
479
+
480
+ async waitForPoll(ms) {
481
+ await this.page.waitForTimeout(ms);
482
+ }
483
+
484
+ #sendDeadlineError() {
485
+ return new BrowserAdapterError(
486
+ `${this.providerName} did not confirm the submitted message before the causal deadline. `
487
+ + "It was not sent again automatically.",
488
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
489
+ );
490
+ }
491
+
492
+ async #awaitWithinSendDeadline(promise, deadline) {
493
+ const remaining = deadline - this.monotonicNow();
494
+ if (remaining <= 0) {
495
+ throw this.#sendDeadlineError();
496
+ }
497
+ let timer;
498
+ try {
499
+ const result = await Promise.race([
500
+ Promise.resolve(promise),
501
+ new Promise((_, reject) => {
502
+ timer = setTimeout(
503
+ () => reject(this.#sendDeadlineError()),
504
+ Math.max(1, Math.ceil(remaining)),
505
+ );
506
+ }),
507
+ ]);
508
+ if (this.monotonicNow() >= deadline) {
509
+ throw this.#sendDeadlineError();
510
+ }
511
+ return result;
512
+ } finally {
513
+ clearTimeout(timer);
514
+ }
515
+ }
516
+
517
+ // Real elapsed time required before a URL/DOM correlation becomes trusted.
518
+ // Kept overridable only so deterministic adapter tests need not sleep.
519
+ restorationCorrelationWindowMs() {
520
+ return RESTORATION_STABLE_WINDOW_MS;
308
521
  }
309
522
 
310
523
  // Decide whether `candidate` is a genuinely new assistant reply for the
@@ -337,35 +550,924 @@ export class BaseWebAdapter {
337
550
  return 1;
338
551
  }
339
552
 
553
+ acceptRecoveredOutboundAnchor({
554
+ conversationUrl,
555
+ conversationTargetId,
556
+ userMessageId,
557
+ userTurn = null,
558
+ assistantBaseline = null,
559
+ assistantCandidateMessageId = null,
560
+ assistantCandidateTurn = null,
561
+ }) {
562
+ const normalizedUrl = normalizedConversationUrl(conversationUrl);
563
+ const kind = normalizedUrl
564
+ ? this.classifyConversationUrl(normalizedUrl)
565
+ : "invalid";
566
+ if (
567
+ !this.page
568
+ || !["restorable", "provisional"].includes(kind)
569
+ || !sameConversationUrl(this.page.url(), normalizedUrl)
570
+ || (conversationTargetId ?? null) !== (this.cdpChrome?.targetId ?? null)
571
+ ) {
572
+ throw new BrowserAdapterError(
573
+ `${this.providerName} recovery anchor no longer matches the exact browser target.`,
574
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
575
+ );
576
+ }
577
+
578
+ const baselineIds = Array.isArray(assistantBaseline?.ids)
579
+ ? assistantBaseline.ids.filter(Boolean)
580
+ : [];
581
+ this.#beginConversationObservation(normalizedUrl, {
582
+ arm: false,
583
+ reset: true,
584
+ });
585
+ this.assistantIdsBeforeSend = new Set(baselineIds);
586
+ this.assistantMaxTurnBeforeSend = Number.isSafeInteger(
587
+ assistantBaseline?.maxTurn,
588
+ )
589
+ ? assistantBaseline.maxTurn
590
+ : null;
591
+ this.assistantCountBeforeSend = Number.isSafeInteger(
592
+ assistantBaseline?.count,
593
+ )
594
+ ? assistantBaseline.count
595
+ : baselineIds.length;
596
+ this.lastAssistantTextBeforeSend = String(
597
+ assistantBaseline?.lastText ?? "",
598
+ );
599
+ this.sentUserTurn = Number.isSafeInteger(userTurn) ? userTurn : null;
600
+ this.lastUserMessageId = userMessageId ?? null;
601
+ this.expectedRestorationUserId = userMessageId ?? null;
602
+ this.expectedRestorationAssistantId = baselineIds.at(-1) ?? null;
603
+ this.expectedAssistantCandidateId = typeof assistantCandidateMessageId === "string"
604
+ && assistantCandidateMessageId
605
+ ? assistantCandidateMessageId
606
+ : null;
607
+ this.expectedAssistantCandidateTurn = Number.isSafeInteger(
608
+ assistantCandidateTurn,
609
+ )
610
+ ? assistantCandidateTurn
611
+ : null;
612
+ this.recoveredTurnTopologyRequired = true;
613
+ this.#captureActiveTurnProof(normalizedUrl);
614
+ return this.#currentConversationIdentity();
615
+ }
616
+
617
+ setConversationIdentityListener(listener) {
618
+ this.conversationIdentityListener = typeof listener === "function"
619
+ ? listener
620
+ : null;
621
+ }
622
+
623
+ async getConversationIdentity() {
624
+ const currentUrl = this.page?.url?.() ?? null;
625
+ if (
626
+ currentUrl
627
+ && normalizedConversationUrl(currentUrl) !== this.lastObservedPageUrl
628
+ ) {
629
+ this.#observeConversationNavigation(currentUrl);
630
+ }
631
+ await this.#verifyPendingCanonicalUrl();
632
+ return this.#currentConversationIdentity();
633
+ }
634
+
635
+ #currentConversationIdentity() {
636
+ const currentUrl = this.page?.url?.() ?? null;
637
+ const currentKind = currentUrl
638
+ ? this.classifyConversationUrl(currentUrl)
639
+ : "invalid";
640
+ const normalizedCurrent = normalizedConversationUrl(currentUrl);
641
+ const currentIsTrusted = normalizedCurrent != null && (
642
+ this.observedConversationAliases.has(normalizedCurrent)
643
+ || (
644
+ currentKind === "fresh"
645
+ && normalizedCurrent === this.observedConversationUrl
646
+ )
647
+ );
648
+ const conversationUrl = currentIsTrusted
649
+ ? normalizedCurrent
650
+ : this.observedConversationUrl;
651
+ return {
652
+ conversationUrl: conversationUrl ?? null,
653
+ targetId: this.cdpChrome?.targetId ?? null,
654
+ kind: conversationUrl
655
+ ? this.classifyConversationUrl(conversationUrl)
656
+ : "invalid",
657
+ };
658
+ }
659
+
660
+ #isObservedConversationAlias(value) {
661
+ const normalized = normalizedConversationUrl(value);
662
+ return normalized != null && this.observedConversationAliases.has(normalized);
663
+ }
664
+
665
+ #activeSendProofIsIntact() {
666
+ const proof = this.activeSendProof;
667
+ if (!proof) {
668
+ return true;
669
+ }
670
+ if (
671
+ proof.invalidated
672
+ || proof.sendEpoch !== this.sendEpoch
673
+ || proof.page !== this.page
674
+ ) {
675
+ return false;
676
+ }
677
+ if (
678
+ proof.mainFrame
679
+ && typeof this.page?.mainFrame === "function"
680
+ && this.page.mainFrame() !== proof.mainFrame
681
+ ) {
682
+ proof.invalidated = true;
683
+ return false;
684
+ }
685
+ if ((proof.targetId ?? null) !== (this.cdpChrome?.targetId ?? null)) {
686
+ proof.invalidated = true;
687
+ return false;
688
+ }
689
+ if (
690
+ proof.currentUrl
691
+ && !sameConversationUrl(this.page?.url?.(), proof.currentUrl)
692
+ ) {
693
+ // Polling may observe a URL before Playwright dispatches its navigation
694
+ // event. It can invalidate a proof, but it must never nominate a route.
695
+ proof.invalidated = true;
696
+ return false;
697
+ }
698
+ return true;
699
+ }
700
+
701
+ #captureActiveTurnProof(conversationUrl) {
702
+ const normalizedUrl = normalizedConversationUrl(conversationUrl);
703
+ this.activeTurnProof = {
704
+ page: this.page,
705
+ mainFrame: typeof this.page?.mainFrame === "function"
706
+ ? this.page.mainFrame()
707
+ : null,
708
+ targetId: this.cdpChrome?.targetId ?? null,
709
+ scopeEpoch: this.conversationScopeEpoch,
710
+ conversationUrl: normalizedUrl,
711
+ invalidated: false,
712
+ };
713
+ this.activeTurnConversationUrl = normalizedUrl;
714
+ }
715
+
716
+ #activeTurnProofIsIntact({ allowPendingCanonicalUrl = false } = {}) {
717
+ const proof = this.activeTurnProof;
718
+ if (!proof) {
719
+ return this.activeTurnConversationUrl == null;
720
+ }
721
+ const currentUrl = normalizedConversationUrl(this.page?.url?.());
722
+ if (
723
+ proof.invalidated
724
+ || proof.page !== this.page
725
+ || proof.scopeEpoch !== this.conversationScopeEpoch
726
+ || (proof.targetId ?? null) !== (this.cdpChrome?.targetId ?? null)
727
+ || (
728
+ proof.mainFrame
729
+ && typeof this.page?.mainFrame === "function"
730
+ && this.page.mainFrame() !== proof.mainFrame
731
+ )
732
+ || (
733
+ !sameConversationUrl(currentUrl, proof.conversationUrl)
734
+ && !this.#isObservedConversationAlias(currentUrl)
735
+ && !(
736
+ allowPendingCanonicalUrl
737
+ && sameConversationUrl(currentUrl, this.pendingCanonicalUrl)
738
+ )
739
+ )
740
+ ) {
741
+ proof.invalidated = true;
742
+ return false;
743
+ }
744
+ return true;
745
+ }
746
+
747
+ async #validateRecoveredTurnBoundary(candidate = {}) {
748
+ if (!this.recoveredTurnTopologyRequired) {
749
+ return;
750
+ }
751
+ const assertProof = () => {
752
+ if (!this.#activeTurnProofIsIntact()) {
753
+ throw new BrowserAdapterError(
754
+ `${this.providerName} recovery boundary left its exact browser target. `
755
+ + "No content from the changed conversation was accepted.",
756
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
757
+ );
758
+ }
759
+ };
760
+ assertProof();
761
+ await this.assertRecoveredTurnTopology(candidate);
762
+ assertProof();
763
+ }
764
+
765
+ #recordActiveSendCandidate(value, {
766
+ previousUrl = null,
767
+ allowCurrentNavigation = false,
768
+ } = {}) {
769
+ const proof = this.activeSendProof;
770
+ if (!proof) {
771
+ return true;
772
+ }
773
+ const candidate = normalizedConversationUrl(value);
774
+ if (
775
+ !candidate
776
+ || proof.page !== this.page
777
+ || proof.sendEpoch !== this.sendEpoch
778
+ || (proof.invalidated && !allowCurrentNavigation)
779
+ || !sameConversationUrl(previousUrl, proof.currentUrl)
780
+ ) {
781
+ return false;
782
+ }
783
+ proof.invalidated = false;
784
+ proof.currentUrl = candidate;
785
+ proof.candidateUrl = candidate;
786
+ proof.candidateNavigationEpoch = this.navigationEpoch;
787
+ proof.candidateBeforeSubmission = Boolean(
788
+ proof.candidateBeforeSubmission || !proof.submissionStarted,
789
+ );
790
+ return true;
791
+ }
792
+
793
+ #resetPendingCanonicalVerification({ clearCandidate = false } = {}) {
794
+ this.pendingCanonicalSignature = null;
795
+ this.pendingCanonicalStableSince = 0;
796
+ if (clearCandidate) {
797
+ this.pendingCanonicalUrl = null;
798
+ }
799
+ }
800
+
801
+ #stageCanonicalCandidate(value) {
802
+ const normalized = normalizedConversationUrl(value);
803
+ if (!normalized) {
804
+ return false;
805
+ }
806
+ if (!sameConversationUrl(this.pendingCanonicalUrl, normalized)) {
807
+ this.#resetPendingCanonicalVerification();
808
+ }
809
+ this.pendingCanonicalUrl = normalized;
810
+ return true;
811
+ }
812
+
813
+ async #recordPendingFreshCandidateEmpty(value) {
814
+ const candidate = normalizedConversationUrl(value);
815
+ if (
816
+ !candidate
817
+ || !sameConversationUrl(this.page?.url?.(), candidate)
818
+ || !this.pendingFreshConversationAliases.has(candidate)
819
+ ) {
820
+ return false;
821
+ }
822
+ if (await this.conversationMessages().count().catch(() => -1) !== 0) {
823
+ this.pendingFreshEmptyAliases.set(candidate, {
824
+ stableSince: 0,
825
+ verified: false,
826
+ poisoned: true,
827
+ });
828
+ return false;
829
+ }
830
+ if (
831
+ !sameConversationUrl(this.page?.url?.(), candidate)
832
+ || !this.pendingFreshConversationAliases.has(candidate)
833
+ ) {
834
+ this.pendingFreshEmptyAliases.delete(candidate);
835
+ return false;
836
+ }
837
+
838
+ const observedAt = this.monotonicNow();
839
+ const previous = this.pendingFreshEmptyAliases.get(candidate);
840
+ const state = previous ?? {
841
+ stableSince: observedAt,
842
+ verified: false,
843
+ poisoned: false,
844
+ };
845
+ if (state.poisoned) {
846
+ return false;
847
+ }
848
+ state.verified = state.verified
849
+ || observedAt - state.stableSince >= this.restorationCorrelationWindowMs();
850
+ this.pendingFreshEmptyAliases.set(candidate, state);
851
+ return state.verified;
852
+ }
853
+
854
+ async #waitForStablePendingFreshCandidate(candidate, {
855
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
856
+ } = {}) {
857
+ while (this.monotonicNow() < deadline) {
858
+ if (await this.#recordPendingFreshCandidateEmpty(candidate)) {
859
+ return true;
860
+ }
861
+ if (
862
+ !sameConversationUrl(this.page?.url?.(), candidate)
863
+ || !this.pendingFreshConversationAliases.has(candidate)
864
+ || this.pendingFreshEmptyAliases.get(candidate)?.poisoned
865
+ ) {
866
+ return false;
867
+ }
868
+ await this.waitForPoll(Math.min(250, deadline - this.monotonicNow()));
869
+ }
870
+ return false;
871
+ }
872
+
873
+ #verifyPendingCanonicalUrl() {
874
+ const operation = this.pendingCanonicalVerificationQueue
875
+ .catch(() => false)
876
+ .then(() => this.#verifyPendingCanonicalUrlNow());
877
+ this.pendingCanonicalVerificationQueue = operation;
878
+ return operation;
879
+ }
880
+
881
+ async #verifyPendingCanonicalUrlNow() {
882
+ const candidate = this.pendingCanonicalUrl;
883
+ const candidateKind = candidate
884
+ ? this.classifyConversationUrl(candidate)
885
+ : "invalid";
886
+ if (
887
+ !candidate
888
+ || !sameConversationUrl(this.page?.url?.(), candidate)
889
+ || !["restorable", "provisional"].includes(candidateKind)
890
+ ) {
891
+ this.#resetPendingCanonicalVerification();
892
+ return false;
893
+ }
894
+ const [assistant, user] = await Promise.all([
895
+ this.#captureMessageIdentities(this.assistantMessages()),
896
+ this.#captureMessageIdentities(this.userMessages()),
897
+ ]);
898
+ const assistantMatched = Boolean(
899
+ this.expectedRestorationAssistantId
900
+ && assistant.ids.has(this.expectedRestorationAssistantId)
901
+ );
902
+ const userMatched = Boolean(
903
+ this.expectedRestorationUserId
904
+ && user.ids.has(this.expectedRestorationUserId)
905
+ );
906
+ if (!assistantMatched && !userMatched) {
907
+ this.#resetPendingCanonicalVerification();
908
+ return false;
909
+ }
910
+
911
+ const matchedMarker = assistantMatched
912
+ ? `assistant:${this.expectedRestorationAssistantId}`
913
+ : `user:${this.expectedRestorationUserId}`;
914
+ const signature = [
915
+ candidate,
916
+ candidateKind,
917
+ this.cdpChrome?.targetId ?? "",
918
+ matchedMarker,
919
+ ].join(":");
920
+ const observedAt = this.monotonicNow();
921
+ if (signature !== this.pendingCanonicalSignature) {
922
+ this.pendingCanonicalSignature = signature;
923
+ this.pendingCanonicalStableSince = observedAt;
924
+ return false;
925
+ }
926
+ if (
927
+ observedAt - this.pendingCanonicalStableSince
928
+ < this.restorationCorrelationWindowMs()
929
+ ) {
930
+ return false;
931
+ }
932
+ if (
933
+ !sameConversationUrl(this.pendingCanonicalUrl, candidate)
934
+ || !sameConversationUrl(this.page?.url?.(), candidate)
935
+ ) {
936
+ this.#resetPendingCanonicalVerification();
937
+ return false;
938
+ }
939
+
940
+ this.#trustConversationUrl(candidate);
941
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
942
+ this.#publishConversationIdentity();
943
+ return true;
944
+ }
945
+
946
+ #publishConversationIdentity() {
947
+ const identity = this.#currentConversationIdentity();
948
+ if (
949
+ !identity.conversationUrl
950
+ || !["restorable", "provisional"].includes(identity.kind)
951
+ ) {
952
+ return;
953
+ }
954
+ Promise.resolve(this.conversationIdentityListener?.(identity)).catch(() => {});
955
+ }
956
+
957
+ #trustConversationUrl(value, { notify = false } = {}) {
958
+ const normalized = normalizedConversationUrl(value);
959
+ if (!normalized) {
960
+ return false;
961
+ }
962
+ const kind = this.classifyConversationUrl(normalized);
963
+ if (!["fresh", "restorable", "provisional"].includes(kind)) {
964
+ return false;
965
+ }
966
+ this.observedConversationUrl = normalized;
967
+ this.lastObservedPageUrl = normalized;
968
+ if (["restorable", "provisional"].includes(kind)) {
969
+ this.observedConversationAliases.add(normalized);
970
+ }
971
+ if (notify) {
972
+ this.#publishConversationIdentity();
973
+ }
974
+ return true;
975
+ }
976
+
977
+ #observeConversationNavigation(value, { navigationEvent = false } = {}) {
978
+ const normalized = normalizedConversationUrl(value);
979
+ if (!normalized) {
980
+ if (this.activeSendProof) {
981
+ this.activeSendProof.invalidated = true;
982
+ }
983
+ return false;
984
+ }
985
+ const kind = this.classifyConversationUrl(normalized);
986
+ const previous = this.lastObservedPageUrl;
987
+ const previousKind = previous
988
+ ? this.classifyConversationUrl(previous)
989
+ : "invalid";
990
+ const proofWasInvalidated = Boolean(this.activeSendProof?.invalidated);
991
+ if (
992
+ this.activeSendProof
993
+ && !navigationEvent
994
+ && !sameConversationUrl(normalized, this.activeSendProof.currentUrl)
995
+ ) {
996
+ // A polled URL change is not causal evidence. The matching top-frame event
997
+ // must be observed first for this send epoch.
998
+ this.activeSendProof.invalidated = true;
999
+ }
1000
+ if (navigationEvent && this.activeSendProof) {
1001
+ // Every top-frame navigation invalidates the current proof until this exact
1002
+ // event is classified as an allowed causal transition below. A prior bad
1003
+ // transition is permanent; a later plausible route cannot rehabilitate it.
1004
+ this.activeSendProof.invalidated = true;
1005
+ }
1006
+ this.lastObservedPageUrl = normalized;
1007
+
1008
+ if (this.#isObservedConversationAlias(normalized)) {
1009
+ this.observedConversationUrl = normalized;
1010
+ this.#publishConversationIdentity();
1011
+ return true;
1012
+ }
1013
+ const validNewConversation = navigationEvent
1014
+ && this.conversationObservationArmed
1015
+ && previousKind === "fresh"
1016
+ && ["provisional", "restorable"].includes(kind);
1017
+ const pendingCanonicalTransition = navigationEvent
1018
+ && previousKind === "provisional"
1019
+ && this.pendingFreshConversationAliases.has(previous)
1020
+ && kind === "restorable";
1021
+ const validCanonicalTransition = navigationEvent
1022
+ && previousKind === "provisional"
1023
+ && this.#isObservedConversationAlias(previous)
1024
+ && kind === "restorable";
1025
+ const activeTransitionCandidate = validNewConversation
1026
+ || pendingCanonicalTransition
1027
+ || validCanonicalTransition;
1028
+ const acceptedForActiveSend = !this.activeSendProof
1029
+ || (
1030
+ activeTransitionCandidate
1031
+ && this.#recordActiveSendCandidate(normalized, {
1032
+ previousUrl: previous,
1033
+ allowCurrentNavigation: navigationEvent && !proofWasInvalidated,
1034
+ })
1035
+ );
1036
+
1037
+ if (
1038
+ (validNewConversation || pendingCanonicalTransition)
1039
+ && acceptedForActiveSend
1040
+ ) {
1041
+ this.pendingFreshConversationAliases.add(normalized);
1042
+ if (
1043
+ validNewConversation
1044
+ && this.activeSendProof?.candidateBeforeSubmission
1045
+ ) {
1046
+ void this.#recordPendingFreshCandidateEmpty(normalized).catch(() => null);
1047
+ }
1048
+ if (!this.freshSendConfirmed) {
1049
+ // Do not trust or publish a fresh-page transition before a new user bubble
1050
+ // proves this send. File upload can legitimately allocate /c/WEB: early,
1051
+ // while a user switching chats must remain untrusted.
1052
+ return false;
1053
+ }
1054
+ this.#stageCanonicalCandidate(normalized);
1055
+ return false;
1056
+ }
1057
+ if (validCanonicalTransition && acceptedForActiveSend) {
1058
+ // A top-frame event proves page continuity, not conversation identity: the
1059
+ // user could have navigated this tab. Promote only after a saved message id
1060
+ // is found on the candidate page.
1061
+ this.#stageCanonicalCandidate(normalized);
1062
+ return false;
1063
+ }
1064
+ return false;
1065
+ }
1066
+
1067
+ #beginConversationObservation(seedUrl, { arm = false, reset = false } = {}) {
1068
+ if (reset) {
1069
+ this.conversationScopeEpoch += 1;
1070
+ this.activeTurnProof = null;
1071
+ this.observedConversationAliases.clear();
1072
+ this.pendingFreshConversationAliases.clear();
1073
+ this.pendingFreshEmptyAliases.clear();
1074
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1075
+ this.observedConversationUrl = null;
1076
+ this.freshSendConfirmed = false;
1077
+ }
1078
+ if (seedUrl) {
1079
+ this.#trustConversationUrl(seedUrl);
1080
+ }
1081
+ if (arm) {
1082
+ this.pendingFreshConversationAliases.clear();
1083
+ this.pendingFreshEmptyAliases.clear();
1084
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1085
+ this.freshSendConfirmed = false;
1086
+ }
1087
+ this.conversationObservationArmed = arm;
1088
+ if (this.pageNavigationListener || typeof this.page?.on !== "function") {
1089
+ return;
1090
+ }
1091
+ this.pageNavigationListener = (frame) => {
1092
+ try {
1093
+ if (
1094
+ typeof this.page.mainFrame === "function"
1095
+ && frame !== this.page.mainFrame()
1096
+ ) {
1097
+ return;
1098
+ }
1099
+ this.navigationEpoch += 1;
1100
+ this.#observeConversationNavigation(
1101
+ frame?.url?.() ?? this.page.url(),
1102
+ { navigationEvent: true },
1103
+ );
1104
+ void this.#verifyPendingCanonicalUrl().catch(() => null);
1105
+ } catch {
1106
+ // The transport may disappear while Playwright dispatches navigation.
1107
+ }
1108
+ };
1109
+ this.page.on("framenavigated", this.pageNavigationListener);
1110
+ }
1111
+
1112
+ async #assertTrustedPageForSend(expectedUrl, {
1113
+ allowPendingFreshTransition = false,
1114
+ } = {}) {
1115
+ const currentUrl = normalizedConversationUrl(this.page.url());
1116
+ const turnBindingTrusted = !this.activeTurnConversationUrl
1117
+ || this.#activeTurnProofIsIntact();
1118
+ const directlyTrusted = turnBindingTrusted
1119
+ && this.#activeSendProofIsIntact()
1120
+ && (
1121
+ sameConversationUrl(currentUrl, expectedUrl)
1122
+ || this.#isObservedConversationAlias(currentUrl)
1123
+ );
1124
+ if (directlyTrusted) {
1125
+ return currentUrl;
1126
+ }
1127
+ if (
1128
+ allowPendingFreshTransition
1129
+ && turnBindingTrusted
1130
+ && this.#activeSendProofIsIntact()
1131
+ && this.pendingFreshConversationAliases.has(currentUrl)
1132
+ && await this.#waitForStablePendingFreshCandidate(currentUrl)
1133
+ && this.#activeSendProofIsIntact()
1134
+ ) {
1135
+ return currentUrl;
1136
+ }
1137
+ await this.writeDiagnostics("conversation-changed-before-send");
1138
+ throw new BrowserAdapterError(
1139
+ `${this.providerName} left the verified conversation before the message was submitted. `
1140
+ + "Nothing was sent.",
1141
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
1142
+ );
1143
+ }
1144
+
1145
+ #isTrustedPageUrl(value) {
1146
+ const normalized = normalizedConversationUrl(value);
1147
+ const kind = normalized
1148
+ ? this.classifyConversationUrl(normalized)
1149
+ : "invalid";
1150
+ return Boolean(
1151
+ normalized
1152
+ && (
1153
+ this.#isObservedConversationAlias(normalized)
1154
+ || (
1155
+ kind === "fresh"
1156
+ && sameConversationUrl(normalized, this.observedConversationUrl)
1157
+ )
1158
+ )
1159
+ );
1160
+ }
1161
+
1162
+ async #submittedPageState({ allowPendingFreshTransition = false } = {}) {
1163
+ const currentUrl = normalizedConversationUrl(this.page.url());
1164
+ if (!this.#activeSendProofIsIntact()) {
1165
+ return { status: "untrusted", conversationUrl: currentUrl };
1166
+ }
1167
+ if (sameConversationUrl(currentUrl, this.pendingCanonicalUrl)) {
1168
+ await this.#verifyPendingCanonicalUrl();
1169
+ }
1170
+ if (this.#isTrustedPageUrl(currentUrl)) {
1171
+ return { status: "trusted", conversationUrl: currentUrl };
1172
+ }
1173
+ if (
1174
+ sameConversationUrl(currentUrl, this.pendingCanonicalUrl)
1175
+ || (
1176
+ allowPendingFreshTransition
1177
+ && this.pendingFreshConversationAliases.has(currentUrl)
1178
+ )
1179
+ ) {
1180
+ return { status: "candidate", conversationUrl: currentUrl };
1181
+ }
1182
+ return { status: "untrusted", conversationUrl: currentUrl };
1183
+ }
1184
+
1185
+ async #waitForCanonicalCandidate(candidate, {
1186
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
1187
+ } = {}) {
1188
+ while (this.monotonicNow() < deadline) {
1189
+ const state = await this.#awaitWithinSendDeadline(
1190
+ this.#submittedPageState(),
1191
+ deadline,
1192
+ );
1193
+ if (state.status === "trusted") {
1194
+ return state.conversationUrl;
1195
+ }
1196
+ if (
1197
+ state.status !== "candidate"
1198
+ || !sameConversationUrl(state.conversationUrl, candidate)
1199
+ ) {
1200
+ return null;
1201
+ }
1202
+ const remaining = deadline - this.monotonicNow();
1203
+ if (remaining <= 0) {
1204
+ break;
1205
+ }
1206
+ await this.#awaitWithinSendDeadline(
1207
+ this.waitForPoll(Math.min(250, remaining)),
1208
+ deadline,
1209
+ );
1210
+ }
1211
+ return null;
1212
+ }
1213
+
1214
+ #confirmFreshConversationSend() {
1215
+ this.freshSendConfirmed = true;
1216
+ const currentUrl = normalizedConversationUrl(this.page.url());
1217
+ const currentKind = currentUrl
1218
+ ? this.classifyConversationUrl(currentUrl)
1219
+ : "invalid";
1220
+ if (
1221
+ !["restorable", "provisional"].includes(currentKind)
1222
+ || !this.pendingFreshConversationAliases.has(currentUrl)
1223
+ ) {
1224
+ // Some providers keep the explicit fresh route until later in generation.
1225
+ // Leave observation armed; the confirmed user bubble now makes a later
1226
+ // fresh -> conversation transition eligible for marker verification.
1227
+ return;
1228
+ }
1229
+ // Only the page that contains this send's confirmed user bubble is trusted.
1230
+ // Earlier candidates may have been opened manually and must not become aliases.
1231
+ this.pendingFreshConversationAliases.clear();
1232
+ this.pendingFreshEmptyAliases.clear();
1233
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1234
+ this.#trustConversationUrl(currentUrl, { notify: true });
1235
+ if (currentKind === "restorable") {
1236
+ this.conversationObservationArmed = false;
1237
+ }
1238
+ }
1239
+
1240
+ #dropPageState({ clearObservation = true } = {}) {
1241
+ if (this.activeSendProof) {
1242
+ this.activeSendProof.invalidated = true;
1243
+ this.activeSendProof = null;
1244
+ }
1245
+ if (this.activeTurnProof) {
1246
+ this.activeTurnProof.invalidated = true;
1247
+ this.activeTurnProof = null;
1248
+ }
1249
+ if (this.pageNavigationListener && typeof this.page?.off === "function") {
1250
+ this.page.off("framenavigated", this.pageNavigationListener);
1251
+ }
1252
+ this.cdpChrome = null;
1253
+ this.context = null;
1254
+ this.page = null;
1255
+ this.preservedPreferredUrl = null;
1256
+ this.pageNavigationListener = null;
1257
+ this.preferredTabAmbiguous = false;
1258
+ this.conversationObservationArmed = false;
1259
+ this.freshSendConfirmed = false;
1260
+ this.pendingFreshConversationAliases.clear();
1261
+ this.pendingFreshEmptyAliases.clear();
1262
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1263
+ this.expectedRestorationAssistantId = null;
1264
+ this.expectedRestorationUserId = null;
1265
+ this.expectedAssistantCandidateId = null;
1266
+ this.expectedAssistantCandidateTurn = null;
1267
+ this.recoveredTurnTopologyRequired = false;
1268
+ this.lastObservedPageUrl = null;
1269
+ if (clearObservation) {
1270
+ this.activeTurnConversationUrl = null;
1271
+ this.allowInPlaceAssistantContinuation = false;
1272
+ this.inPlaceAssistantGenerationObserved = false;
1273
+ this.observedConversationUrl = null;
1274
+ this.observedConversationAliases.clear();
1275
+ }
1276
+ }
1277
+
340
1278
  // ---- lifecycle ---------------------------------------------------------
341
1279
 
1280
+ // Kept overridable so lifecycle tests can supply a deterministic CDP
1281
+ // connection without launching a real browser.
1282
+ async launchCdpChrome(options) {
1283
+ return await launchAndConnectCdpChrome(options);
1284
+ }
1285
+
1286
+ hasExactRecoveryTarget(targetId) {
1287
+ return Boolean(
1288
+ targetId
1289
+ && this.cdpChrome?.exactTargetOnly === true
1290
+ && this.cdpChrome.targetId === targetId
1291
+ && this.context
1292
+ && this.page,
1293
+ );
1294
+ }
1295
+
1296
+ // Attach only the exact already-open Page used by an ambiguous outbound. This
1297
+ // recovery path never creates or navigates a target and never launches Chrome.
1298
+ async launchRecoveryTarget(targetId) {
1299
+ if (typeof targetId !== "string" || targetId.length === 0) {
1300
+ throw new BrowserAdapterError(
1301
+ "Pending outbound recovery requires a saved browser target ID.",
1302
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1303
+ );
1304
+ }
1305
+ if (this.context) {
1306
+ if (this.hasExactRecoveryTarget(targetId)) {
1307
+ return {
1308
+ conversationUrl: this.page.url(),
1309
+ targetId,
1310
+ };
1311
+ }
1312
+ if (this.cdpChrome?.targetId !== targetId || !this.page) {
1313
+ throw new BrowserAdapterError(
1314
+ "A different browser target is already attached; refusing recovery fallback.",
1315
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1316
+ );
1317
+ }
1318
+ if (typeof this.cdpChrome.detach !== "function") {
1319
+ throw new BrowserAdapterError(
1320
+ "The current browser transport cannot be safely promoted to exact-target recovery.",
1321
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1322
+ );
1323
+ }
1324
+ // A same-process /retry can still own a normal CDP connection. Release its
1325
+ // transport and profile lock, then reacquire the saved target through the
1326
+ // strict path so close() cannot terminate Chrome or remove recovery state.
1327
+ try {
1328
+ await this.cdpChrome.detach();
1329
+ } catch (error) {
1330
+ throw new BrowserAdapterError(
1331
+ "The current browser transport could not be detached for exact-target recovery.",
1332
+ {
1333
+ code: "RECOVERY_TARGET_UNAVAILABLE",
1334
+ recoverable: false,
1335
+ cause: error,
1336
+ },
1337
+ );
1338
+ }
1339
+ this.#dropPageState();
1340
+ }
1341
+
1342
+ await ensureDirectory(this.profileDir);
1343
+ let connection = null;
1344
+ try {
1345
+ connection = await this.launchCdpChrome({
1346
+ profileDir: this.profileDir,
1347
+ preferredTargetId: targetId,
1348
+ exactTargetOnly: true,
1349
+ });
1350
+ if (
1351
+ connection?.exactTargetOnly !== true
1352
+ || !connection.preferredTargetMatched
1353
+ || connection.targetId !== targetId
1354
+ || !connection.page
1355
+ || !connection.context
1356
+ ) {
1357
+ throw new BrowserAdapterError(
1358
+ "Chrome did not expose the exact saved target for pending outbound recovery.",
1359
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1360
+ );
1361
+ }
1362
+ this.cdpChrome = connection;
1363
+ this.context = connection.context;
1364
+ this.page = connection.page;
1365
+ this.page.setDefaultTimeout(15_000);
1366
+ this.page.setDefaultNavigationTimeout(60_000);
1367
+ this.preservedPreferredUrl = null;
1368
+ this.preferredTabAmbiguous = false;
1369
+ return {
1370
+ conversationUrl: this.page.url(),
1371
+ targetId,
1372
+ };
1373
+ } catch (error) {
1374
+ await connection?.detach?.().catch(() => null);
1375
+ this.#dropPageState();
1376
+ if (error instanceof BrowserAdapterError) {
1377
+ throw error;
1378
+ }
1379
+ throw new BrowserAdapterError(
1380
+ "The exact saved browser target is unavailable for recovery.",
1381
+ {
1382
+ code: error?.code === "RECOVERY_TARGET_UNAVAILABLE"
1383
+ ? error.code
1384
+ : "RECOVERY_TARGET_UNAVAILABLE",
1385
+ recoverable: false,
1386
+ cause: error,
1387
+ },
1388
+ );
1389
+ }
1390
+ }
1391
+
342
1392
  // `preferredUrl` lets a reused Chrome pick an existing tab that already
343
1393
  // shows the conversation (instead of opening a new tab per run).
344
- async launch(preferredUrl = null) {
1394
+ async launch(preferredUrl = null, { preferredTargetId = null } = {}) {
345
1395
  if (this.context) {
1396
+ // A later turn can still hold the same Page after its provisional URL was
1397
+ // replaced. Arm that page's known alias instead of navigating it to root.
1398
+ if (preferredUrl && this.#isObservedConversationAlias(preferredUrl)) {
1399
+ this.preservedPreferredUrl = preferredUrl;
1400
+ }
346
1401
  return;
347
1402
  }
348
1403
  await ensureDirectory(this.profileDir);
349
1404
  const executablePath = discoverChromeExecutable(this.chromePath);
350
1405
 
351
- this.cdpChrome = await launchAndConnectCdpChrome({
1406
+ const preferredKind = preferredUrl
1407
+ ? this.classifyConversationUrl(preferredUrl)
1408
+ : "fresh";
1409
+ const reusablePreferredUrl = ["restorable", "provisional"]
1410
+ .includes(preferredKind)
1411
+ ? String(preferredUrl)
1412
+ : null;
1413
+
1414
+ this.cdpChrome = await this.launchCdpChrome({
352
1415
  executablePath,
353
1416
  profileDir: this.profileDir,
354
1417
  minimized: this.minimized,
355
- preferredUrl,
1418
+ preferredUrl: reusablePreferredUrl,
1419
+ preferredTargetId,
356
1420
  });
357
1421
  this.context = this.cdpChrome.context;
358
1422
  this.page = this.cdpChrome.page;
359
1423
  this.page.setDefaultTimeout(15_000);
360
1424
  this.page.setDefaultNavigationTimeout(60_000);
361
- await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
1425
+
1426
+ const preservedPreferredTab = Boolean(
1427
+ this.cdpChrome.preferredTargetMatched
1428
+ || (
1429
+ reusablePreferredUrl
1430
+ && this.cdpChrome.preferredTabMatched
1431
+ ),
1432
+ );
1433
+ this.preferredTabAmbiguous = Boolean(
1434
+ this.cdpChrome.preferredTabAmbiguous,
1435
+ );
1436
+ if (
1437
+ preferredKind === "provisional"
1438
+ && !preservedPreferredTab
1439
+ && (this.cdpChrome.existingPageUrls ?? []).some((value) =>
1440
+ ["restorable", "provisional"].includes(
1441
+ this.classifyConversationUrl(value),
1442
+ )
1443
+ )
1444
+ ) {
1445
+ // A legacy session has no target id, and its provisional path may already
1446
+ // have canonicalized in one of these tabs. Do not guess which conversation
1447
+ // belongs to it, and do not rebuild while a possible original is still open.
1448
+ this.preferredTabAmbiguous = true;
1449
+ }
1450
+ this.preservedPreferredUrl = preservedPreferredTab
1451
+ ? String(preferredUrl ?? this.baseUrl)
1452
+ : null;
1453
+ if (this.preferredTabAmbiguous) {
1454
+ throw new BrowserAdapterError(
1455
+ `${this.providerName} has multiple possible tabs for this provisional conversation. `
1456
+ + "Refusing to choose one or rebuild on a blank page.",
1457
+ { code: "CONVERSATION_TAB_AMBIGUOUS", recoverable: false },
1458
+ );
1459
+ }
1460
+ if (preservedPreferredTab) {
1461
+ this.#beginConversationObservation(this.preservedPreferredUrl);
1462
+ } else {
1463
+ await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
1464
+ }
362
1465
  }
363
1466
 
1467
+
364
1468
  async close() {
365
1469
  await this.cdpChrome?.close();
366
- this.cdpChrome = null;
367
- this.context = null;
368
- this.page = null;
1470
+ this.#dropPageState();
369
1471
  }
370
1472
 
371
1473
  // Leave Chrome open for inspection, but drop the CDP transport and the
@@ -374,21 +1476,19 @@ export class BaseWebAdapter {
374
1476
  // lock held by this process.
375
1477
  async detach() {
376
1478
  await this.cdpChrome?.detach?.().catch(() => null);
377
- this.cdpChrome = null;
378
- this.context = null;
379
- this.page = null;
1479
+ this.#dropPageState();
380
1480
  }
381
1481
 
382
1482
  // Re-establishes the CDP connection to a still-alive Chrome after the
383
1483
  // Playwright transport died mid-run (e.g. the Mac slept). launch() reuses
384
1484
  // the saved CDP state, so Chrome is neither relaunched nor killed; an
385
1485
  // existing tab on the preferred conversation is reused when available.
386
- async reconnect(preferredUrl = null) {
387
- await this.cdpChrome?.disconnect?.().catch(() => null);
388
- this.cdpChrome = null;
389
- this.context = null;
390
- this.page = null;
391
- await this.launch(preferredUrl);
1486
+ async reconnect(preferredUrl = null, { preferredTargetId = null } = {}) {
1487
+ // launch() acquires a new profile lock. Release this connection's ownership
1488
+ // first; if another CLI wins the lock, launch must respect that owner.
1489
+ await this.cdpChrome?.detach?.();
1490
+ this.#dropPageState({ clearObservation: false });
1491
+ await this.launch(preferredUrl, { preferredTargetId });
392
1492
  }
393
1493
 
394
1494
  // Bring the window forward (used before asking the user to log in or solve a
@@ -459,91 +1559,172 @@ export class BaseWebAdapter {
459
1559
 
460
1560
  async startConversation(
461
1561
  conversationUrl = null,
462
- { expectedAssistantMessageId = null } = {},
1562
+ {
1563
+ expectedAssistantMessageId = null,
1564
+ expectedUserMessageId = null,
1565
+ } = {},
463
1566
  ) {
464
1567
  this.requirePage();
465
- let target = this.baseUrl;
466
- let resumesExistingConversation = Boolean(conversationUrl);
467
- if (conversationUrl) {
468
- const parsed = new URL(conversationUrl);
469
- const base = new URL(this.baseUrl);
470
- if (parsed.protocol !== "https:" || parsed.hostname !== base.hostname) {
471
- throw new BrowserAdapterError(
472
- `Refusing to open a conversation outside ${base.hostname}.`,
473
- { code: "INVALID_CONVERSATION_URL" },
474
- );
475
- }
476
- target = parsed.href;
477
- const conversationPattern = this.conversationUrlPattern();
478
- // A run can fail before the first message is committed. In that case its
479
- // saved URL is the provider's home/new-chat page, not a conversation to
480
- // hydrate. Resume it as a verified fresh chat instead of waiting forever
481
- // for history that cannot exist.
482
- if (conversationPattern && !conversationPattern.test(parsed.pathname)) {
483
- resumesExistingConversation = false;
484
- }
485
- }
486
-
487
- const reuseCurrent = Boolean(
488
- conversationUrl
489
- && sameConversationUrl(this.page.url(), target),
490
- );
491
- if (!reuseCurrent) {
492
- await this.page.goto(target, { waitUntil: "domcontentloaded" });
1568
+ this.expectedRestorationAssistantId = expectedAssistantMessageId;
1569
+ this.expectedRestorationUserId = expectedUserMessageId;
1570
+ const base = new URL(this.baseUrl);
1571
+ const requestedUrl = conversationUrl
1572
+ ? normalizedConversationUrl(conversationUrl)
1573
+ : null;
1574
+ const requestedKind = requestedUrl
1575
+ ? this.classifyConversationUrl(requestedUrl)
1576
+ : "fresh";
1577
+ if (["invalid", "unknown"].includes(requestedKind)) {
1578
+ throw new BrowserAdapterError(
1579
+ requestedKind === "invalid"
1580
+ ? `Refusing to open a conversation outside ${base.origin}.`
1581
+ : `Refusing to treat an unknown ${base.hostname} route as a conversation.`,
1582
+ {
1583
+ code: requestedKind === "invalid"
1584
+ ? "INVALID_CONVERSATION_URL"
1585
+ : "UNKNOWN_CONVERSATION_URL",
1586
+ },
1587
+ );
493
1588
  }
494
- const composer = await this.#waitForComposer(30_000);
495
- if (!composer) {
1589
+ if (this.preferredTabAmbiguous) {
496
1590
  throw new BrowserAdapterError(
497
- `${this.providerName} composer was not found after opening a new conversation.`,
498
- { code: "COMPOSER_NOT_FOUND" },
1591
+ `${this.providerName} has multiple possible tabs for this provisional conversation. `
1592
+ + "Refusing to choose one or rebuild on a blank page.",
1593
+ { code: "CONVERSATION_TAB_AMBIGUOUS", recoverable: false },
499
1594
  );
500
1595
  }
501
1596
 
502
- if (resumesExistingConversation) {
503
- await this.#waitForConversationHistory({
504
- expectedAssistantMessageId,
505
- expectedUrl: target,
506
- });
507
- return;
508
- }
1597
+ const currentUrl = normalizedConversationUrl(this.page.url());
1598
+ const provisionalTabMatched = Boolean(
1599
+ requestedUrl
1600
+ && requestedKind === "provisional"
1601
+ && (
1602
+ sameConversationUrl(currentUrl, requestedUrl)
1603
+ || sameConversationUrl(this.preservedPreferredUrl, requestedUrl)
1604
+ || this.#isObservedConversationAlias(requestedUrl)
1605
+ )
1606
+ );
1607
+ const currentKind = currentUrl
1608
+ ? this.classifyConversationUrl(currentUrl)
1609
+ : "invalid";
1610
+ const freshTargetConversationCandidate = Boolean(
1611
+ requestedKind === "fresh"
1612
+ && this.cdpChrome?.preferredTargetMatched
1613
+ && currentKind === "restorable"
1614
+ );
1615
+ const resumesExistingConversation = requestedKind === "restorable"
1616
+ || provisionalTabMatched
1617
+ || freshTargetConversationCandidate;
1618
+
1619
+ // A provisional URL can locate a live tab, but must never be navigated to.
1620
+ // A matched page may canonicalize while we observe it; Page continuity alone
1621
+ // is insufficient, so a saved user/assistant marker must remain on the new
1622
+ // route throughout the restoration stability window.
1623
+ const target = requestedKind === "provisional"
1624
+ ? (provisionalTabMatched ? currentUrl : normalizedConversationUrl(this.baseUrl))
1625
+ : freshTargetConversationCandidate
1626
+ ? currentUrl
1627
+ : (requestedUrl ?? normalizedConversationUrl(this.baseUrl));
1628
+
1629
+ try {
1630
+ if (!sameConversationUrl(this.page.url(), target)) {
1631
+ await this.page.goto(String(target), { waitUntil: "domcontentloaded" });
1632
+ }
1633
+ const composer = await this.#waitForComposer(30_000);
1634
+ if (!composer) {
1635
+ throw new BrowserAdapterError(
1636
+ `${this.providerName} composer was not found after opening a conversation.`,
1637
+ { code: "COMPOSER_NOT_FOUND" },
1638
+ );
1639
+ }
1640
+
1641
+ if (resumesExistingConversation) {
1642
+ return await this.#waitForConversationHistory({
1643
+ expectedAssistantMessageId,
1644
+ expectedUserMessageId,
1645
+ expectedUrl: requestedUrl,
1646
+ allowCanonicalTransition: requestedKind === "provisional"
1647
+ || freshTargetConversationCandidate,
1648
+ });
1649
+ }
509
1650
 
510
- if (!resumesExistingConversation && !await this.#isFreshConversation()) {
511
- await this.#openNewConversation();
512
- const freshComposer = await this.#waitForComposer(30_000);
513
- if (!freshComposer || !await this.#isFreshConversation()) {
1651
+ if (!await this.#isFreshConversation()) {
1652
+ await this.#openNewConversation();
1653
+ const freshComposer = await this.#waitForComposer(30_000);
1654
+ if (!freshComposer) {
1655
+ await this.writeDiagnostics("conversation-not-fresh");
1656
+ throw new BrowserAdapterError(
1657
+ `${this.providerName} did not expose a composer on its new-chat page.`,
1658
+ { code: "CONVERSATION_NOT_FRESH" },
1659
+ );
1660
+ }
1661
+ }
1662
+ const freshUrl = await this.#waitForStableFreshConversation();
1663
+ if (!freshUrl) {
514
1664
  await this.writeDiagnostics("conversation-not-fresh");
515
1665
  throw new BrowserAdapterError(
516
- `${this.providerName} did not open a verified empty conversation. `
1666
+ `${this.providerName} did not open a stable, verified empty conversation. `
517
1667
  + "Refusing to send a new session prompt into an existing chat.",
518
1668
  { code: "CONVERSATION_NOT_FRESH" },
519
1669
  );
520
1670
  }
1671
+ this.expectedRestorationAssistantId = null;
1672
+ this.expectedRestorationUserId = null;
1673
+ this.lastAssistantMessageId = null;
1674
+ this.lastAssistantTurn = null;
1675
+ this.lastUserMessageId = null;
1676
+ this.activeTurnConversationUrl = null;
1677
+ this.#beginConversationObservation(freshUrl, { arm: false, reset: true });
1678
+ return {
1679
+ status: "verified-fresh",
1680
+ conversationUrl: freshUrl,
1681
+ ...(this.cdpChrome?.targetId
1682
+ ? { targetId: this.cdpChrome.targetId }
1683
+ : {}),
1684
+ };
1685
+ } finally {
1686
+ // A CDP selection proves only this single restoration attempt. Long-lived
1687
+ // page aliases continue to be tracked separately by navigation events.
1688
+ this.preservedPreferredUrl = null;
521
1689
  }
522
1690
  }
523
1691
 
524
- async selectMode(mode) {
525
- this.requirePage();
526
- if (!mode) {
527
- return { status: "skipped", requested: mode, attempts: 0 };
528
- }
529
-
530
- const port = this.modeSelectionPort();
531
- const result = await runModeSelection(port, mode);
532
- this.lastModeSelection = result;
533
- return result;
534
- }
535
-
536
1692
  async getConversationUrl() {
537
1693
  this.requirePage();
538
1694
  return this.page.url();
539
1695
  }
540
1696
 
1697
+ async getLastUserMessageId() {
1698
+ return this.lastUserMessageId;
1699
+ }
1700
+
541
1701
  async getLastAssistantMessageId() {
542
1702
  return this.lastAssistantMessageId;
543
1703
  }
544
1704
 
545
- async sendMessage(text, { files = [], maxBytes = null } = {}) {
1705
+ async getLastAssistantTurn() {
1706
+ return this.lastAssistantTurn;
1707
+ }
1708
+
1709
+ getLastSendStatus() {
1710
+ return this.lastSendStatus;
1711
+ }
1712
+
1713
+ async sendMessage(text, {
1714
+ files = [],
1715
+ maxBytes = null,
1716
+ outboundId = null,
1717
+ allowAssistantContinuation = false,
1718
+ } = {}) {
546
1719
  this.requirePage();
1720
+ this.lastSendStatus = "not-submitted";
1721
+ this.expectedAssistantCandidateId = null;
1722
+ this.expectedAssistantCandidateTurn = null;
1723
+ this.recoveredTurnTopologyRequired = false;
1724
+ this.allowInPlaceAssistantContinuation = Boolean(
1725
+ allowAssistantContinuation,
1726
+ );
1727
+ this.inPlaceAssistantGenerationObserved = false;
547
1728
  const messageBytes = Buffer.byteLength(String(text ?? ""), "utf8");
548
1729
  if (maxBytes != null && messageBytes > maxBytes) {
549
1730
  throw new BrowserAdapterError(
@@ -554,7 +1735,25 @@ export class BaseWebAdapter {
554
1735
  },
555
1736
  );
556
1737
  }
557
- const urlBeforeSend = this.page.url();
1738
+ if (
1739
+ outboundId != null
1740
+ && (
1741
+ !isValidOutboundCorrelationId(outboundId)
1742
+ || !renderedMessageContainsOutboundMarker(text, outboundId)
1743
+ )
1744
+ ) {
1745
+ throw new BrowserAdapterError(
1746
+ "The outbound transport correlation marker is malformed. Nothing was sent.",
1747
+ { code: "INVALID_OUTBOUND_CORRELATION_ID", recoverable: false },
1748
+ );
1749
+ }
1750
+
1751
+ let sendProof = null;
1752
+ try {
1753
+ const sendIdentity = await this.getConversationIdentity();
1754
+ const urlBeforeSend = sendIdentity.conversationUrl;
1755
+ const sendStartedFresh = sendIdentity.kind === "fresh";
1756
+ await this.#assertTrustedPageForSend(urlBeforeSend);
558
1757
  const composer = await this.#waitForComposer(30_000);
559
1758
  if (!composer) {
560
1759
  throw new BrowserAdapterError(
@@ -566,6 +1765,43 @@ export class BaseWebAdapter {
566
1765
  await this.dismissTransientOverlays();
567
1766
  await this.#waitUntilReadyToSend();
568
1767
 
1768
+ if (await firstVisible(this.pendingAttachmentLocators())) {
1769
+ throw new BrowserAdapterError(
1770
+ `${this.providerName} composer contains attachments from an earlier draft. `
1771
+ + "Remove them in the browser before resuming.",
1772
+ { code: "STALE_COMPOSER_ATTACHMENTS", recoverable: false },
1773
+ );
1774
+ }
1775
+
1776
+ await this.#assertTrustedPageForSend(urlBeforeSend);
1777
+ this.sendEpoch += 1;
1778
+ sendProof = {
1779
+ sendEpoch: this.sendEpoch,
1780
+ outboundId,
1781
+ page: this.page,
1782
+ mainFrame: typeof this.page?.mainFrame === "function"
1783
+ ? this.page.mainFrame()
1784
+ : null,
1785
+ targetId: this.cdpChrome?.targetId ?? null,
1786
+ sourceUrl: normalizedConversationUrl(urlBeforeSend),
1787
+ currentUrl: normalizedConversationUrl(urlBeforeSend),
1788
+ startedNavigationEpoch: this.navigationEpoch,
1789
+ candidateUrl: null,
1790
+ candidateNavigationEpoch: null,
1791
+ candidateBeforeSubmission: false,
1792
+ submissionStarted: false,
1793
+ submittedAt: null,
1794
+ deadline: null,
1795
+ orderedBaseline: null,
1796
+ freshCandidateUserId: null,
1797
+ freshCandidateUserTurn: null,
1798
+ invalidated: false,
1799
+ };
1800
+ this.activeSendProof = sendProof;
1801
+ this.#beginConversationObservation(urlBeforeSend, {
1802
+ arm: sendStartedFresh,
1803
+ });
1804
+
569
1805
  // Attach any @file uploads before typing/sending. Upload is best-effort: a
570
1806
  // failure is reported to the caller but does not block sending the text.
571
1807
  let attachment = null;
@@ -582,6 +1818,12 @@ export class BaseWebAdapter {
582
1818
  );
583
1819
  this.assistantIdsBeforeSend = assistantBaseline.ids;
584
1820
  this.assistantMaxTurnBeforeSend = assistantBaseline.maxTurn;
1821
+ this.expectedRestorationAssistantId ??= [...assistantBaseline.ids].at(-1) ?? null;
1822
+ this.expectedRestorationUserId ??= [...userBaseline.ids].at(-1) ?? null;
1823
+ const hasPreTransitionMarker = Boolean(
1824
+ this.expectedRestorationAssistantId
1825
+ || this.expectedRestorationUserId,
1826
+ );
585
1827
  // Count baseline for providers whose DOM exposes no stable per-message id
586
1828
  // or turn ordinal (they override isNewAssistantIdentity to use it).
587
1829
  this.assistantCountBeforeSend = assistantBaseline.count;
@@ -590,41 +1832,142 @@ export class BaseWebAdapter {
590
1832
  : "";
591
1833
  this.sentUserTurn = null;
592
1834
 
1835
+ await this.#assertTrustedPageForSend(urlBeforeSend, {
1836
+ allowPendingFreshTransition: sendStartedFresh,
1837
+ });
1838
+ if (
1839
+ sendStartedFresh
1840
+ && (assistantBaseline.count !== 0 || userBaseline.count !== 0)
1841
+ ) {
1842
+ await this.writeDiagnostics("conversation-changed-before-send");
1843
+ throw new BrowserAdapterError(
1844
+ `${this.providerName} exposed conversation history before submission. Nothing was sent.`,
1845
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
1846
+ );
1847
+ }
593
1848
  await this.fillComposer(composer, text);
1849
+ await this.#assertTrustedPageForSend(urlBeforeSend, {
1850
+ allowPendingFreshTransition: sendStartedFresh,
1851
+ });
594
1852
 
595
- await this.submitComposer(composer);
596
-
597
- const conversationPattern = this.conversationUrlPattern();
598
1853
  if (
599
- conversationPattern
600
- && !conversationPattern.test(new URL(urlBeforeSend).pathname)
1854
+ sendStartedFresh
1855
+ && this.requiresOrderedConversationSnapshotForFreshSend()
601
1856
  ) {
602
- await this.page.waitForURL(
603
- (url) => conversationPattern.test(url.pathname),
604
- { timeout: 5_000 },
605
- ).catch(() => null);
1857
+ const orderedBaseline = this.#validatedOrderedConversationSnapshot(
1858
+ await this.orderedConversationSnapshot().catch(() => null),
1859
+ );
1860
+ if (
1861
+ !orderedBaseline
1862
+ || orderedBaseline.entries.length !== 0
1863
+ || !sameConversationUrl(orderedBaseline.url, this.page.url())
1864
+ || !this.#activeSendProofIsIntact()
1865
+ ) {
1866
+ await this.writeDiagnostics("conversation-changed-before-send");
1867
+ throw new BrowserAdapterError(
1868
+ `${this.providerName} could not verify an empty ordered conversation before submission. `
1869
+ + "Nothing was sent.",
1870
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
1871
+ );
1872
+ }
1873
+ sendProof.orderedBaseline = {
1874
+ url: orderedBaseline.url,
1875
+ entries: [],
1876
+ };
606
1877
  }
607
1878
 
608
- let sentMessage = await this.#waitForSentUserMessage(userBaseline);
609
- if (!sentMessage) {
610
- // A click/Enter can be ignored while a native-tool card is still open.
611
- // Retry once after the composer is idle again.
612
- await this.#waitUntilReadyToSend(5_000);
613
- await this.submitComposer(composer);
614
- sentMessage = await this.#waitForSentUserMessage(userBaseline);
615
- }
1879
+ // From this point onward a transport failure cannot prove whether the
1880
+ // provider committed the message. Runtime must never resend it blindly. One
1881
+ // monotonic deadline includes the submission itself and every proof sample.
1882
+ sendProof.submissionStarted = true;
1883
+ sendProof.submittedAt = this.monotonicNow();
1884
+ sendProof.deadline = sendProof.submittedAt
1885
+ + this.sendConfirmationTimeoutMs();
1886
+ this.lastSendStatus = "commit-unknown";
1887
+ await this.#awaitWithinSendDeadline(
1888
+ this.submitComposer(composer),
1889
+ sendProof.deadline,
1890
+ );
1891
+
1892
+ const sentMessage = await this.#waitForSentUserMessage(userBaseline, {
1893
+ deadline: sendProof.deadline,
1894
+ expectedText: text,
1895
+ outboundId,
1896
+ allowPendingFreshTransition: sendStartedFresh,
1897
+ hasPreTransitionMarker,
1898
+ });
616
1899
  if (!sentMessage) {
617
- // The model never rendered the message: the send did not register (a
618
- // disabled send button, a missed Enter, or a transient UI state). Fail
619
- // loudly instead of pretending the message went out — otherwise the
620
- // runtime waits for a reply that was never received.
1900
+ // Submission already happened. A missing bubble is ambiguous: retrying can
1901
+ // duplicate a committed message whose DOM confirmation was delayed/lost.
621
1902
  await this.writeDiagnostics("send-not-detected");
622
1903
  throw new BrowserAdapterError(
623
- `${this.providerName} did not render the sent message; the send may have failed.`,
624
- { code: "SEND_NOT_DETECTED" },
1904
+ `${this.providerName} did not confirm whether the sent message committed. `
1905
+ + "It was not sent again automatically.",
1906
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
1907
+ );
1908
+ }
1909
+ this.lastUserMessageId = sentMessage.id ?? null;
1910
+ if (sendStartedFresh) {
1911
+ this.#confirmFreshConversationSend();
1912
+ } else {
1913
+ this.#observeConversationNavigation(this.page.url());
1914
+ }
1915
+
1916
+ let submittedPage = await this.#awaitWithinSendDeadline(
1917
+ this.#submittedPageState(),
1918
+ sendProof.deadline,
1919
+ );
1920
+ if (submittedPage.status === "candidate") {
1921
+ const verifiedUrl = await this.#waitForCanonicalCandidate(
1922
+ submittedPage.conversationUrl,
1923
+ { deadline: sendProof.deadline },
1924
+ );
1925
+ submittedPage = verifiedUrl
1926
+ ? { status: "trusted", conversationUrl: verifiedUrl }
1927
+ : { status: "untrusted", conversationUrl: this.page.url() };
1928
+ }
1929
+ if (submittedPage.status !== "trusted") {
1930
+ await this.writeDiagnostics("conversation-changed-after-submit");
1931
+ throw new BrowserAdapterError(
1932
+ `${this.providerName} changed conversations after submission, so the sent `
1933
+ + "message could not be bound to the verified page. It was not sent again.",
1934
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
625
1935
  );
626
1936
  }
627
- return { attachment };
1937
+
1938
+ if (this.lastUserMessageId) {
1939
+ this.expectedRestorationUserId = this.lastUserMessageId;
1940
+ }
1941
+ this.#captureActiveTurnProof(submittedPage.conversationUrl);
1942
+ this.lastSendStatus = "confirmed";
1943
+ return {
1944
+ attachment,
1945
+ userMessageId: this.lastUserMessageId,
1946
+ userTurn: sentMessage.turn ?? null,
1947
+ conversationUrl: submittedPage.conversationUrl,
1948
+ conversationTargetId: this.cdpChrome?.targetId ?? null,
1949
+ assistantBaseline: {
1950
+ ids: [...assistantBaseline.ids],
1951
+ count: assistantBaseline.count,
1952
+ maxTurn: assistantBaseline.maxTurn,
1953
+ lastText: this.lastAssistantTextBeforeSend,
1954
+ },
1955
+ preOutboundMarkerIds: [
1956
+ ...userBaseline.ids,
1957
+ ...assistantBaseline.ids,
1958
+ ],
1959
+ };
1960
+ } finally {
1961
+ if (sendProof && this.activeSendProof === sendProof) {
1962
+ sendProof.invalidated = true;
1963
+ this.activeSendProof = null;
1964
+ }
1965
+ if (this.lastSendStatus !== "confirmed") {
1966
+ this.allowInPlaceAssistantContinuation = false;
1967
+ this.inPlaceAssistantGenerationObserved = false;
1968
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1969
+ }
1970
+ }
628
1971
  }
629
1972
 
630
1973
  async waitForTurnComplete({
@@ -646,11 +1989,63 @@ export class BaseWebAdapter {
646
1989
  let emptyCandidate = null;
647
1990
  let sawGenerationSignal = false;
648
1991
  let lastGenerationSignalAt = 0;
1992
+ let unverifiedPageChecks = 0;
649
1993
  const detachEscCancel = this.#attachEscCancel();
650
1994
  const truncatedGraceMs = truncatedEnvelopeWindowMs
651
1995
  ?? this.truncatedEnvelopeGraceMs();
652
1996
  try {
653
1997
  while (Date.now() < deadline) {
1998
+ if (this.activeTurnConversationUrl) {
1999
+ // A main-frame navigation may nominate a provisional-to-canonical
2000
+ // transition before its saved message marker has remained stable.
2001
+ // Preserve the exact page/frame/target binding while the page-state
2002
+ // verifier below waits; no reply is read until that URL is trusted.
2003
+ if (!this.#activeTurnProofIsIntact({ allowPendingCanonicalUrl: true })) {
2004
+ await this.writeDiagnostics("conversation-changed-during-turn");
2005
+ throw new BrowserAdapterError(
2006
+ `${this.providerName} browser target changed while waiting for its reply. `
2007
+ + "No content from the substitute page was accepted.",
2008
+ {
2009
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2010
+ recoverable: false,
2011
+ },
2012
+ );
2013
+ }
2014
+ const pageState = await this.#submittedPageState();
2015
+ if (pageState.status === "candidate") {
2016
+ unverifiedPageChecks += 1;
2017
+ if (unverifiedPageChecks >= 60) {
2018
+ await this.writeDiagnostics("conversation-changed-during-turn");
2019
+ throw new BrowserAdapterError(
2020
+ `${this.providerName} could not correlate the conversation URL `
2021
+ + "that appeared while waiting for the reply.",
2022
+ {
2023
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2024
+ recoverable: false,
2025
+ },
2026
+ );
2027
+ }
2028
+ await this.page.waitForTimeout(250);
2029
+ continue;
2030
+ }
2031
+ if (pageState.status !== "trusted") {
2032
+ await this.writeDiagnostics("conversation-changed-during-turn");
2033
+ throw new BrowserAdapterError(
2034
+ `${this.providerName} left the verified conversation while waiting `
2035
+ + "for its reply. No content from the other page was accepted.",
2036
+ {
2037
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2038
+ recoverable: false,
2039
+ },
2040
+ );
2041
+ }
2042
+ unverifiedPageChecks = 0;
2043
+ this.activeTurnConversationUrl = pageState.conversationUrl;
2044
+ if (this.activeTurnProof) {
2045
+ this.activeTurnProof.conversationUrl = pageState.conversationUrl;
2046
+ }
2047
+ }
2048
+
654
2049
  await this.throwIfBlockedPage();
655
2050
 
656
2051
  if (this.escCancelRequested) {
@@ -667,23 +2062,56 @@ export class BaseWebAdapter {
667
2062
  const messages = this.assistantMessages();
668
2063
  const count = await messages.count();
669
2064
  const lastMessage = count > 0 ? messages.last() : null;
670
- const candidateText = lastMessage
671
- ? await this.assistantText(lastMessage)
672
- : "";
673
2065
  const { id: candidateId, turn: candidateTurn } = lastMessage
674
2066
  ? await this.messageIdentity(lastMessage)
675
2067
  : { id: null, turn: null };
676
- const hasNewAssistant = this.isNewAssistantIdentity({
677
- id: candidateId,
678
- turn: candidateTurn,
679
- text: candidateText,
680
- });
681
-
2068
+ await this.#validateRecoveredTurnBoundary();
2069
+ if (
2070
+ (this.expectedAssistantCandidateId != null
2071
+ || this.expectedAssistantCandidateTurn != null)
2072
+ && (
2073
+ !lastMessage
2074
+ || (
2075
+ this.expectedAssistantCandidateId != null
2076
+ && candidateId !== this.expectedAssistantCandidateId
2077
+ )
2078
+ || (
2079
+ this.expectedAssistantCandidateTurn != null
2080
+ && candidateTurn !== this.expectedAssistantCandidateTurn
2081
+ )
2082
+ )
2083
+ ) {
2084
+ await this.writeDiagnostics("recovered-assistant-boundary-changed");
2085
+ throw new BrowserAdapterError(
2086
+ `${this.providerName} assistant boundary changed after recovery. `
2087
+ + "No content from the replacement reply was accepted.",
2088
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
2089
+ );
2090
+ }
2091
+ const candidateText = lastMessage
2092
+ ? await this.assistantText(lastMessage)
2093
+ : "";
682
2094
  const stopVisible = await this.#isStopButtonVisible();
683
2095
  const assistantGenerating = lastMessage
684
2096
  ? await this.isAssistantGenerating(lastMessage)
685
2097
  : false;
686
2098
  const generating = stopVisible || assistantGenerating;
2099
+ if (generating && this.allowInPlaceAssistantContinuation) {
2100
+ this.inPlaceAssistantGenerationObserved = true;
2101
+ }
2102
+ const hasNewAssistant = this.isNewAssistantIdentity({
2103
+ id: candidateId,
2104
+ turn: candidateTurn,
2105
+ text: candidateText,
2106
+ generating,
2107
+ });
2108
+ if (hasNewAssistant) {
2109
+ await this.#validateRecoveredTurnBoundary({
2110
+ assistantPresent: true,
2111
+ assistantMessageId: candidateId,
2112
+ assistantTurn: candidateTurn,
2113
+ });
2114
+ }
687
2115
  if (hasNewAssistant || generating) {
688
2116
  // A reply node or a visible stop button proves generation started;
689
2117
  // only a request with neither signal can be dead.
@@ -718,7 +2146,13 @@ export class BaseWebAdapter {
718
2146
  emptySince > 0
719
2147
  && Date.now() - emptySince >= emptyResponseWindowMs
720
2148
  ) {
2149
+ await this.#validateRecoveredTurnBoundary({
2150
+ assistantPresent: true,
2151
+ assistantMessageId: candidateId,
2152
+ assistantTurn: candidateTurn,
2153
+ });
721
2154
  this.lastAssistantMessageId = candidateId;
2155
+ this.lastAssistantTurn = candidateTurn;
722
2156
  throw new BrowserAdapterError(
723
2157
  `${this.providerName} completed an assistant turn without any content.`,
724
2158
  {
@@ -766,15 +2200,21 @@ export class BaseWebAdapter {
766
2200
  )
767
2201
  )
768
2202
  ) {
769
- this.lastAssistantMessageId = candidateId;
770
2203
  const limitMarker = await this.findUsageLimitMarker(lastMessage);
2204
+ const generationError = await this.findGenerationErrorMarker(lastMessage);
2205
+ await this.#validateRecoveredTurnBoundary({
2206
+ assistantPresent: true,
2207
+ assistantMessageId: candidateId,
2208
+ assistantTurn: candidateTurn,
2209
+ });
2210
+ this.lastAssistantMessageId = candidateId;
2211
+ this.lastAssistantTurn = candidateTurn;
771
2212
  if (limitMarker) {
772
2213
  throw new BrowserAdapterError(
773
2214
  `${this.providerName} reported a usage limit (${limitMarker}).`,
774
2215
  { code: "USAGE_LIMIT_REACHED" },
775
2216
  );
776
2217
  }
777
- const generationError = await this.findGenerationErrorMarker(lastMessage);
778
2218
  if (generationError) {
779
2219
  throw new BrowserAdapterError(
780
2220
  `${this.providerName} generation failed (${generationError}).`,
@@ -799,7 +2239,13 @@ export class BaseWebAdapter {
799
2239
  && !assistantGenerating
800
2240
  && Date.now() - stableSince >= truncatedGraceMs
801
2241
  ) {
2242
+ await this.#validateRecoveredTurnBoundary({
2243
+ assistantPresent: true,
2244
+ assistantMessageId: candidateId,
2245
+ assistantTurn: candidateTurn,
2246
+ });
802
2247
  this.lastAssistantMessageId = candidateId;
2248
+ this.lastAssistantTurn = candidateTurn;
803
2249
  return lastText.trim();
804
2250
  }
805
2251
  }
@@ -823,6 +2269,11 @@ export class BaseWebAdapter {
823
2269
  && this.sentUserTurn != null
824
2270
  && Date.now() - quietSince >= quietGraceMs
825
2271
  ) {
2272
+ await this.#validateRecoveredTurnBoundary({
2273
+ assistantPresent: hasNewAssistant,
2274
+ assistantMessageId: candidateId,
2275
+ assistantTurn: candidateTurn,
2276
+ });
826
2277
  await this.writeDiagnostics("dead-request");
827
2278
  throw new BrowserAdapterError(
828
2279
  `${this.providerName} stopped responding without completing a reply.`,
@@ -842,6 +2293,8 @@ export class BaseWebAdapter {
842
2293
  { code: "TURN_TIMEOUT" },
843
2294
  );
844
2295
  } finally {
2296
+ this.allowInPlaceAssistantContinuation = false;
2297
+ this.inPlaceAssistantGenerationObserved = false;
845
2298
  detachEscCancel?.();
846
2299
  }
847
2300
  }
@@ -857,16 +2310,34 @@ export class BaseWebAdapter {
857
2310
  }
858
2311
 
859
2312
  async #isFreshConversation() {
860
- const current = new URL(this.page.url());
861
- const base = new URL(this.baseUrl);
862
- const pattern = this.conversationUrlPattern();
863
- if (
864
- current.hostname !== base.hostname
865
- || (pattern && pattern.test(current.pathname))
866
- ) {
867
- return false;
2313
+ return this.classifyConversationUrl(this.page.url()) === "fresh"
2314
+ && await this.conversationMessages().count().catch(() => -1) === 0;
2315
+ }
2316
+
2317
+ async #waitForStableFreshConversation({ attempts = 60 } = {}) {
2318
+ let previousSignature = null;
2319
+ let stableChecks = 0;
2320
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
2321
+ this.#throwIfCancelRequested();
2322
+ const currentUrl = normalizedConversationUrl(this.page.url());
2323
+ const kind = currentUrl
2324
+ ? this.classifyConversationUrl(currentUrl)
2325
+ : "invalid";
2326
+ const totalMessages = await this.conversationMessages()
2327
+ .count()
2328
+ .catch(() => -1);
2329
+ const signature = `${currentUrl ?? ""}:${kind}:${totalMessages}`;
2330
+ if (kind !== "fresh" || totalMessages !== 0) {
2331
+ return null;
2332
+ }
2333
+ stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
2334
+ previousSignature = signature;
2335
+ if (stableChecks >= RESTORATION_STABLE_CHECKS) {
2336
+ return currentUrl;
2337
+ }
2338
+ await this.page.waitForTimeout(250);
868
2339
  }
869
- return await this.conversationMessages().count() === 0;
2340
+ return null;
870
2341
  }
871
2342
 
872
2343
  async #openNewConversation() {
@@ -927,88 +2398,499 @@ export class BaseWebAdapter {
927
2398
  return { count, ids, maxTurn };
928
2399
  }
929
2400
 
930
- async #waitForSentUserMessage(baseline, attempts = this.sentUserWaitAttempts()) {
931
- for (let attempt = 0; attempt < attempts; attempt += 1) {
2401
+ #validatedOrderedConversationSnapshot(snapshot) {
2402
+ if (!snapshot || !Array.isArray(snapshot.entries)) {
2403
+ return null;
2404
+ }
2405
+ const url = normalizedConversationUrl(snapshot.url);
2406
+ if (!url) {
2407
+ return null;
2408
+ }
2409
+
2410
+ const ids = new Set();
2411
+ const turns = new Set();
2412
+ const entries = [];
2413
+ let previousDomIndex = -1;
2414
+ let previousTurn = -1;
2415
+ for (const entry of snapshot.entries) {
2416
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
2417
+ const turn = entry?.turn;
2418
+ const domIndex = entry?.domIndex;
2419
+ if (
2420
+ !["user", "assistant"].includes(entry?.role)
2421
+ || !id
2422
+ || !Number.isSafeInteger(turn)
2423
+ || turn < 0
2424
+ || !Number.isSafeInteger(domIndex)
2425
+ || domIndex <= previousDomIndex
2426
+ || turn <= previousTurn
2427
+ || ids.has(id)
2428
+ || turns.has(turn)
2429
+ ) {
2430
+ return null;
2431
+ }
2432
+ ids.add(id);
2433
+ turns.add(turn);
2434
+ previousDomIndex = domIndex;
2435
+ previousTurn = turn;
2436
+ entries.push({
2437
+ domIndex,
2438
+ role: entry.role,
2439
+ id,
2440
+ turn,
2441
+ renderedText: String(entry.renderedText ?? ""),
2442
+ });
2443
+ }
2444
+ return { url, entries };
2445
+ }
2446
+
2447
+ #freshUserFromOrderedSnapshot(snapshot, {
2448
+ baseline,
2449
+ pageState,
2450
+ expectedText,
2451
+ outboundId,
2452
+ }) {
2453
+ const proof = this.activeSendProof;
2454
+ if (!proof || !this.#activeSendProofIsIntact()) {
2455
+ return null;
2456
+ }
2457
+ if (!sameConversationUrl(pageState.conversationUrl, proof.currentUrl)) {
2458
+ // A permitted main-frame transition can complete while the asynchronous
2459
+ // DOM snapshot is being read. Its navigation event has already advanced
2460
+ // the intact send proof, so this page-state/snapshot pair is stale, not
2461
+ // evidence of a substituted conversation. Discard the sample and verify
2462
+ // the same outbound/user again on the new route for a full stable window.
2463
+ return null;
2464
+ }
2465
+ const ordered = this.#validatedOrderedConversationSnapshot(snapshot);
2466
+ if (!ordered) {
2467
+ // A real assistant row can be inserted over more than one render frame.
2468
+ // An incomplete atomic sample neither proves nor disproves the user bubble;
2469
+ // a later valid sample must still pass the full structural checks.
2470
+ return null;
2471
+ }
2472
+ if (
2473
+ !sameConversationUrl(ordered.url, pageState.conversationUrl)
2474
+ || baseline.count !== 0
2475
+ ) {
2476
+ proof.invalidated = true;
2477
+ return null;
2478
+ }
2479
+ if (ordered.entries.length === 0) {
2480
+ if (proof.freshCandidateUserId != null) {
2481
+ proof.invalidated = true;
2482
+ }
2483
+ return null;
2484
+ }
2485
+
2486
+ const users = ordered.entries.filter((entry) => entry.role === "user");
2487
+ const matchingUsers = users.filter((entry) => (
2488
+ renderedMessageContains(entry.renderedText, expectedText, outboundId)
2489
+ ));
2490
+ const validFreshTopology = users.length === 1
2491
+ && ordered.entries[0] === users[0];
2492
+ if (!validFreshTopology || matchingUsers.length > 1) {
2493
+ proof.invalidated = true;
2494
+ return null;
2495
+ }
2496
+ if (matchingUsers.length === 0) {
2497
+ // The committed bubble may mount before its rendered text. Once its marker
2498
+ // has been observed, however, losing it is an identity change, not loading.
2499
+ if (proof.freshCandidateUserId != null) {
2500
+ proof.invalidated = true;
2501
+ }
2502
+ return null;
2503
+ }
2504
+
2505
+ const user = matchingUsers[0];
2506
+ const newByTurn = baseline.maxTurn == null || user.turn > baseline.maxTurn;
2507
+ const newById = !baseline.ids.has(user.id);
2508
+ if (!newByTurn || !newById) {
2509
+ proof.invalidated = true;
2510
+ return null;
2511
+ }
2512
+ if (
2513
+ (proof.freshCandidateUserId != null
2514
+ && proof.freshCandidateUserId !== user.id)
2515
+ || (proof.freshCandidateUserTurn != null
2516
+ && proof.freshCandidateUserTurn !== user.turn)
2517
+ || (
2518
+ !sameConversationUrl(pageState.conversationUrl, proof.sourceUrl)
2519
+ && !sameConversationUrl(pageState.conversationUrl, proof.candidateUrl)
2520
+ )
2521
+ ) {
2522
+ proof.invalidated = true;
2523
+ return null;
2524
+ }
2525
+ proof.freshCandidateUserId = user.id;
2526
+ proof.freshCandidateUserTurn = user.turn;
2527
+
2528
+ const correlationIdentity = outboundId == null
2529
+ ? comparableMessageText(expectedText)
2530
+ : `${OUTBOUND_MARKER_PREFIX}${outboundId}.`;
2531
+ return {
2532
+ id: user.id,
2533
+ turn: user.turn,
2534
+ signature: [
2535
+ proof.sendEpoch,
2536
+ proof.candidateNavigationEpoch ?? proof.startedNavigationEpoch,
2537
+ pageState.conversationUrl,
2538
+ proof.targetId ?? "",
2539
+ user.id,
2540
+ user.turn,
2541
+ correlationIdentity,
2542
+ ].join(":"),
2543
+ };
2544
+ }
2545
+
2546
+ async #waitBeforeSendDeadline(deadline, ms = 100) {
2547
+ const remaining = deadline - this.monotonicNow();
2548
+ if (remaining <= 0) {
2549
+ return false;
2550
+ }
2551
+ await this.#awaitWithinSendDeadline(
2552
+ this.waitForPoll(Math.min(ms, remaining)),
2553
+ deadline,
2554
+ );
2555
+ return true;
2556
+ }
2557
+
2558
+ async #throwCommitUnknownForPageChange() {
2559
+ await this.writeDiagnostics("conversation-changed-after-submit");
2560
+ throw new BrowserAdapterError(
2561
+ `${this.providerName} changed conversations after submission, so commit `
2562
+ + "could not be verified. The message was not sent again.",
2563
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
2564
+ );
2565
+ }
2566
+
2567
+ async #waitForSentUserMessage(baseline, {
2568
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
2569
+ expectedText = "",
2570
+ outboundId = null,
2571
+ allowPendingFreshTransition = false,
2572
+ hasPreTransitionMarker = false,
2573
+ } = {}) {
2574
+ let candidateBubbleSignature = null;
2575
+ let candidateBubbleStableSince = 0;
2576
+ const requireFreshCorrelation = allowPendingFreshTransition;
2577
+ const requireOrderedSnapshot = requireFreshCorrelation
2578
+ && this.requiresOrderedConversationSnapshotForFreshSend();
2579
+ const withinDeadline = (promise) => this.#awaitWithinSendDeadline(
2580
+ promise,
2581
+ deadline,
2582
+ );
2583
+
2584
+ while (this.monotonicNow() < deadline) {
932
2585
  this.#throwIfCancelRequested();
933
- const messages = this.userMessages();
934
- const count = await messages.count().catch(() => 0);
935
- for (let index = count - 1; index >= 0; index -= 1) {
936
- const identity = await this.messageIdentity(messages.nth(index));
937
- const newByTurn = identity.turn != null
938
- && (
939
- baseline.maxTurn == null
940
- || identity.turn > baseline.maxTurn
941
- );
942
- const newById = Boolean(
943
- identity.id
944
- && !baseline.ids.has(identity.id),
2586
+ const pageState = await withinDeadline(this.#submittedPageState({
2587
+ allowPendingFreshTransition,
2588
+ }));
2589
+ if (pageState.status === "untrusted") {
2590
+ await this.#throwCommitUnknownForPageChange();
2591
+ }
2592
+ if (pageState.status === "candidate" && hasPreTransitionMarker) {
2593
+ // Existing conversations must first carry a stable pre-navigation marker.
2594
+ // Never bootstrap candidate trust from a message found on the candidate.
2595
+ candidateBubbleSignature = null;
2596
+ candidateBubbleStableSince = 0;
2597
+ await this.#waitBeforeSendDeadline(deadline);
2598
+ continue;
2599
+ }
2600
+
2601
+ const proofNavigated = this.activeSendProof?.candidateNavigationEpoch != null;
2602
+ const requireCorrelation = outboundId != null
2603
+ || requireFreshCorrelation
2604
+ || proofNavigated;
2605
+ let candidate = null;
2606
+ let orderedSnapshot = null;
2607
+ if (requireOrderedSnapshot) {
2608
+ orderedSnapshot = await withinDeadline(
2609
+ this.orderedConversationSnapshot().catch(() => null),
945
2610
  );
946
- if (newByTurn || newById) {
947
- this.sentUserTurn = identity.turn;
948
- return identity;
2611
+ candidate = this.#freshUserFromOrderedSnapshot(orderedSnapshot, {
2612
+ baseline,
2613
+ pageState,
2614
+ expectedText,
2615
+ outboundId,
2616
+ });
2617
+ } else {
2618
+ const messages = this.userMessages();
2619
+ const count = await withinDeadline(messages.count().catch(() => 0));
2620
+ for (let index = count - 1; index >= 0; index -= 1) {
2621
+ const message = messages.nth(index);
2622
+ const identity = await withinDeadline(
2623
+ this.messageIdentity(message),
2624
+ );
2625
+ const newByTurn = identity.turn != null
2626
+ && (baseline.maxTurn == null || identity.turn > baseline.maxTurn);
2627
+ const newById = Boolean(
2628
+ identity.id && !baseline.ids.has(identity.id),
2629
+ );
2630
+ if (!newByTurn && !newById) {
2631
+ continue;
2632
+ }
2633
+
2634
+ if (requireCorrelation) {
2635
+ // A context-dependent send that crossed a route boundary needs the
2636
+ // transport nonce; equal human text is not a conversation identity.
2637
+ if (!requireFreshCorrelation && outboundId == null) {
2638
+ continue;
2639
+ }
2640
+ const renderedText = await withinDeadline(
2641
+ this.userMessageText(message),
2642
+ );
2643
+ if (
2644
+ index !== count - 1
2645
+ || !renderedMessageContains(renderedText, expectedText, outboundId)
2646
+ ) {
2647
+ continue;
2648
+ }
2649
+ if (requireFreshCorrelation) {
2650
+ const totalMessages = await withinDeadline(
2651
+ this.conversationMessages().count().catch(() => -1),
2652
+ );
2653
+ if (
2654
+ baseline.count !== 0
2655
+ || count !== 1
2656
+ || totalMessages < 1
2657
+ || totalMessages > 2
2658
+ ) {
2659
+ continue;
2660
+ }
2661
+ }
2662
+ }
2663
+ const proof = this.activeSendProof;
2664
+ candidate = {
2665
+ ...identity,
2666
+ signature: requireCorrelation
2667
+ ? [
2668
+ proof?.sendEpoch ?? "",
2669
+ proof?.candidateNavigationEpoch ?? proof?.startedNavigationEpoch ?? "",
2670
+ pageState.conversationUrl,
2671
+ proof?.targetId ?? "",
2672
+ identity.id ?? "",
2673
+ identity.turn ?? "",
2674
+ outboundId == null
2675
+ ? comparableMessageText(expectedText)
2676
+ : `${OUTBOUND_MARKER_PREFIX}${outboundId}.`,
2677
+ ].join(":")
2678
+ : null,
2679
+ };
2680
+ break;
2681
+ }
2682
+ }
2683
+
2684
+ if (!candidate) {
2685
+ if (
2686
+ !requireOrderedSnapshot
2687
+ || candidateBubbleSignature == null
2688
+ || this.activeSendProof?.invalidated
2689
+ ) {
2690
+ candidateBubbleSignature = null;
2691
+ candidateBubbleStableSince = 0;
2692
+ }
2693
+ await this.#waitBeforeSendDeadline(deadline);
2694
+ continue;
2695
+ }
2696
+
2697
+ if (requireCorrelation) {
2698
+ const observedAt = this.monotonicNow();
2699
+ if (candidate.signature !== candidateBubbleSignature) {
2700
+ candidateBubbleSignature = candidate.signature;
2701
+ candidateBubbleStableSince = observedAt;
2702
+ if (this.restorationCorrelationWindowMs() > 0) {
2703
+ await this.#waitBeforeSendDeadline(deadline);
2704
+ continue;
2705
+ }
2706
+ }
2707
+ if (
2708
+ observedAt - candidateBubbleStableSince
2709
+ < this.restorationCorrelationWindowMs()
2710
+ ) {
2711
+ await this.#waitBeforeSendDeadline(deadline);
2712
+ continue;
949
2713
  }
2714
+
2715
+ if (requireOrderedSnapshot) {
2716
+ const finalSnapshot = await withinDeadline(
2717
+ this.orderedConversationSnapshot().catch(() => null),
2718
+ );
2719
+ const finalCandidate = this.#freshUserFromOrderedSnapshot(finalSnapshot, {
2720
+ baseline,
2721
+ pageState,
2722
+ expectedText,
2723
+ outboundId,
2724
+ });
2725
+ if (!finalCandidate || finalCandidate.signature !== candidate.signature) {
2726
+ if (this.activeSendProof?.invalidated) {
2727
+ candidateBubbleSignature = null;
2728
+ candidateBubbleStableSince = 0;
2729
+ }
2730
+ await this.#waitBeforeSendDeadline(deadline);
2731
+ continue;
2732
+ }
2733
+ }
2734
+ }
2735
+
2736
+ const confirmedPage = await withinDeadline(this.#submittedPageState({
2737
+ allowPendingFreshTransition,
2738
+ }));
2739
+ if (confirmedPage.status === "untrusted") {
2740
+ await this.#throwCommitUnknownForPageChange();
2741
+ }
2742
+ if (
2743
+ !sameConversationUrl(
2744
+ confirmedPage.conversationUrl,
2745
+ pageState.conversationUrl,
2746
+ )
2747
+ || (
2748
+ pageState.status === "trusted"
2749
+ && confirmedPage.status !== "trusted"
2750
+ )
2751
+ ) {
2752
+ // The DOM may have been read across a SPA navigation. Retry against one
2753
+ // stable URL rather than binding that mixed snapshot to this send.
2754
+ candidateBubbleSignature = null;
2755
+ candidateBubbleStableSince = 0;
2756
+ await this.#waitBeforeSendDeadline(deadline);
2757
+ continue;
950
2758
  }
951
- await this.page.waitForTimeout(100);
2759
+ this.sentUserTurn = candidate.turn;
2760
+ return {
2761
+ id: candidate.id ?? null,
2762
+ turn: candidate.turn ?? null,
2763
+ conversationUrl: confirmedPage.conversationUrl,
2764
+ };
952
2765
  }
953
2766
  return null;
954
2767
  }
955
2768
 
956
2769
  async #waitForConversationHistory({
957
2770
  expectedAssistantMessageId = null,
2771
+ expectedUserMessageId = null,
958
2772
  expectedUrl = null,
2773
+ allowCanonicalTransition = false,
959
2774
  attempts = 60,
960
2775
  } = {}) {
961
2776
  let previousSignature = null;
962
2777
  let stableChecks = 0;
963
2778
  let scrolledToBottom = 0;
2779
+ const normalizedExpected = normalizedConversationUrl(expectedUrl);
2780
+
2781
+ if (
2782
+ allowCanonicalTransition
2783
+ && sameConversationUrl(this.page.url(), normalizedExpected)
2784
+ ) {
2785
+ this.#beginConversationObservation(normalizedExpected);
2786
+ }
964
2787
 
965
2788
  for (let attempt = 0; attempt < attempts; attempt += 1) {
966
2789
  this.#throwIfCancelRequested();
967
- const assistant = await this.#captureMessageIdentities(
968
- this.assistantMessages(),
2790
+ const currentUrl = normalizedConversationUrl(this.page.url());
2791
+ const currentKind = currentUrl
2792
+ ? this.classifyConversationUrl(currentUrl)
2793
+ : "invalid";
2794
+ const [assistant, user] = await Promise.all([
2795
+ this.#captureMessageIdentities(this.assistantMessages()),
2796
+ this.#captureMessageIdentities(this.userMessages()),
2797
+ ]);
2798
+ const totalMessages = await this.conversationMessages()
2799
+ .count()
2800
+ .catch(() => -1);
2801
+ const assistantMarkerFound = Boolean(
2802
+ expectedAssistantMessageId
2803
+ && assistant.ids.has(expectedAssistantMessageId)
2804
+ );
2805
+ const userMarkerFound = Boolean(
2806
+ expectedUserMessageId
2807
+ && user.ids.has(expectedUserMessageId)
2808
+ );
2809
+ const markerFound = assistantMarkerFound || userMarkerFound;
2810
+
2811
+ // Page/target continuity does not prove conversation continuity: a user can
2812
+ // navigate that tab. A provisional route's canonical candidate therefore
2813
+ // needs a persisted message id on every sample in the stability window. A
2814
+ // single stale React snapshot must never latch an unrelated canonical URL.
2815
+ const markerCorrelatedTransition = Boolean(
2816
+ allowCanonicalTransition
2817
+ && currentKind === "restorable"
2818
+ && markerFound
969
2819
  );
2820
+ const expectedConversation = Boolean(
2821
+ normalizedExpected
2822
+ && (
2823
+ sameConversationUrl(currentUrl, normalizedExpected)
2824
+ || markerCorrelatedTransition
2825
+ )
2826
+ );
2827
+
2828
+ // The expected assistant marker is near the virtualized tail. Scroll a few
2829
+ // times before falling back to stable URL + non-empty history.
970
2830
  if (
971
2831
  expectedAssistantMessageId
972
- && assistant.ids.has(expectedAssistantMessageId)
2832
+ && !assistantMarkerFound
2833
+ && scrolledToBottom < 3
973
2834
  ) {
974
- return;
975
- }
976
-
977
- // The expected resume marker is the latest reply, near the bottom of a
978
- // virtualized thread: scroll down a few times to force the tail to mount.
979
- if (expectedAssistantMessageId && scrolledToBottom < 3) {
980
2835
  await this.scrollConversationToBottom();
981
2836
  scrolledToBottom += 1;
982
2837
  }
983
2838
 
984
- const totalMessages = await this.conversationMessages()
985
- .count()
986
- .catch(() => 0);
987
2839
  const signature = [
2840
+ currentUrl ?? "",
2841
+ currentKind,
2842
+ expectedConversation,
988
2843
  totalMessages,
989
2844
  assistant.count,
990
2845
  assistant.maxTurn ?? "",
991
2846
  [...assistant.ids].join(","),
2847
+ user.count,
2848
+ user.maxTurn ?? "",
2849
+ [...user.ids].join(","),
992
2850
  ].join(":");
993
- if (totalMessages > 0 && signature === previousSignature) {
994
- stableChecks += 1;
995
- if (stableChecks >= 3) {
996
- // With an expected id we normally return as soon as it appears;
997
- // reaching stability instead means the id is not mounted or has been
998
- // deleted (providers remove transient error/limit cards, and the last
999
- // recorded reply can be one). Accept when the URL still proves this
1000
- // is the expected conversation.
1001
- if (
1002
- !expectedAssistantMessageId
1003
- || !expectedUrl
1004
- || sameConversationUrl(this.page.url(), expectedUrl)
1005
- ) {
1006
- return;
2851
+ stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
2852
+ previousSignature = signature;
2853
+
2854
+ if (stableChecks >= RESTORATION_STABLE_CHECKS) {
2855
+ if (expectedConversation && totalMessages > 0) {
2856
+ const rebindActiveTurn = this.activeTurnConversationUrl != null;
2857
+ this.#beginConversationObservation(currentUrl, {
2858
+ arm: false,
2859
+ reset: true,
2860
+ });
2861
+ if (rebindActiveTurn) {
2862
+ this.#captureActiveTurnProof(currentUrl);
1007
2863
  }
2864
+ this.#publishConversationIdentity();
2865
+ return {
2866
+ status: "restored-existing",
2867
+ conversationUrl: currentUrl,
2868
+ ...(this.cdpChrome?.targetId
2869
+ ? { targetId: this.cdpChrome.targetId }
2870
+ : {}),
2871
+ };
2872
+ }
2873
+ if (currentKind === "fresh" && totalMessages === 0) {
2874
+ // A durable/provisional route may have expired and redirected to the
2875
+ // provider's explicit empty new-chat route. The prolonged stable check
2876
+ // prevents an early composer mount from masquerading as lost history.
2877
+ this.expectedRestorationAssistantId = null;
2878
+ this.expectedRestorationUserId = null;
2879
+ this.lastAssistantMessageId = null;
2880
+ this.lastUserMessageId = null;
2881
+ this.activeTurnConversationUrl = null;
2882
+ this.#beginConversationObservation(currentUrl, {
2883
+ arm: false,
2884
+ reset: true,
2885
+ });
2886
+ return {
2887
+ status: "verified-fresh",
2888
+ conversationUrl: currentUrl,
2889
+ ...(this.cdpChrome?.targetId
2890
+ ? { targetId: this.cdpChrome.targetId }
2891
+ : {}),
2892
+ };
1008
2893
  }
1009
- } else {
1010
- stableChecks = 0;
1011
- previousSignature = signature;
1012
2894
  }
1013
2895
 
1014
2896
  await this.page.waitForTimeout(250);
@@ -1036,8 +2918,9 @@ export class BaseWebAdapter {
1036
2918
  }
1037
2919
 
1038
2920
  // While a turn is being processed, raw-mode stdin lets ESC cancel the wait.
1039
- // Raw mode swallows Ctrl+C, so forward it as a real SIGINT so the CLI's
1040
- // existing interrupt path still runs. The returned detach restores the
2921
+ // Raw mode swallows Ctrl+C, so dispatch the CLI's SIGINT event internally.
2922
+ // process.kill(self, "SIGINT") would terminate Windows without cleanup.
2923
+ // The returned detach restores the
1041
2924
  // previous terminal mode, keeping approval prompts (which also read stdin)
1042
2925
  // working. Keys other than ESC/Ctrl+C are consumed and dropped.
1043
2926
  //
@@ -1056,7 +2939,7 @@ export class BaseWebAdapter {
1056
2939
  if (key?.name === "escape") {
1057
2940
  this.escCancelRequested = true;
1058
2941
  } else if (key?.ctrl && key?.name === "c") {
1059
- process.kill(process.pid, "SIGINT");
2942
+ process.emit("SIGINT", "SIGINT");
1060
2943
  }
1061
2944
  };
1062
2945
  stream.on("keypress", onKeypress);