wtagent 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +163 -9
  2. package/package.json +9 -8
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +2418 -201
  7. package/src/browser/cdp-browser.js +223 -25
  8. package/src/browser/cdp-state.js +8 -5
  9. package/src/browser/chatgpt-dom.js +179 -0
  10. package/src/browser/chatgpt-web-adapter.js +831 -69
  11. package/src/browser/claude-web-adapter.js +13 -0
  12. package/src/browser/fake-web-model-adapter.js +152 -3
  13. package/src/browser/gemini-web-adapter.js +71 -20
  14. package/src/browser/glm-web-adapter.js +2 -2
  15. package/src/browser/grok-web-adapter.js +35 -3
  16. package/src/browser/rendered-text.js +11 -0
  17. package/src/cli/i18n.js +50 -20
  18. package/src/cli/main.js +124 -40
  19. package/src/cli/prompt-input.js +68 -0
  20. package/src/image/browser-image-driver.js +238 -0
  21. package/src/image/browser-image-session-pool.js +96 -0
  22. package/src/image/image-generation-service.js +101 -0
  23. package/src/image/image-provider-driver.js +22 -0
  24. package/src/image/native-image-receiver.js +66 -0
  25. package/src/image/provider-registry.js +23 -0
  26. package/src/image/providers/chatgpt-image-driver.js +211 -0
  27. package/src/image/providers/gemini-image-driver.js +100 -0
  28. package/src/image/providers/grok-image-driver.js +193 -0
  29. package/src/platform/command-launcher.js +1 -1
  30. package/src/platform/paths.js +19 -0
  31. package/src/platform/windows-diagnostics.js +9 -16
  32. package/src/policy/path-guard.js +5 -1
  33. package/src/policy/policy-engine.js +10 -5
  34. package/src/protocol/envelope.js +74 -0
  35. package/src/protocol/markers.js +16 -0
  36. package/src/protocol/prompt-builder.js +12 -9
  37. package/src/protocol/xml-protocol.js +36 -18
  38. package/src/runtime/agent-runtime.js +1383 -238
  39. package/src/session/agent-session.js +1047 -121
  40. package/src/tools/default-tools.js +1 -1
  41. package/src/tools/image-tools.js +92 -0
  42. package/src/tools/registry.js +13 -1
  43. package/docs/technical-design.md +0 -866
@@ -5,6 +5,8 @@ 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 { hasCompleteAgentEnvelope } from "../protocol/envelope.js";
9
+ export { hasCompleteAgentEnvelope } from "../protocol/envelope.js";
8
10
 
9
11
  // Playwright error messages for a dead transport. The Chrome process itself is
10
12
  // usually still alive (e.g. the connection died while the Mac slept); these
@@ -29,6 +31,26 @@ export function isConnectionLostError(error) {
29
31
  const CHALLENGE_BODY_PATTERN =
30
32
  /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;
31
33
 
34
+ // A composer can mount well before an existing SPA conversation finishes
35
+ // hydrating. Require several seconds of unchanged URL + DOM state before either
36
+ // restored history or an empty new-chat page is trusted.
37
+ const RESTORATION_STABLE_CHECKS = 12;
38
+ const RESTORATION_STABLE_WINDOW_MS = (RESTORATION_STABLE_CHECKS - 1) * 250;
39
+
40
+ function normalizedPathname(value) {
41
+ const pathname = String(value || "/").replace(/\/+$/, "");
42
+ return pathname || "/";
43
+ }
44
+
45
+ function normalizedConversationUrl(value) {
46
+ try {
47
+ const parsed = value instanceof URL ? value : new URL(value);
48
+ return `${parsed.origin}${normalizedPathname(parsed.pathname)}`;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
32
54
  export async function firstVisible(locators) {
33
55
  for (const locator of locators) {
34
56
  const count = await locator.count().catch(() => 0);
@@ -48,26 +70,71 @@ function deltaFrom(previous, current) {
48
70
  return "";
49
71
  }
50
72
 
51
- export function hasCompleteAgentEnvelope(text) {
52
- const trimmed = String(text ?? "").trim();
53
- const start = trimmed.indexOf("<agent_response");
54
- const endTag = "</agent_response>";
55
- const end = trimmed.lastIndexOf(endTag);
56
- // The envelope is "complete" as soon as both the opening and closing tags
57
- // are present. The web model may append trailing text or render rich cards
58
- // after the XML, so we do not require the closing tag to be the last content.
59
- return start >= 0 && end >= start;
73
+ function sameConversationUrl(left, right) {
74
+ const normalizedLeft = normalizedConversationUrl(left);
75
+ return normalizedLeft != null
76
+ && normalizedLeft === normalizedConversationUrl(right);
60
77
  }
61
78
 
62
- function sameConversationUrl(left, right) {
63
- try {
64
- const leftUrl = new URL(left);
65
- const rightUrl = new URL(right);
66
- return leftUrl.origin === rightUrl.origin
67
- && leftUrl.pathname === rightUrl.pathname;
68
- } catch {
79
+ function comparableMessageText(value) {
80
+ return String(value ?? "").replace(/\s+/g, " ").trim();
81
+ }
82
+
83
+ const OUTBOUND_ID_PATTERN =
84
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
85
+ const OUTBOUND_MARKER_PREFIX =
86
+ "Opaque WTAgent transport correlation ID (do not repeat): ";
87
+
88
+ function renderedMessageContains(rendered, expected, outboundId = null) {
89
+ const normalizedRendered = comparableMessageText(rendered);
90
+ const normalizedExpected = comparableMessageText(expected);
91
+ const reminderStart = normalizedExpected.lastIndexOf("<system_reminder>");
92
+ const reminder = reminderStart < 0 ? "" : normalizedExpected.slice(reminderStart);
93
+ const correlation = reminder.match(
94
+ /(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>$/,
95
+ );
96
+ if (!correlation) {
97
+ return outboundId == null
98
+ && normalizedExpected.length > 0
99
+ && normalizedRendered.includes(normalizedExpected);
100
+ }
101
+ if (outboundId != null && correlation[2] !== outboundId) {
102
+ return false;
103
+ }
104
+ // Markdown can change the displayed payload. Correlate only the exact
105
+ // per-send nonce from its final reminder; URL/history guards remain separate.
106
+ const marker = correlation[1];
107
+ const index = normalizedRendered.indexOf(marker);
108
+ if (index < 0 || normalizedRendered.indexOf(marker, index + marker.length) !== -1) {
109
+ return false;
110
+ }
111
+ const renderedReminderStart = normalizedRendered.lastIndexOf("<system_reminder>", index);
112
+ if (
113
+ renderedReminderStart < 0
114
+ || normalizedRendered.slice(renderedReminderStart, index).includes("</system_reminder>")
115
+ ) {
116
+ return false;
117
+ }
118
+ const closingTag = "</system_reminder>";
119
+ const renderedSuffix = normalizedRendered.slice(index + marker.length);
120
+ if (!renderedSuffix.startsWith(closingTag)) {
69
121
  return false;
70
122
  }
123
+ const trailing = renderedSuffix.slice(closingTag.length);
124
+ return !trailing.includes("<system_reminder")
125
+ && !trailing.includes("</system_reminder>");
126
+ }
127
+
128
+ export function isValidOutboundCorrelationId(value) {
129
+ return typeof value === "string" && OUTBOUND_ID_PATTERN.test(value);
130
+ }
131
+
132
+ export function renderedMessageContainsOutboundMarker(rendered, outboundId) {
133
+ if (!isValidOutboundCorrelationId(outboundId)) {
134
+ return false;
135
+ }
136
+ const expected = `<system_reminder>${OUTBOUND_MARKER_PREFIX}${outboundId}.</system_reminder>`;
137
+ return renderedMessageContains(rendered, expected, outboundId);
71
138
  }
72
139
 
73
140
  // Provider-independent orchestration for a web-AI conversation driven over CDP.
@@ -108,11 +175,43 @@ export class BaseWebAdapter {
108
175
  this.context = null;
109
176
  this.cdpChrome = null;
110
177
  this.page = null;
178
+ this.preservedPreferredUrl = null;
179
+ this.observedConversationUrl = null;
180
+ this.observedConversationAliases = new Set();
181
+ this.pendingFreshConversationAliases = new Set();
182
+ this.pendingFreshEmptyAliases = new Map();
183
+ this.conversationIdentityListener = null;
184
+ this.pageNavigationListener = null;
185
+ this.lastObservedPageUrl = null;
186
+ this.pendingCanonicalUrl = null;
187
+ this.pendingCanonicalSignature = null;
188
+ this.pendingCanonicalStableSince = 0;
189
+ this.pendingCanonicalVerificationQueue = Promise.resolve();
190
+ this.conversationObservationArmed = false;
191
+ this.freshSendConfirmed = false;
192
+ this.navigationEpoch = 0;
193
+ this.sendEpoch = 0;
194
+ this.conversationScopeEpoch = 0;
195
+ this.activeSendProof = null;
196
+ this.activeTurnProof = null;
197
+ this.activeTurnConversationUrl = null;
198
+ this.allowInPlaceAssistantContinuation = false;
199
+ this.inPlaceAssistantGenerationObserved = false;
200
+ this.expectedRestorationAssistantId = null;
201
+ this.expectedRestorationUserId = null;
202
+ this.expectedAssistantCandidateId = null;
203
+ this.expectedAssistantCandidateTurn = null;
204
+ this.recoveredTurnTopologyRequired = false;
205
+ this.preferredTabAmbiguous = false;
111
206
  this.assistantIdsBeforeSend = new Set();
112
207
  this.assistantMaxTurnBeforeSend = null;
113
208
  this.assistantCountBeforeSend = 0;
114
209
  this.sentUserTurn = null;
210
+ this.lastUserMessageId = null;
115
211
  this.lastAssistantMessageId = null;
212
+ this.lastAssistantTurn = null;
213
+ this.lastSendStatus = "not-submitted";
214
+ this.pendingSubmission = null;
116
215
  }
117
216
 
118
217
  // ---- provider primitives (override in subclasses) ----------------------
@@ -127,6 +226,48 @@ export class BaseWebAdapter {
127
226
  return null;
128
227
  }
129
228
 
229
+ freshConversationUrlPattern() {
230
+ // Optional extra provider routes that explicitly mean a blank new chat. The
231
+ // baseUrl pathname is always accepted; arbitrary other same-origin routes are
232
+ // not. For example, Claude exposes both / and /new.
233
+ return null;
234
+ }
235
+
236
+ // Classifies saved/current URLs without navigating to them. Providers may
237
+ // refine a matching conversation route into "provisional" when it is useful
238
+ // only as a locator for an already-open tab, not as a durable navigation URL.
239
+ // Only the provider's explicit base route is fresh; an arbitrary same-origin
240
+ // settings/project/error route is unknown and must never receive a prompt.
241
+ classifyConversationUrl(value) {
242
+ let parsed;
243
+ let base;
244
+ try {
245
+ parsed = value instanceof URL ? value : new URL(value);
246
+ base = new URL(this.baseUrl);
247
+ } catch {
248
+ return "invalid";
249
+ }
250
+ if (parsed.protocol !== "https:" || parsed.origin !== base.origin) {
251
+ return "invalid";
252
+ }
253
+
254
+ const pattern = this.conversationUrlPattern();
255
+ if (pattern?.test(parsed.pathname)) {
256
+ return "restorable";
257
+ }
258
+ const explicitFresh = normalizedPathname(parsed.pathname)
259
+ === normalizedPathname(base.pathname)
260
+ || this.freshConversationUrlPattern()?.test(parsed.pathname);
261
+ return explicitFresh ? "fresh" : "unknown";
262
+ }
263
+
264
+ // Existing attachment previews in a reused tab may belong to an abandoned
265
+ // draft. Providers with uploads override this with structural chip locators;
266
+ // the base refuses to send until no stale chips remain.
267
+ pendingAttachmentLocators() {
268
+ return [];
269
+ }
270
+
130
271
  composerLocators() {
131
272
  return [];
132
273
  }
@@ -187,6 +328,49 @@ export class BaseWebAdapter {
187
328
  return await message.innerText().catch(() => "");
188
329
  }
189
330
 
331
+ // Used only to correlate a post-submit bubble while the provider is assigning
332
+ // a new conversation URL. Stable URL bindings remain the primary guard.
333
+ async userMessageText(message) {
334
+ return await message.innerText().catch(() => "");
335
+ }
336
+
337
+ // Virtualized providers can read identities and text in one DOM operation.
338
+ // undefined uses legacy locators; null means a supported sample failed and
339
+ // must be retried, never mixed with a different DOM revision.
340
+ async userMessageSnapshot() {
341
+ return undefined;
342
+ }
343
+
344
+ // Providers with a shared user/assistant turn order return one atomic snapshot.
345
+ // null keeps legacy normal-send behavior but cannot support ambiguous recovery.
346
+ async orderedConversationSnapshot() {
347
+ return null;
348
+ }
349
+
350
+ // Recovery-capable providers revalidate the correlated user/assistant suffix
351
+ // from one atomic DOM snapshot while the generic completion loop is running.
352
+ // The default is a no-op because providers without ordered snapshots never
353
+ // enter pending-outbound reconciliation.
354
+ async assertRecoveredTurnTopology(_candidate = {}) {}
355
+
356
+ // Providers that expose a single shared user/assistant order can require that
357
+ // atomic view when binding a fresh send. This prevents separately queried role
358
+ // locators from being combined across SPA hydration frames.
359
+ requiresOrderedConversationSnapshotForFreshSend() {
360
+ return false;
361
+ }
362
+
363
+ supportsPendingOutboundRecovery() {
364
+ return false;
365
+ }
366
+
367
+ async reconcilePendingOutbound() {
368
+ throw new BrowserAdapterError(
369
+ `${this.providerName} does not support pending outbound recovery.`,
370
+ { code: "OUTBOUND_RECOVERY_UNSUPPORTED", recoverable: false },
371
+ );
372
+ }
373
+
190
374
  // Returns a short human string when the message is a plan/usage-limit notice,
191
375
  // or null when it is an ordinary reply. Default: never a limit.
192
376
  async findUsageLimitMarker(_message) {
@@ -230,17 +414,22 @@ export class BaseWebAdapter {
230
414
  // Submit the already-filled composer. Providers that need a native pointer
231
415
  // sequence may override this while retaining the shared post-send identity
232
416
  // checks and one-shot retry.
233
- async submitComposer(composer) {
417
+ async submitComposer(composer, { assertActive = () => {}, timeoutMs } = {}) {
418
+ assertActive();
234
419
  const sendButton = await firstVisible(this.sendButtonLocators());
420
+ assertActive();
235
421
  if (sendButton && await sendButton.isEnabled().catch(() => false)) {
236
422
  try {
237
- await sendButton.click();
423
+ assertActive();
424
+ await sendButton.click(timeoutMs == null ? {} : { timeout: timeoutMs });
238
425
  return;
239
426
  } catch {
427
+ assertActive();
240
428
  await this.dismissTransientOverlays();
241
429
  }
242
430
  }
243
- await composer.press("Enter");
431
+ assertActive();
432
+ await composer.press("Enter", timeoutMs == null ? {} : { timeout: timeoutMs });
244
433
  }
245
434
 
246
435
  // Scroll a virtualized thread so the latest replies mount. No-op by default.
@@ -282,11 +471,72 @@ export class BaseWebAdapter {
282
471
  return this.hasReliableCompletionSignal() ? 10_000 : 90_000;
283
472
  }
284
473
 
285
- // How long sendMessage() waits for the user bubble to appear. GLM can take
286
- // several seconds to commit a filled textarea, especially after a long tool
287
- // result, so providers may widen this.
288
- sentUserWaitAttempts() {
289
- return 50;
474
+ // One monotonic deadline covers submit, URL allocation, user-bubble discovery,
475
+ // and causal stabilization. Providers may widen it for unusually slow commits.
476
+ sendConfirmationTimeoutMs() {
477
+ return 20_000;
478
+ }
479
+
480
+ monotonicNow() {
481
+ return performance.now();
482
+ }
483
+
484
+ async waitForPoll(ms) {
485
+ await this.page.waitForTimeout(ms);
486
+ }
487
+
488
+ #sendDeadlineError() {
489
+ return new BrowserAdapterError(
490
+ `${this.providerName} did not confirm the submitted message before the causal deadline. `
491
+ + "It was not sent again automatically.",
492
+ {
493
+ code: "SEND_COMMIT_UNKNOWN",
494
+ recoverable: false,
495
+ details: this.#sendDiagnosticDetails(),
496
+ },
497
+ );
498
+ }
499
+
500
+ #sendDiagnosticDetails() {
501
+ const proof = this.activeSendProof;
502
+ return {
503
+ stage: proof?.stage ?? "unknown",
504
+ sendStatus: this.lastSendStatus,
505
+ elapsedMs: proof?.submittedAt == null
506
+ ? null : Math.round(this.monotonicNow() - proof.submittedAt),
507
+ ...(proof?.sample ?? {}),
508
+ };
509
+ }
510
+
511
+ async #awaitWithinSendDeadline(promise, deadline) {
512
+ const remaining = deadline - this.monotonicNow();
513
+ if (remaining <= 0) {
514
+ throw this.#sendDeadlineError();
515
+ }
516
+ let timer;
517
+ try {
518
+ const result = await Promise.race([
519
+ Promise.resolve(promise),
520
+ new Promise((_, reject) => {
521
+ timer = setTimeout(
522
+ () => reject(this.#sendDeadlineError()),
523
+ Math.max(1, Math.ceil(remaining)),
524
+ );
525
+ }),
526
+ ]);
527
+ if (this.monotonicNow() >= deadline) {
528
+ throw this.#sendDeadlineError();
529
+ }
530
+ return result;
531
+ } finally {
532
+ clearTimeout(timer);
533
+ }
534
+ }
535
+
536
+ // Real elapsed time required before a URL/DOM correlation becomes trusted.
537
+ // Kept overridable only so deterministic adapter tests need not sleep.
538
+ restorationCorrelationWindowMs() {
539
+ return RESTORATION_STABLE_WINDOW_MS;
290
540
  }
291
541
 
292
542
  // Decide whether `candidate` is a genuinely new assistant reply for the
@@ -319,35 +569,983 @@ export class BaseWebAdapter {
319
569
  return 1;
320
570
  }
321
571
 
572
+ acceptRecoveredOutboundAnchor({
573
+ conversationUrl,
574
+ conversationTargetId,
575
+ userMessageId,
576
+ userTurn = null,
577
+ assistantBaseline = null,
578
+ assistantCandidateMessageId = null,
579
+ assistantCandidateTurn = null,
580
+ }) {
581
+ const normalizedUrl = normalizedConversationUrl(conversationUrl);
582
+ const kind = normalizedUrl
583
+ ? this.classifyConversationUrl(normalizedUrl)
584
+ : "invalid";
585
+ if (
586
+ !this.page
587
+ || !["restorable", "provisional"].includes(kind)
588
+ || !sameConversationUrl(this.page.url(), normalizedUrl)
589
+ || (conversationTargetId ?? null) !== (this.cdpChrome?.targetId ?? null)
590
+ ) {
591
+ throw new BrowserAdapterError(
592
+ `${this.providerName} recovery anchor no longer matches the exact browser target.`,
593
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
594
+ );
595
+ }
596
+
597
+ const baselineIds = Array.isArray(assistantBaseline?.ids)
598
+ ? assistantBaseline.ids.filter(Boolean)
599
+ : [];
600
+ this.#beginConversationObservation(normalizedUrl, {
601
+ arm: false,
602
+ reset: true,
603
+ });
604
+ this.assistantIdsBeforeSend = new Set(baselineIds);
605
+ this.assistantMaxTurnBeforeSend = Number.isSafeInteger(
606
+ assistantBaseline?.maxTurn,
607
+ )
608
+ ? assistantBaseline.maxTurn
609
+ : null;
610
+ this.assistantCountBeforeSend = Number.isSafeInteger(
611
+ assistantBaseline?.count,
612
+ )
613
+ ? assistantBaseline.count
614
+ : baselineIds.length;
615
+ this.lastAssistantTextBeforeSend = String(
616
+ assistantBaseline?.lastText ?? "",
617
+ );
618
+ this.sentUserTurn = Number.isSafeInteger(userTurn) ? userTurn : null;
619
+ this.lastUserMessageId = userMessageId ?? null;
620
+ this.expectedRestorationUserId = userMessageId ?? null;
621
+ this.expectedRestorationAssistantId = baselineIds.at(-1) ?? null;
622
+ this.expectedAssistantCandidateId = typeof assistantCandidateMessageId === "string"
623
+ && assistantCandidateMessageId
624
+ ? assistantCandidateMessageId
625
+ : null;
626
+ this.expectedAssistantCandidateTurn = Number.isSafeInteger(
627
+ assistantCandidateTurn,
628
+ )
629
+ ? assistantCandidateTurn
630
+ : null;
631
+ this.recoveredTurnTopologyRequired = true;
632
+ this.#captureActiveTurnProof(normalizedUrl);
633
+ return this.#currentConversationIdentity();
634
+ }
635
+
636
+ setConversationIdentityListener(listener) {
637
+ this.conversationIdentityListener = typeof listener === "function"
638
+ ? listener
639
+ : null;
640
+ }
641
+
642
+ async getConversationIdentity() {
643
+ const currentUrl = this.page?.url?.() ?? null;
644
+ if (
645
+ currentUrl
646
+ && normalizedConversationUrl(currentUrl) !== this.lastObservedPageUrl
647
+ ) {
648
+ this.#observeConversationNavigation(currentUrl);
649
+ }
650
+ await this.#verifyPendingCanonicalUrl();
651
+ return this.#currentConversationIdentity();
652
+ }
653
+
654
+ #currentConversationIdentity() {
655
+ const currentUrl = this.page?.url?.() ?? null;
656
+ const currentKind = currentUrl
657
+ ? this.classifyConversationUrl(currentUrl)
658
+ : "invalid";
659
+ const normalizedCurrent = normalizedConversationUrl(currentUrl);
660
+ const currentIsTrusted = normalizedCurrent != null && (
661
+ this.observedConversationAliases.has(normalizedCurrent)
662
+ || (
663
+ currentKind === "fresh"
664
+ && normalizedCurrent === this.observedConversationUrl
665
+ )
666
+ );
667
+ const conversationUrl = currentIsTrusted
668
+ ? normalizedCurrent
669
+ : this.observedConversationUrl;
670
+ return {
671
+ conversationUrl: conversationUrl ?? null,
672
+ targetId: this.cdpChrome?.targetId ?? null,
673
+ kind: conversationUrl
674
+ ? this.classifyConversationUrl(conversationUrl)
675
+ : "invalid",
676
+ };
677
+ }
678
+
679
+ #isObservedConversationAlias(value) {
680
+ const normalized = normalizedConversationUrl(value);
681
+ return normalized != null && this.observedConversationAliases.has(normalized);
682
+ }
683
+
684
+ #activeSendProofIsIntact() {
685
+ const proof = this.activeSendProof;
686
+ if (!proof) {
687
+ return true;
688
+ }
689
+ if (
690
+ proof.invalidated
691
+ || proof.sendEpoch !== this.sendEpoch
692
+ || proof.page !== this.page
693
+ ) {
694
+ return false;
695
+ }
696
+ if (
697
+ proof.mainFrame
698
+ && typeof this.page?.mainFrame === "function"
699
+ && this.page.mainFrame() !== proof.mainFrame
700
+ ) {
701
+ proof.invalidated = true;
702
+ return false;
703
+ }
704
+ if ((proof.targetId ?? null) !== (this.cdpChrome?.targetId ?? null)) {
705
+ proof.invalidated = true;
706
+ return false;
707
+ }
708
+ if (
709
+ proof.currentUrl
710
+ && !sameConversationUrl(this.page?.url?.(), proof.currentUrl)
711
+ ) {
712
+ // Polling may observe a URL before Playwright dispatches its navigation
713
+ // event. It can invalidate a proof, but it must never nominate a route.
714
+ proof.invalidated = true;
715
+ return false;
716
+ }
717
+ return true;
718
+ }
719
+
720
+ #captureActiveTurnProof(conversationUrl, {
721
+ outboundId = null,
722
+ userMessageId = null,
723
+ userTurn = null,
724
+ } = {}) {
725
+ const normalizedUrl = normalizedConversationUrl(conversationUrl);
726
+ this.activeTurnProof = {
727
+ page: this.page,
728
+ mainFrame: typeof this.page?.mainFrame === "function"
729
+ ? this.page.mainFrame()
730
+ : null,
731
+ targetId: this.cdpChrome?.targetId ?? null,
732
+ scopeEpoch: this.conversationScopeEpoch,
733
+ conversationUrl: normalizedUrl,
734
+ outboundId,
735
+ userMessageId,
736
+ userTurn,
737
+ invalidated: false,
738
+ };
739
+ this.activeTurnConversationUrl = normalizedUrl;
740
+ }
741
+
742
+ #activeTurnProofIsIntact({ allowPendingCanonicalUrl = false } = {}) {
743
+ const proof = this.activeTurnProof;
744
+ if (!proof) {
745
+ return this.activeTurnConversationUrl == null;
746
+ }
747
+ const currentUrl = normalizedConversationUrl(this.page?.url?.());
748
+ if (
749
+ proof.invalidated
750
+ || proof.page !== this.page
751
+ || proof.scopeEpoch !== this.conversationScopeEpoch
752
+ || (proof.targetId ?? null) !== (this.cdpChrome?.targetId ?? null)
753
+ || (
754
+ proof.mainFrame
755
+ && typeof this.page?.mainFrame === "function"
756
+ && this.page.mainFrame() !== proof.mainFrame
757
+ )
758
+ || (
759
+ !sameConversationUrl(currentUrl, proof.conversationUrl)
760
+ && !this.#isObservedConversationAlias(currentUrl)
761
+ && !(
762
+ allowPendingCanonicalUrl
763
+ && sameConversationUrl(currentUrl, this.pendingCanonicalUrl)
764
+ )
765
+ )
766
+ ) {
767
+ proof.invalidated = true;
768
+ return false;
769
+ }
770
+ return true;
771
+ }
772
+
773
+ async #validateRecoveredTurnBoundary(candidate = {}) {
774
+ if (!this.recoveredTurnTopologyRequired) {
775
+ return;
776
+ }
777
+ const assertProof = () => {
778
+ if (!this.#activeTurnProofIsIntact()) {
779
+ throw new BrowserAdapterError(
780
+ `${this.providerName} recovery boundary left its exact browser target. `
781
+ + "No content from the changed conversation was accepted.",
782
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
783
+ );
784
+ }
785
+ };
786
+ assertProof();
787
+ await this.assertRecoveredTurnTopology(candidate);
788
+ assertProof();
789
+ }
790
+
791
+ #recordActiveSendCandidate(value, {
792
+ previousUrl = null,
793
+ allowCurrentNavigation = false,
794
+ } = {}) {
795
+ const proof = this.activeSendProof;
796
+ if (!proof) {
797
+ return true;
798
+ }
799
+ const candidate = normalizedConversationUrl(value);
800
+ if (
801
+ !candidate
802
+ || proof.page !== this.page
803
+ || proof.sendEpoch !== this.sendEpoch
804
+ || (proof.invalidated && !allowCurrentNavigation)
805
+ || !sameConversationUrl(previousUrl, proof.currentUrl)
806
+ ) {
807
+ return false;
808
+ }
809
+ proof.invalidated = false;
810
+ proof.currentUrl = candidate;
811
+ proof.candidateUrl = candidate;
812
+ proof.candidateNavigationEpoch = this.navigationEpoch;
813
+ proof.candidateBeforeSubmission = Boolean(
814
+ proof.candidateBeforeSubmission || !proof.submissionStarted,
815
+ );
816
+ return true;
817
+ }
818
+
819
+ #resetPendingCanonicalVerification({ clearCandidate = false } = {}) {
820
+ this.pendingCanonicalSignature = null;
821
+ this.pendingCanonicalStableSince = 0;
822
+ if (clearCandidate) {
823
+ this.pendingCanonicalUrl = null;
824
+ }
825
+ }
826
+
827
+ #stageCanonicalCandidate(value) {
828
+ const normalized = normalizedConversationUrl(value);
829
+ if (!normalized) {
830
+ return false;
831
+ }
832
+ if (!sameConversationUrl(this.pendingCanonicalUrl, normalized)) {
833
+ this.#resetPendingCanonicalVerification();
834
+ }
835
+ this.pendingCanonicalUrl = normalized;
836
+ return true;
837
+ }
838
+
839
+ async #recordPendingFreshCandidateEmpty(value) {
840
+ const candidate = normalizedConversationUrl(value);
841
+ if (
842
+ !candidate
843
+ || !sameConversationUrl(this.page?.url?.(), candidate)
844
+ || !this.pendingFreshConversationAliases.has(candidate)
845
+ ) {
846
+ return false;
847
+ }
848
+ if (await this.conversationMessages().count().catch(() => -1) !== 0) {
849
+ this.pendingFreshEmptyAliases.set(candidate, {
850
+ stableSince: 0,
851
+ verified: false,
852
+ poisoned: true,
853
+ });
854
+ return false;
855
+ }
856
+ if (
857
+ !sameConversationUrl(this.page?.url?.(), candidate)
858
+ || !this.pendingFreshConversationAliases.has(candidate)
859
+ ) {
860
+ this.pendingFreshEmptyAliases.delete(candidate);
861
+ return false;
862
+ }
863
+
864
+ const observedAt = this.monotonicNow();
865
+ const previous = this.pendingFreshEmptyAliases.get(candidate);
866
+ const state = previous ?? {
867
+ stableSince: observedAt,
868
+ verified: false,
869
+ poisoned: false,
870
+ };
871
+ if (state.poisoned) {
872
+ return false;
873
+ }
874
+ state.verified = state.verified
875
+ || observedAt - state.stableSince >= this.restorationCorrelationWindowMs();
876
+ this.pendingFreshEmptyAliases.set(candidate, state);
877
+ return state.verified;
878
+ }
879
+
880
+ async #waitForStablePendingFreshCandidate(candidate, {
881
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
882
+ } = {}) {
883
+ while (this.monotonicNow() < deadline) {
884
+ if (await this.#recordPendingFreshCandidateEmpty(candidate)) {
885
+ return true;
886
+ }
887
+ if (
888
+ !sameConversationUrl(this.page?.url?.(), candidate)
889
+ || !this.pendingFreshConversationAliases.has(candidate)
890
+ || this.pendingFreshEmptyAliases.get(candidate)?.poisoned
891
+ ) {
892
+ return false;
893
+ }
894
+ await this.waitForPoll(Math.min(250, deadline - this.monotonicNow()));
895
+ }
896
+ return false;
897
+ }
898
+
899
+ #verifyPendingCanonicalUrl() {
900
+ const operation = this.pendingCanonicalVerificationQueue
901
+ .catch(() => false)
902
+ .then(() => this.#verifyPendingCanonicalUrlNow());
903
+ this.pendingCanonicalVerificationQueue = operation;
904
+ return operation;
905
+ }
906
+
907
+ async #verifyPendingCanonicalUrlNow() {
908
+ const candidate = this.pendingCanonicalUrl;
909
+ const candidateKind = candidate
910
+ ? this.classifyConversationUrl(candidate)
911
+ : "invalid";
912
+ if (
913
+ !candidate
914
+ || !sameConversationUrl(this.page?.url?.(), candidate)
915
+ || !["restorable", "provisional"].includes(candidateKind)
916
+ ) {
917
+ this.#resetPendingCanonicalVerification();
918
+ return false;
919
+ }
920
+ const [assistant, user] = await Promise.all([
921
+ this.#captureMessageIdentities(this.assistantMessages()),
922
+ this.#captureMessageIdentities(this.userMessages()),
923
+ ]);
924
+ const assistantMatched = Boolean(
925
+ this.expectedRestorationAssistantId
926
+ && assistant.ids.has(this.expectedRestorationAssistantId)
927
+ );
928
+ const userMatched = Boolean(
929
+ this.expectedRestorationUserId
930
+ && user.ids.has(this.expectedRestorationUserId)
931
+ );
932
+ let currentSendUserMatched = Boolean(
933
+ this.freshSendConfirmed
934
+ && this.lastUserMessageId
935
+ && user.ids.has(this.lastUserMessageId)
936
+ );
937
+ let currentSendMarker = this.lastUserMessageId;
938
+ if (
939
+ !currentSendUserMatched
940
+ && this.freshSendConfirmed
941
+ && isValidOutboundCorrelationId(this.activeTurnProof?.outboundId)
942
+ ) {
943
+ const messages = this.userMessages();
944
+ const count = await messages.count().catch(() => 0);
945
+ for (let index = count - 1; index >= 0; index -= 1) {
946
+ const message = messages.nth(index);
947
+ const identity = await this.messageIdentity(message);
948
+ if (
949
+ this.activeTurnProof.userTurn != null
950
+ && identity.turn != null
951
+ && identity.turn !== this.activeTurnProof.userTurn
952
+ ) {
953
+ continue;
954
+ }
955
+ const renderedText = await this.userMessageText(message);
956
+ if (!renderedMessageContainsOutboundMarker(
957
+ renderedText,
958
+ this.activeTurnProof.outboundId,
959
+ )) {
960
+ continue;
961
+ }
962
+ currentSendUserMatched = true;
963
+ currentSendMarker = identity.id
964
+ ?? `turn:${identity.turn ?? this.activeTurnProof.userTurn ?? "unknown"}`;
965
+ if (identity.id) {
966
+ this.lastUserMessageId = identity.id;
967
+ this.expectedRestorationUserId = identity.id;
968
+ this.activeTurnProof.userMessageId = identity.id;
969
+ }
970
+ break;
971
+ }
972
+ }
973
+ if (!assistantMatched && !userMatched && !currentSendUserMatched) {
974
+ this.#resetPendingCanonicalVerification();
975
+ return false;
976
+ }
977
+
978
+ const matchedMarker = currentSendUserMatched
979
+ ? `user:${currentSendMarker}`
980
+ : assistantMatched
981
+ ? `assistant:${this.expectedRestorationAssistantId}`
982
+ : `user:${this.expectedRestorationUserId}`;
983
+ const signature = [
984
+ candidate,
985
+ candidateKind,
986
+ this.cdpChrome?.targetId ?? "",
987
+ matchedMarker,
988
+ ].join(":");
989
+ const observedAt = this.monotonicNow();
990
+ if (signature !== this.pendingCanonicalSignature) {
991
+ this.pendingCanonicalSignature = signature;
992
+ this.pendingCanonicalStableSince = observedAt;
993
+ return false;
994
+ }
995
+ if (
996
+ observedAt - this.pendingCanonicalStableSince
997
+ < this.restorationCorrelationWindowMs()
998
+ ) {
999
+ return false;
1000
+ }
1001
+ if (
1002
+ !sameConversationUrl(this.pendingCanonicalUrl, candidate)
1003
+ || !sameConversationUrl(this.page?.url?.(), candidate)
1004
+ ) {
1005
+ this.#resetPendingCanonicalVerification();
1006
+ return false;
1007
+ }
1008
+
1009
+ this.#trustConversationUrl(candidate);
1010
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1011
+ this.#publishConversationIdentity();
1012
+ return true;
1013
+ }
1014
+
1015
+ #publishConversationIdentity() {
1016
+ const identity = this.#currentConversationIdentity();
1017
+ if (
1018
+ !identity.conversationUrl
1019
+ || !["restorable", "provisional"].includes(identity.kind)
1020
+ ) {
1021
+ return;
1022
+ }
1023
+ Promise.resolve(this.conversationIdentityListener?.(identity)).catch(() => {});
1024
+ }
1025
+
1026
+ #trustConversationUrl(value, { notify = false } = {}) {
1027
+ const normalized = normalizedConversationUrl(value);
1028
+ if (!normalized) {
1029
+ return false;
1030
+ }
1031
+ const kind = this.classifyConversationUrl(normalized);
1032
+ if (!["fresh", "restorable", "provisional"].includes(kind)) {
1033
+ return false;
1034
+ }
1035
+ this.observedConversationUrl = normalized;
1036
+ this.lastObservedPageUrl = normalized;
1037
+ if (["restorable", "provisional"].includes(kind)) {
1038
+ this.observedConversationAliases.add(normalized);
1039
+ }
1040
+ if (notify) {
1041
+ this.#publishConversationIdentity();
1042
+ }
1043
+ return true;
1044
+ }
1045
+
1046
+ #observeConversationNavigation(value, { navigationEvent = false } = {}) {
1047
+ const normalized = normalizedConversationUrl(value);
1048
+ if (normalized) this.recordConversationNavigation(normalized);
1049
+ if (!normalized) {
1050
+ if (this.activeSendProof) {
1051
+ this.activeSendProof.invalidated = true;
1052
+ }
1053
+ return false;
1054
+ }
1055
+ const kind = this.classifyConversationUrl(normalized);
1056
+ const previous = this.lastObservedPageUrl;
1057
+ const previousKind = previous
1058
+ ? this.classifyConversationUrl(previous)
1059
+ : "invalid";
1060
+ const proofWasInvalidated = Boolean(this.activeSendProof?.invalidated);
1061
+ if (
1062
+ this.activeSendProof
1063
+ && !navigationEvent
1064
+ && !sameConversationUrl(normalized, this.activeSendProof.currentUrl)
1065
+ ) {
1066
+ // A polled URL change is not causal evidence. The matching top-frame event
1067
+ // must be observed first for this send epoch.
1068
+ this.activeSendProof.invalidated = true;
1069
+ }
1070
+ if (
1071
+ navigationEvent
1072
+ && this.activeSendProof
1073
+ && !sameConversationUrl(normalized, this.activeSendProof.currentUrl)
1074
+ ) {
1075
+ // Every top-frame navigation invalidates the current proof until this exact
1076
+ // conversation change is classified as an allowed causal transition below.
1077
+ // Providers can emit several history events for one route (for example
1078
+ // adding a response-id query parameter); those retain the same normalized
1079
+ // conversation identity and must not destroy an otherwise intact proof.
1080
+ // A prior bad transition is permanent; a later plausible route cannot
1081
+ // rehabilitate it.
1082
+ this.activeSendProof.invalidated = true;
1083
+ }
1084
+ this.lastObservedPageUrl = normalized;
1085
+
1086
+ if (this.#isObservedConversationAlias(normalized)) {
1087
+ this.observedConversationUrl = normalized;
1088
+ this.#publishConversationIdentity();
1089
+ return true;
1090
+ }
1091
+ const validNewConversation = navigationEvent
1092
+ && this.conversationObservationArmed
1093
+ && previousKind === "fresh"
1094
+ && ["provisional", "restorable"].includes(kind);
1095
+ const pendingCanonicalTransition = navigationEvent
1096
+ && previousKind === "provisional"
1097
+ && this.pendingFreshConversationAliases.has(previous)
1098
+ && kind === "restorable";
1099
+ const validCanonicalTransition = navigationEvent
1100
+ && previousKind === "provisional"
1101
+ && this.#isObservedConversationAlias(previous)
1102
+ && kind === "restorable";
1103
+ const activeTransitionCandidate = validNewConversation
1104
+ || pendingCanonicalTransition
1105
+ || validCanonicalTransition;
1106
+ const acceptedForActiveSend = !this.activeSendProof
1107
+ || (
1108
+ activeTransitionCandidate
1109
+ && this.#recordActiveSendCandidate(normalized, {
1110
+ previousUrl: previous,
1111
+ allowCurrentNavigation: navigationEvent && !proofWasInvalidated,
1112
+ })
1113
+ );
1114
+
1115
+ if (
1116
+ (validNewConversation || pendingCanonicalTransition)
1117
+ && acceptedForActiveSend
1118
+ ) {
1119
+ this.pendingFreshConversationAliases.add(normalized);
1120
+ if (
1121
+ validNewConversation
1122
+ && this.activeSendProof?.candidateBeforeSubmission
1123
+ ) {
1124
+ void this.#recordPendingFreshCandidateEmpty(normalized).catch(() => null);
1125
+ }
1126
+ if (!this.freshSendConfirmed) {
1127
+ // Do not trust or publish a fresh-page transition before a new user bubble
1128
+ // proves this send. File upload can legitimately allocate /c/WEB: early,
1129
+ // while a user switching chats must remain untrusted.
1130
+ return false;
1131
+ }
1132
+ this.#stageCanonicalCandidate(normalized);
1133
+ return false;
1134
+ }
1135
+ if (validCanonicalTransition && acceptedForActiveSend) {
1136
+ // A top-frame event proves page continuity, not conversation identity: the
1137
+ // user could have navigated this tab. Promote only after a saved message id
1138
+ // is found on the candidate page.
1139
+ this.#stageCanonicalCandidate(normalized);
1140
+ return false;
1141
+ }
1142
+ return false;
1143
+ }
1144
+
1145
+ #beginConversationObservation(seedUrl, { arm = false, reset = false } = {}) {
1146
+ if (reset) {
1147
+ this.conversationScopeEpoch += 1;
1148
+ this.activeTurnProof = null;
1149
+ this.observedConversationAliases.clear();
1150
+ this.pendingFreshConversationAliases.clear();
1151
+ this.pendingFreshEmptyAliases.clear();
1152
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1153
+ this.observedConversationUrl = null;
1154
+ this.freshSendConfirmed = false;
1155
+ }
1156
+ if (seedUrl) {
1157
+ this.#trustConversationUrl(seedUrl);
1158
+ }
1159
+ if (arm) {
1160
+ this.pendingFreshConversationAliases.clear();
1161
+ this.pendingFreshEmptyAliases.clear();
1162
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1163
+ this.freshSendConfirmed = false;
1164
+ }
1165
+ this.conversationObservationArmed = arm;
1166
+ if (this.pageNavigationListener || typeof this.page?.on !== "function") {
1167
+ return;
1168
+ }
1169
+ this.pageNavigationListener = (frame) => {
1170
+ try {
1171
+ if (
1172
+ typeof this.page.mainFrame === "function"
1173
+ && frame !== this.page.mainFrame()
1174
+ ) {
1175
+ return;
1176
+ }
1177
+ this.navigationEpoch += 1;
1178
+ this.#observeConversationNavigation(
1179
+ frame?.url?.() ?? this.page.url(),
1180
+ { navigationEvent: true },
1181
+ );
1182
+ void this.#verifyPendingCanonicalUrl().catch(() => null);
1183
+ } catch {
1184
+ // The transport may disappear while Playwright dispatches navigation.
1185
+ }
1186
+ };
1187
+ this.page.on("framenavigated", this.pageNavigationListener);
1188
+ }
1189
+
1190
+ async #assertTrustedPageForSend(expectedUrl, {
1191
+ allowPendingFreshTransition = false,
1192
+ } = {}) {
1193
+ const currentUrl = normalizedConversationUrl(this.page.url());
1194
+ const turnBindingTrusted = !this.activeTurnConversationUrl
1195
+ || this.#activeTurnProofIsIntact();
1196
+ const directlyTrusted = turnBindingTrusted
1197
+ && this.#activeSendProofIsIntact()
1198
+ && (
1199
+ sameConversationUrl(currentUrl, expectedUrl)
1200
+ || this.#isObservedConversationAlias(currentUrl)
1201
+ );
1202
+ if (directlyTrusted) {
1203
+ return currentUrl;
1204
+ }
1205
+ if (
1206
+ allowPendingFreshTransition
1207
+ && turnBindingTrusted
1208
+ && this.#activeSendProofIsIntact()
1209
+ && this.pendingFreshConversationAliases.has(currentUrl)
1210
+ && await this.#waitForStablePendingFreshCandidate(currentUrl)
1211
+ && this.#activeSendProofIsIntact()
1212
+ ) {
1213
+ return currentUrl;
1214
+ }
1215
+ await this.writeDiagnostics("conversation-changed-before-send");
1216
+ throw new BrowserAdapterError(
1217
+ `${this.providerName} left the verified conversation before the message was submitted. `
1218
+ + "Nothing was sent.",
1219
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
1220
+ );
1221
+ }
1222
+
1223
+ #isTrustedPageUrl(value) {
1224
+ const normalized = normalizedConversationUrl(value);
1225
+ const kind = normalized
1226
+ ? this.classifyConversationUrl(normalized)
1227
+ : "invalid";
1228
+ return Boolean(
1229
+ normalized
1230
+ && (
1231
+ this.#isObservedConversationAlias(normalized)
1232
+ || (
1233
+ kind === "fresh"
1234
+ && sameConversationUrl(normalized, this.observedConversationUrl)
1235
+ )
1236
+ )
1237
+ );
1238
+ }
1239
+
1240
+ async #submittedPageState({ allowPendingFreshTransition = false } = {}) {
1241
+ const currentUrl = normalizedConversationUrl(this.page.url());
1242
+ if (!this.#activeSendProofIsIntact()) {
1243
+ return { status: "untrusted", conversationUrl: currentUrl };
1244
+ }
1245
+ if (sameConversationUrl(currentUrl, this.pendingCanonicalUrl)) {
1246
+ await this.#verifyPendingCanonicalUrl();
1247
+ }
1248
+ if (this.#isTrustedPageUrl(currentUrl)) {
1249
+ return { status: "trusted", conversationUrl: currentUrl };
1250
+ }
1251
+ if (
1252
+ sameConversationUrl(currentUrl, this.pendingCanonicalUrl)
1253
+ || (
1254
+ allowPendingFreshTransition
1255
+ && this.pendingFreshConversationAliases.has(currentUrl)
1256
+ )
1257
+ ) {
1258
+ return { status: "candidate", conversationUrl: currentUrl };
1259
+ }
1260
+ return { status: "untrusted", conversationUrl: currentUrl };
1261
+ }
1262
+
1263
+ async #waitForCanonicalCandidate(candidate, {
1264
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
1265
+ } = {}) {
1266
+ while (this.monotonicNow() < deadline) {
1267
+ const state = await this.#awaitWithinSendDeadline(
1268
+ this.#submittedPageState(),
1269
+ deadline,
1270
+ );
1271
+ if (state.status === "trusted") {
1272
+ return state.conversationUrl;
1273
+ }
1274
+ if (
1275
+ state.status !== "candidate"
1276
+ || !sameConversationUrl(state.conversationUrl, candidate)
1277
+ ) {
1278
+ return null;
1279
+ }
1280
+ const remaining = deadline - this.monotonicNow();
1281
+ if (remaining <= 0) {
1282
+ break;
1283
+ }
1284
+ await this.#awaitWithinSendDeadline(
1285
+ this.waitForPoll(Math.min(250, remaining)),
1286
+ deadline,
1287
+ );
1288
+ }
1289
+ return null;
1290
+ }
1291
+
1292
+ #confirmFreshConversationSend() {
1293
+ this.freshSendConfirmed = true;
1294
+ const currentUrl = normalizedConversationUrl(this.page.url());
1295
+ const currentKind = currentUrl
1296
+ ? this.classifyConversationUrl(currentUrl)
1297
+ : "invalid";
1298
+ if (
1299
+ !["restorable", "provisional"].includes(currentKind)
1300
+ || !this.pendingFreshConversationAliases.has(currentUrl)
1301
+ ) {
1302
+ // Some providers keep the explicit fresh route until later in generation.
1303
+ // Leave observation armed; the confirmed user bubble now makes a later
1304
+ // fresh -> conversation transition eligible for marker verification.
1305
+ return;
1306
+ }
1307
+ // Only the page that contains this send's confirmed user bubble is trusted.
1308
+ // Earlier candidates may have been opened manually and must not become aliases.
1309
+ this.pendingFreshConversationAliases.clear();
1310
+ this.pendingFreshEmptyAliases.clear();
1311
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1312
+ this.#trustConversationUrl(currentUrl, { notify: true });
1313
+ if (currentKind === "restorable") {
1314
+ this.conversationObservationArmed = false;
1315
+ }
1316
+ }
1317
+
1318
+ #dropPageState({ clearObservation = true } = {}) {
1319
+ if (this.activeSendProof) {
1320
+ this.activeSendProof.invalidated = true;
1321
+ this.activeSendProof = null;
1322
+ }
1323
+ if (this.activeTurnProof) {
1324
+ this.activeTurnProof.invalidated = true;
1325
+ this.activeTurnProof = null;
1326
+ }
1327
+ if (this.pageNavigationListener && typeof this.page?.off === "function") {
1328
+ this.page.off("framenavigated", this.pageNavigationListener);
1329
+ }
1330
+ this.cdpChrome = null;
1331
+ this.context = null;
1332
+ this.page = null;
1333
+ this.preservedPreferredUrl = null;
1334
+ this.pageNavigationListener = null;
1335
+ this.preferredTabAmbiguous = false;
1336
+ this.conversationObservationArmed = false;
1337
+ this.freshSendConfirmed = false;
1338
+ this.pendingFreshConversationAliases.clear();
1339
+ this.pendingFreshEmptyAliases.clear();
1340
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
1341
+ this.expectedRestorationAssistantId = null;
1342
+ this.expectedRestorationUserId = null;
1343
+ this.expectedAssistantCandidateId = null;
1344
+ this.expectedAssistantCandidateTurn = null;
1345
+ this.recoveredTurnTopologyRequired = false;
1346
+ this.lastObservedPageUrl = null;
1347
+ if (clearObservation) {
1348
+ this.activeTurnConversationUrl = null;
1349
+ this.allowInPlaceAssistantContinuation = false;
1350
+ this.inPlaceAssistantGenerationObserved = false;
1351
+ this.observedConversationUrl = null;
1352
+ this.observedConversationAliases.clear();
1353
+ }
1354
+ }
1355
+
322
1356
  // ---- lifecycle ---------------------------------------------------------
323
1357
 
1358
+ // Kept overridable so lifecycle tests can supply a deterministic CDP
1359
+ // connection without launching a real browser.
1360
+ async launchCdpChrome(options) {
1361
+ return await launchAndConnectCdpChrome(options);
1362
+ }
1363
+
1364
+ hasExactRecoveryTarget(targetId) {
1365
+ return Boolean(
1366
+ targetId
1367
+ && this.cdpChrome?.exactTargetOnly === true
1368
+ && this.cdpChrome.targetId === targetId
1369
+ && this.context
1370
+ && this.page,
1371
+ );
1372
+ }
1373
+
1374
+ // Attach only the exact already-open Page used by an ambiguous outbound. This
1375
+ // recovery path never creates or navigates a target and never launches Chrome.
1376
+ async launchRecoveryTarget(targetId) {
1377
+ if (typeof targetId !== "string" || targetId.length === 0) {
1378
+ throw new BrowserAdapterError(
1379
+ "Pending outbound recovery requires a saved browser target ID.",
1380
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1381
+ );
1382
+ }
1383
+ if (this.context) {
1384
+ if (this.hasExactRecoveryTarget(targetId)) {
1385
+ return {
1386
+ conversationUrl: this.page.url(),
1387
+ targetId,
1388
+ };
1389
+ }
1390
+ if (this.cdpChrome?.targetId !== targetId || !this.page) {
1391
+ throw new BrowserAdapterError(
1392
+ "A different browser target is already attached; refusing recovery fallback.",
1393
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1394
+ );
1395
+ }
1396
+ if (typeof this.cdpChrome.detach !== "function") {
1397
+ throw new BrowserAdapterError(
1398
+ "The current browser transport cannot be safely promoted to exact-target recovery.",
1399
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1400
+ );
1401
+ }
1402
+ // A same-process /retry can still own a normal CDP connection. Release its
1403
+ // transport and profile lock, then reacquire the saved target through the
1404
+ // strict path so close() cannot terminate Chrome or remove recovery state.
1405
+ try {
1406
+ await this.cdpChrome.detach();
1407
+ } catch (error) {
1408
+ throw new BrowserAdapterError(
1409
+ "The current browser transport could not be detached for exact-target recovery.",
1410
+ {
1411
+ code: "RECOVERY_TARGET_UNAVAILABLE",
1412
+ recoverable: false,
1413
+ cause: error,
1414
+ },
1415
+ );
1416
+ }
1417
+ this.#dropPageState();
1418
+ }
1419
+
1420
+ await ensureDirectory(this.profileDir);
1421
+ let connection = null;
1422
+ try {
1423
+ connection = await this.launchCdpChrome({
1424
+ profileDir: this.profileDir,
1425
+ preferredTargetId: targetId,
1426
+ exactTargetOnly: true,
1427
+ });
1428
+ if (
1429
+ connection?.exactTargetOnly !== true
1430
+ || !connection.preferredTargetMatched
1431
+ || connection.targetId !== targetId
1432
+ || !connection.page
1433
+ || !connection.context
1434
+ ) {
1435
+ throw new BrowserAdapterError(
1436
+ "Chrome did not expose the exact saved target for pending outbound recovery.",
1437
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
1438
+ );
1439
+ }
1440
+ this.cdpChrome = connection;
1441
+ this.context = connection.context;
1442
+ this.page = connection.page;
1443
+ this.page.setDefaultTimeout(15_000);
1444
+ this.page.setDefaultNavigationTimeout(60_000);
1445
+ this.preservedPreferredUrl = null;
1446
+ this.preferredTabAmbiguous = false;
1447
+ return {
1448
+ conversationUrl: this.page.url(),
1449
+ targetId,
1450
+ };
1451
+ } catch (error) {
1452
+ await connection?.detach?.().catch(() => null);
1453
+ this.#dropPageState();
1454
+ if (error instanceof BrowserAdapterError) {
1455
+ throw error;
1456
+ }
1457
+ throw new BrowserAdapterError(
1458
+ "The exact saved browser target is unavailable for recovery.",
1459
+ {
1460
+ code: error?.code === "RECOVERY_TARGET_UNAVAILABLE"
1461
+ ? error.code
1462
+ : "RECOVERY_TARGET_UNAVAILABLE",
1463
+ recoverable: false,
1464
+ cause: error,
1465
+ },
1466
+ );
1467
+ }
1468
+ }
1469
+
324
1470
  // `preferredUrl` lets a reused Chrome pick an existing tab that already
325
1471
  // shows the conversation (instead of opening a new tab per run).
326
- async launch(preferredUrl = null) {
1472
+ async launch(preferredUrl = null, { preferredTargetId = null } = {}) {
327
1473
  if (this.context) {
1474
+ // A later turn can still hold the same Page after its provisional URL was
1475
+ // replaced. Arm that page's known alias instead of navigating it to root.
1476
+ if (preferredUrl && this.#isObservedConversationAlias(preferredUrl)) {
1477
+ this.preservedPreferredUrl = preferredUrl;
1478
+ }
328
1479
  return;
329
1480
  }
330
1481
  await ensureDirectory(this.profileDir);
331
1482
  const executablePath = discoverChromeExecutable(this.chromePath);
332
1483
 
333
- this.cdpChrome = await launchAndConnectCdpChrome({
1484
+ const preferredKind = preferredUrl
1485
+ ? this.classifyConversationUrl(preferredUrl)
1486
+ : "fresh";
1487
+ const reusablePreferredUrl = ["restorable", "provisional"]
1488
+ .includes(preferredKind)
1489
+ ? String(preferredUrl)
1490
+ : null;
1491
+
1492
+ this.cdpChrome = await this.launchCdpChrome({
334
1493
  executablePath,
335
1494
  profileDir: this.profileDir,
336
1495
  minimized: this.minimized,
337
- preferredUrl,
1496
+ preferredUrl: reusablePreferredUrl,
1497
+ preferredTargetId,
338
1498
  });
339
1499
  this.context = this.cdpChrome.context;
340
1500
  this.page = this.cdpChrome.page;
341
1501
  this.page.setDefaultTimeout(15_000);
342
1502
  this.page.setDefaultNavigationTimeout(60_000);
343
- await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
1503
+
1504
+ const preservedPreferredTab = Boolean(
1505
+ this.cdpChrome.preferredTargetMatched
1506
+ || (
1507
+ reusablePreferredUrl
1508
+ && this.cdpChrome.preferredTabMatched
1509
+ ),
1510
+ );
1511
+ this.preferredTabAmbiguous = Boolean(
1512
+ this.cdpChrome.preferredTabAmbiguous,
1513
+ );
1514
+ if (
1515
+ preferredKind === "provisional"
1516
+ && !preservedPreferredTab
1517
+ && (this.cdpChrome.existingPageUrls ?? []).some((value) =>
1518
+ ["restorable", "provisional"].includes(
1519
+ this.classifyConversationUrl(value),
1520
+ )
1521
+ )
1522
+ ) {
1523
+ // A legacy session has no target id, and its provisional path may already
1524
+ // have canonicalized in one of these tabs. Do not guess which conversation
1525
+ // belongs to it, and do not rebuild while a possible original is still open.
1526
+ this.preferredTabAmbiguous = true;
1527
+ }
1528
+ this.preservedPreferredUrl = preservedPreferredTab
1529
+ ? String(preferredUrl ?? this.baseUrl)
1530
+ : null;
1531
+ if (this.preferredTabAmbiguous) {
1532
+ throw new BrowserAdapterError(
1533
+ `${this.providerName} has multiple possible tabs for this provisional conversation. `
1534
+ + "Refusing to choose one or rebuild on a blank page.",
1535
+ { code: "CONVERSATION_TAB_AMBIGUOUS", recoverable: false },
1536
+ );
1537
+ }
1538
+ if (preservedPreferredTab) {
1539
+ this.#beginConversationObservation(this.preservedPreferredUrl);
1540
+ } else {
1541
+ await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
1542
+ }
344
1543
  }
345
1544
 
1545
+
346
1546
  async close() {
347
1547
  await this.cdpChrome?.close();
348
- this.cdpChrome = null;
349
- this.context = null;
350
- this.page = null;
1548
+ this.#dropPageState();
351
1549
  }
352
1550
 
353
1551
  // Leave Chrome open for inspection, but drop the CDP transport and the
@@ -356,21 +1554,19 @@ export class BaseWebAdapter {
356
1554
  // lock held by this process.
357
1555
  async detach() {
358
1556
  await this.cdpChrome?.detach?.().catch(() => null);
359
- this.cdpChrome = null;
360
- this.context = null;
361
- this.page = null;
1557
+ this.#dropPageState();
362
1558
  }
363
1559
 
364
1560
  // Re-establishes the CDP connection to a still-alive Chrome after the
365
1561
  // Playwright transport died mid-run (e.g. the Mac slept). launch() reuses
366
1562
  // the saved CDP state, so Chrome is neither relaunched nor killed; an
367
1563
  // existing tab on the preferred conversation is reused when available.
368
- async reconnect(preferredUrl = null) {
369
- await this.cdpChrome?.disconnect?.().catch(() => null);
370
- this.cdpChrome = null;
371
- this.context = null;
372
- this.page = null;
373
- await this.launch(preferredUrl);
1564
+ async reconnect(preferredUrl = null, { preferredTargetId = null } = {}) {
1565
+ // launch() acquires a new profile lock. Release this connection's ownership
1566
+ // first; if another CLI wins the lock, launch must respect that owner.
1567
+ await this.cdpChrome?.detach?.();
1568
+ this.#dropPageState({ clearObservation: false });
1569
+ await this.launch(preferredUrl, { preferredTargetId });
374
1570
  }
375
1571
 
376
1572
  // Bring the window forward (used before asking the user to log in or solve a
@@ -441,65 +1637,133 @@ export class BaseWebAdapter {
441
1637
 
442
1638
  async startConversation(
443
1639
  conversationUrl = null,
444
- { expectedAssistantMessageId = null } = {},
1640
+ {
1641
+ expectedAssistantMessageId = null,
1642
+ expectedUserMessageId = null,
1643
+ } = {},
445
1644
  ) {
446
1645
  this.requirePage();
447
- let target = this.baseUrl;
448
- let resumesExistingConversation = Boolean(conversationUrl);
449
- if (conversationUrl) {
450
- const parsed = new URL(conversationUrl);
451
- const base = new URL(this.baseUrl);
452
- if (parsed.protocol !== "https:" || parsed.hostname !== base.hostname) {
453
- throw new BrowserAdapterError(
454
- `Refusing to open a conversation outside ${base.hostname}.`,
455
- { code: "INVALID_CONVERSATION_URL" },
456
- );
457
- }
458
- target = parsed.href;
459
- const conversationPattern = this.conversationUrlPattern();
460
- // A run can fail before the first message is committed. In that case its
461
- // saved URL is the provider's home/new-chat page, not a conversation to
462
- // hydrate. Resume it as a verified fresh chat instead of waiting forever
463
- // for history that cannot exist.
464
- if (conversationPattern && !conversationPattern.test(parsed.pathname)) {
465
- resumesExistingConversation = false;
466
- }
467
- }
468
-
469
- const reuseCurrent = Boolean(
470
- conversationUrl
471
- && sameConversationUrl(this.page.url(), target),
472
- );
473
- if (!reuseCurrent) {
474
- await this.page.goto(target, { waitUntil: "domcontentloaded" });
1646
+ this.expectedRestorationAssistantId = expectedAssistantMessageId;
1647
+ this.expectedRestorationUserId = expectedUserMessageId;
1648
+ const base = new URL(this.baseUrl);
1649
+ const requestedUrl = conversationUrl
1650
+ ? normalizedConversationUrl(conversationUrl)
1651
+ : null;
1652
+ const requestedKind = requestedUrl
1653
+ ? this.classifyConversationUrl(requestedUrl)
1654
+ : "fresh";
1655
+ if (["invalid", "unknown"].includes(requestedKind)) {
1656
+ throw new BrowserAdapterError(
1657
+ requestedKind === "invalid"
1658
+ ? `Refusing to open a conversation outside ${base.origin}.`
1659
+ : `Refusing to treat an unknown ${base.hostname} route as a conversation.`,
1660
+ {
1661
+ code: requestedKind === "invalid"
1662
+ ? "INVALID_CONVERSATION_URL"
1663
+ : "UNKNOWN_CONVERSATION_URL",
1664
+ },
1665
+ );
475
1666
  }
476
- const composer = await this.#waitForComposer(30_000);
477
- if (!composer) {
1667
+ if (this.preferredTabAmbiguous) {
478
1668
  throw new BrowserAdapterError(
479
- `${this.providerName} composer was not found after opening a new conversation.`,
480
- { code: "COMPOSER_NOT_FOUND" },
1669
+ `${this.providerName} has multiple possible tabs for this provisional conversation. `
1670
+ + "Refusing to choose one or rebuild on a blank page.",
1671
+ { code: "CONVERSATION_TAB_AMBIGUOUS", recoverable: false },
481
1672
  );
482
1673
  }
483
1674
 
484
- if (resumesExistingConversation) {
485
- await this.#waitForConversationHistory({
486
- expectedAssistantMessageId,
487
- expectedUrl: target,
488
- });
489
- return;
490
- }
1675
+ const currentUrl = normalizedConversationUrl(this.page.url());
1676
+ const provisionalTabMatched = Boolean(
1677
+ requestedUrl
1678
+ && requestedKind === "provisional"
1679
+ && (
1680
+ sameConversationUrl(currentUrl, requestedUrl)
1681
+ || sameConversationUrl(this.preservedPreferredUrl, requestedUrl)
1682
+ || this.#isObservedConversationAlias(requestedUrl)
1683
+ )
1684
+ );
1685
+ const currentKind = currentUrl
1686
+ ? this.classifyConversationUrl(currentUrl)
1687
+ : "invalid";
1688
+ const freshTargetConversationCandidate = Boolean(
1689
+ requestedKind === "fresh"
1690
+ && this.cdpChrome?.preferredTargetMatched
1691
+ && currentKind === "restorable"
1692
+ );
1693
+ const resumesExistingConversation = requestedKind === "restorable"
1694
+ || provisionalTabMatched
1695
+ || freshTargetConversationCandidate;
1696
+
1697
+ // A provisional URL can locate a live tab, but must never be navigated to.
1698
+ // A matched page may canonicalize while we observe it; Page continuity alone
1699
+ // is insufficient, so a saved user/assistant marker must remain on the new
1700
+ // route throughout the restoration stability window.
1701
+ const target = requestedKind === "provisional"
1702
+ ? (provisionalTabMatched ? currentUrl : normalizedConversationUrl(this.baseUrl))
1703
+ : freshTargetConversationCandidate
1704
+ ? currentUrl
1705
+ : (requestedUrl ?? normalizedConversationUrl(this.baseUrl));
491
1706
 
492
- if (!resumesExistingConversation && !await this.#isFreshConversation()) {
493
- await this.#openNewConversation();
494
- const freshComposer = await this.#waitForComposer(30_000);
495
- if (!freshComposer || !await this.#isFreshConversation()) {
1707
+ try {
1708
+ if (!sameConversationUrl(this.page.url(), target)) {
1709
+ await this.page.goto(String(target), { waitUntil: "domcontentloaded" });
1710
+ }
1711
+ const composer = await this.#waitForComposer(30_000);
1712
+ if (!composer) {
1713
+ throw new BrowserAdapterError(
1714
+ `${this.providerName} composer was not found after opening a conversation.`,
1715
+ { code: "COMPOSER_NOT_FOUND" },
1716
+ );
1717
+ }
1718
+
1719
+ if (resumesExistingConversation) {
1720
+ return await this.#waitForConversationHistory({
1721
+ expectedAssistantMessageId,
1722
+ expectedUserMessageId,
1723
+ expectedUrl: requestedUrl,
1724
+ allowCanonicalTransition: requestedKind === "provisional"
1725
+ || freshTargetConversationCandidate,
1726
+ });
1727
+ }
1728
+
1729
+ if (!await this.#isFreshConversation()) {
1730
+ await this.#openNewConversation();
1731
+ const freshComposer = await this.#waitForComposer(30_000);
1732
+ if (!freshComposer) {
1733
+ await this.writeDiagnostics("conversation-not-fresh");
1734
+ throw new BrowserAdapterError(
1735
+ `${this.providerName} did not expose a composer on its new-chat page.`,
1736
+ { code: "CONVERSATION_NOT_FRESH" },
1737
+ );
1738
+ }
1739
+ }
1740
+ const freshUrl = await this.#waitForStableFreshConversation();
1741
+ if (!freshUrl) {
496
1742
  await this.writeDiagnostics("conversation-not-fresh");
497
1743
  throw new BrowserAdapterError(
498
- `${this.providerName} did not open a verified empty conversation. `
1744
+ `${this.providerName} did not open a stable, verified empty conversation. `
499
1745
  + "Refusing to send a new session prompt into an existing chat.",
500
1746
  { code: "CONVERSATION_NOT_FRESH" },
501
1747
  );
502
1748
  }
1749
+ this.expectedRestorationAssistantId = null;
1750
+ this.expectedRestorationUserId = null;
1751
+ this.lastAssistantMessageId = null;
1752
+ this.lastAssistantTurn = null;
1753
+ this.lastUserMessageId = null;
1754
+ this.activeTurnConversationUrl = null;
1755
+ this.#beginConversationObservation(freshUrl, { arm: false, reset: true });
1756
+ return {
1757
+ status: "verified-fresh",
1758
+ conversationUrl: freshUrl,
1759
+ ...(this.cdpChrome?.targetId
1760
+ ? { targetId: this.cdpChrome.targetId }
1761
+ : {}),
1762
+ };
1763
+ } finally {
1764
+ // A CDP selection proves only this single restoration attempt. Long-lived
1765
+ // page aliases continue to be tracked separately by navigation events.
1766
+ this.preservedPreferredUrl = null;
503
1767
  }
504
1768
  }
505
1769
 
@@ -508,12 +1772,117 @@ export class BaseWebAdapter {
508
1772
  return this.page.url();
509
1773
  }
510
1774
 
1775
+ async getLastUserMessageId() {
1776
+ return this.lastUserMessageId;
1777
+ }
1778
+
511
1779
  async getLastAssistantMessageId() {
512
1780
  return this.lastAssistantMessageId;
513
1781
  }
514
1782
 
515
- async sendMessage(text, { files = [], maxBytes = null } = {}) {
1783
+ async getLastAssistantTurn() {
1784
+ return this.lastAssistantTurn;
1785
+ }
1786
+
1787
+ async captureAuxiliaryTurnCompletion({ timeoutMs = 30_000 } = {}) {
516
1788
  this.requirePage();
1789
+ const deadline = this.monotonicNow() + Math.max(0, Number(timeoutMs) || 0);
1790
+ let candidate = null;
1791
+ let conversationUrl = null;
1792
+ while (this.monotonicNow() <= deadline) {
1793
+ // A fresh provider chat may allocate its durable conversation URL only
1794
+ // after the generated image has rendered. Keep the exact page/frame/
1795
+ // target binding while the saved user-message marker stabilizes; do not
1796
+ // read or accept an assistant boundary from an uncorrelated candidate URL.
1797
+ if (
1798
+ this.activeTurnConversationUrl
1799
+ && !this.#activeTurnProofIsIntact({ allowPendingCanonicalUrl: true })
1800
+ ) {
1801
+ throw new BrowserAdapterError(
1802
+ `${this.providerName} left the verified conversation during the provider operation.`,
1803
+ { code: "CONVERSATION_CHANGED_DURING_TURN", recoverable: false },
1804
+ );
1805
+ }
1806
+ const pageState = await this.#submittedPageState();
1807
+ if (pageState.status === "candidate") {
1808
+ await this.waitForPoll(250);
1809
+ continue;
1810
+ }
1811
+ if (pageState.status !== "trusted") {
1812
+ throw new BrowserAdapterError(
1813
+ `${this.providerName} left the verified conversation during the provider operation.`,
1814
+ { code: "CONVERSATION_CHANGED_DURING_TURN", recoverable: false },
1815
+ );
1816
+ }
1817
+ conversationUrl = pageState.conversationUrl;
1818
+ this.activeTurnConversationUrl = conversationUrl;
1819
+ if (this.activeTurnProof) {
1820
+ this.activeTurnProof.conversationUrl = conversationUrl;
1821
+ }
1822
+
1823
+ const messages = this.assistantMessages();
1824
+ const count = await messages.count().catch(() => 0);
1825
+ if (count > 0) {
1826
+ const message = messages.last();
1827
+ const { id, turn } = await this.messageIdentity(message);
1828
+ const text = await this.assistantText(message).catch(() => "");
1829
+ const generating = await this.isAssistantGenerating(message);
1830
+ if (
1831
+ !generating
1832
+ && this.isNewAssistantIdentity({ id, turn, text, generating: false })
1833
+ ) {
1834
+ candidate = { id, turn };
1835
+ break;
1836
+ }
1837
+ }
1838
+ await this.waitForPoll(250);
1839
+ }
1840
+ if (!candidate) {
1841
+ throw new BrowserAdapterError(
1842
+ `${this.providerName} did not expose a completed, new assistant boundary for the provider operation.`,
1843
+ { code: "AUXILIARY_TURN_UNCORRELATED", recoverable: false },
1844
+ );
1845
+ }
1846
+ await this.#validateRecoveredTurnBoundary({
1847
+ assistantPresent: true,
1848
+ assistantMessageId: candidate.id,
1849
+ assistantTurn: candidate.turn,
1850
+ });
1851
+ this.lastAssistantMessageId = candidate.id ?? null;
1852
+ this.lastAssistantTurn = candidate.turn ?? null;
1853
+ return {
1854
+ assistantMessageId: this.lastAssistantMessageId,
1855
+ assistantTurn: this.lastAssistantTurn,
1856
+ conversationUrl,
1857
+ };
1858
+ }
1859
+
1860
+ getLastSendStatus() {
1861
+ return this.lastSendStatus;
1862
+ }
1863
+
1864
+ async sendMessage(text, {
1865
+ files = [],
1866
+ maxBytes = null,
1867
+ outboundId = null,
1868
+ allowAssistantContinuation = false,
1869
+ requireAttachments = false,
1870
+ } = {}) {
1871
+ this.requirePage();
1872
+ if (this.pendingSubmission) {
1873
+ throw new BrowserAdapterError(
1874
+ "The previous submit action is still in flight. No new message was composed.",
1875
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false, details: { stage: "previous-submit" } },
1876
+ );
1877
+ }
1878
+ this.lastSendStatus = "not-submitted";
1879
+ this.expectedAssistantCandidateId = null;
1880
+ this.expectedAssistantCandidateTurn = null;
1881
+ this.recoveredTurnTopologyRequired = false;
1882
+ this.allowInPlaceAssistantContinuation = Boolean(
1883
+ allowAssistantContinuation,
1884
+ );
1885
+ this.inPlaceAssistantGenerationObserved = false;
517
1886
  const messageBytes = Buffer.byteLength(String(text ?? ""), "utf8");
518
1887
  if (maxBytes != null && messageBytes > maxBytes) {
519
1888
  throw new BrowserAdapterError(
@@ -524,23 +1893,90 @@ export class BaseWebAdapter {
524
1893
  },
525
1894
  );
526
1895
  }
527
- const urlBeforeSend = this.page.url();
1896
+ if (
1897
+ outboundId != null
1898
+ && (
1899
+ !isValidOutboundCorrelationId(outboundId)
1900
+ || !renderedMessageContainsOutboundMarker(text, outboundId)
1901
+ )
1902
+ ) {
1903
+ throw new BrowserAdapterError(
1904
+ "The outbound transport correlation marker is malformed. Nothing was sent.",
1905
+ { code: "INVALID_OUTBOUND_CORRELATION_ID", recoverable: false },
1906
+ );
1907
+ }
1908
+
1909
+ let sendProof = null;
1910
+ try {
1911
+ const sendIdentity = await this.getConversationIdentity();
1912
+ const urlBeforeSend = sendIdentity.conversationUrl;
1913
+ const sendStartedFresh = sendIdentity.kind === "fresh";
1914
+ await this.#assertTrustedPageForSend(urlBeforeSend);
528
1915
  const composer = await this.#waitForComposer(30_000);
529
1916
  if (!composer) {
530
1917
  throw new BrowserAdapterError(
531
- `${this.providerName} composer is unavailable.`,
532
- { code: "COMPOSER_NOT_FOUND" },
1918
+ `${this.providerName} composer is unavailable.`,
1919
+ { code: "COMPOSER_NOT_FOUND" },
1920
+ );
1921
+ }
1922
+
1923
+ await this.dismissTransientOverlays();
1924
+ await this.#waitUntilReadyToSend();
1925
+
1926
+ if (await firstVisible(this.pendingAttachmentLocators())) {
1927
+ throw new BrowserAdapterError(
1928
+ `${this.providerName} composer contains attachments from an earlier draft. `
1929
+ + "Remove them in the browser before resuming.",
1930
+ { code: "STALE_COMPOSER_ATTACHMENTS", recoverable: false },
533
1931
  );
534
1932
  }
535
1933
 
536
- await this.dismissTransientOverlays();
537
- await this.#waitUntilReadyToSend();
1934
+ await this.#assertTrustedPageForSend(urlBeforeSend);
1935
+ this.sendEpoch += 1;
1936
+ sendProof = {
1937
+ sendEpoch: this.sendEpoch,
1938
+ outboundId,
1939
+ page: this.page,
1940
+ mainFrame: typeof this.page?.mainFrame === "function"
1941
+ ? this.page.mainFrame()
1942
+ : null,
1943
+ targetId: this.cdpChrome?.targetId ?? null,
1944
+ sourceUrl: normalizedConversationUrl(urlBeforeSend),
1945
+ currentUrl: normalizedConversationUrl(urlBeforeSend),
1946
+ startedNavigationEpoch: this.navigationEpoch,
1947
+ candidateUrl: null,
1948
+ candidateNavigationEpoch: null,
1949
+ candidateBeforeSubmission: false,
1950
+ submissionStarted: false,
1951
+ submittedAt: null,
1952
+ deadline: null,
1953
+ stage: "prepare",
1954
+ sample: null,
1955
+ orderedBaseline: null,
1956
+ freshCandidateUserId: null,
1957
+ freshCandidateUserTurn: null,
1958
+ invalidated: false,
1959
+ };
1960
+ this.activeSendProof = sendProof;
1961
+ this.#beginConversationObservation(urlBeforeSend, {
1962
+ arm: sendStartedFresh,
1963
+ });
538
1964
 
539
1965
  // Attach any @file uploads before typing/sending. Upload is best-effort: a
540
1966
  // failure is reported to the caller but does not block sending the text.
541
1967
  let attachment = null;
542
1968
  if (files.length > 0) {
543
1969
  attachment = await this.attachFiles(files);
1970
+ if (requireAttachments && (attachment?.failed?.length ?? 0) > 0) {
1971
+ throw new BrowserAdapterError(
1972
+ `${this.providerName} could not attach every required file. Nothing was sent.`,
1973
+ {
1974
+ code: "ATTACHMENT_UPLOAD_FAILED",
1975
+ recoverable: false,
1976
+ details: { attachment },
1977
+ },
1978
+ );
1979
+ }
544
1980
  }
545
1981
 
546
1982
  const assistantMessages = this.assistantMessages();
@@ -552,6 +1988,12 @@ export class BaseWebAdapter {
552
1988
  );
553
1989
  this.assistantIdsBeforeSend = assistantBaseline.ids;
554
1990
  this.assistantMaxTurnBeforeSend = assistantBaseline.maxTurn;
1991
+ this.expectedRestorationAssistantId ??= [...assistantBaseline.ids].at(-1) ?? null;
1992
+ this.expectedRestorationUserId ??= [...userBaseline.ids].at(-1) ?? null;
1993
+ const hasPreTransitionMarker = Boolean(
1994
+ this.expectedRestorationAssistantId
1995
+ || this.expectedRestorationUserId,
1996
+ );
555
1997
  // Count baseline for providers whose DOM exposes no stable per-message id
556
1998
  // or turn ordinal (they override isNewAssistantIdentity to use it).
557
1999
  this.assistantCountBeforeSend = assistantBaseline.count;
@@ -560,41 +2002,175 @@ export class BaseWebAdapter {
560
2002
  : "";
561
2003
  this.sentUserTurn = null;
562
2004
 
2005
+ await this.#assertTrustedPageForSend(urlBeforeSend, {
2006
+ allowPendingFreshTransition: sendStartedFresh,
2007
+ });
2008
+ if (
2009
+ sendStartedFresh
2010
+ && (assistantBaseline.count !== 0 || userBaseline.count !== 0)
2011
+ ) {
2012
+ await this.writeDiagnostics("conversation-changed-before-send");
2013
+ throw new BrowserAdapterError(
2014
+ `${this.providerName} exposed conversation history before submission. Nothing was sent.`,
2015
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
2016
+ );
2017
+ }
563
2018
  await this.fillComposer(composer, text);
2019
+ await this.#assertTrustedPageForSend(urlBeforeSend, {
2020
+ allowPendingFreshTransition: sendStartedFresh,
2021
+ });
564
2022
 
565
- await this.submitComposer(composer);
566
-
567
- const conversationPattern = this.conversationUrlPattern();
568
2023
  if (
569
- conversationPattern
570
- && !conversationPattern.test(new URL(urlBeforeSend).pathname)
2024
+ sendStartedFresh
2025
+ && this.requiresOrderedConversationSnapshotForFreshSend()
571
2026
  ) {
572
- await this.page.waitForURL(
573
- (url) => conversationPattern.test(url.pathname),
574
- { timeout: 5_000 },
575
- ).catch(() => null);
2027
+ const orderedBaseline = this.#validatedOrderedConversationSnapshot(
2028
+ await this.orderedConversationSnapshot().catch(() => null),
2029
+ );
2030
+ if (
2031
+ !orderedBaseline
2032
+ || orderedBaseline.entries.length !== 0
2033
+ || !sameConversationUrl(orderedBaseline.url, this.page.url())
2034
+ || !this.#activeSendProofIsIntact()
2035
+ ) {
2036
+ await this.writeDiagnostics("conversation-changed-before-send");
2037
+ throw new BrowserAdapterError(
2038
+ `${this.providerName} could not verify an empty ordered conversation before submission. `
2039
+ + "Nothing was sent.",
2040
+ { code: "CONVERSATION_CHANGED_BEFORE_SEND", recoverable: false },
2041
+ );
2042
+ }
2043
+ sendProof.orderedBaseline = {
2044
+ url: orderedBaseline.url,
2045
+ entries: [],
2046
+ };
576
2047
  }
577
2048
 
578
- let sentMessage = await this.#waitForSentUserMessage(userBaseline);
579
- if (!sentMessage) {
580
- // A click/Enter can be ignored while a native-tool card is still open.
581
- // Retry once after the composer is idle again.
582
- await this.#waitUntilReadyToSend(5_000);
583
- await this.submitComposer(composer);
584
- sentMessage = await this.#waitForSentUserMessage(userBaseline);
585
- }
2049
+ // From this point onward a transport failure cannot prove whether the
2050
+ // provider committed the message. Runtime must never resend it blindly. One
2051
+ // monotonic deadline includes the submission itself and every proof sample.
2052
+ sendProof.submissionStarted = true;
2053
+ sendProof.submittedAt = this.monotonicNow();
2054
+ sendProof.deadline = sendProof.submittedAt
2055
+ + this.sendConfirmationTimeoutMs();
2056
+ this.lastSendStatus = "commit-unknown";
2057
+ sendProof.stage = "submit";
2058
+ const assertActive = () => {
2059
+ if (sendProof.invalidated || this.activeSendProof !== sendProof
2060
+ || this.monotonicNow() >= sendProof.deadline) {
2061
+ throw this.#sendDeadlineError();
2062
+ }
2063
+ };
2064
+ const submission = Promise.resolve().then(() => this.submitComposer(composer, {
2065
+ assertActive,
2066
+ timeoutMs: Math.max(1, Math.ceil(sendProof.deadline - this.monotonicNow())),
2067
+ }));
2068
+ this.pendingSubmission = submission;
2069
+ const settled = () => {
2070
+ if (this.pendingSubmission === submission) this.pendingSubmission = null;
2071
+ };
2072
+ submission.then(settled, settled);
2073
+ await this.#awaitWithinSendDeadline(submission, sendProof.deadline);
2074
+
2075
+ sendProof.stage = "user-message-confirmation";
2076
+ const sentMessage = await this.#waitForSentUserMessage(userBaseline, {
2077
+ deadline: sendProof.deadline,
2078
+ expectedText: text,
2079
+ outboundId,
2080
+ allowPendingFreshTransition: sendStartedFresh,
2081
+ hasPreTransitionMarker,
2082
+ });
586
2083
  if (!sentMessage) {
587
- // The model never rendered the message: the send did not register (a
588
- // disabled send button, a missed Enter, or a transient UI state). Fail
589
- // loudly instead of pretending the message went out — otherwise the
590
- // runtime waits for a reply that was never received.
2084
+ // Submission already happened. A missing bubble is ambiguous: retrying can
2085
+ // duplicate a committed message whose DOM confirmation was delayed/lost.
591
2086
  await this.writeDiagnostics("send-not-detected");
592
2087
  throw new BrowserAdapterError(
593
- `${this.providerName} did not render the sent message; the send may have failed.`,
594
- { code: "SEND_NOT_DETECTED" },
2088
+ `${this.providerName} did not confirm whether the sent message committed. `
2089
+ + "It was not sent again automatically.",
2090
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
2091
+ );
2092
+ }
2093
+ this.lastUserMessageId = sentMessage.id ?? null;
2094
+ if (sendStartedFresh) {
2095
+ this.#confirmFreshConversationSend();
2096
+ } else {
2097
+ this.#observeConversationNavigation(this.page.url());
2098
+ }
2099
+
2100
+ sendProof.stage = "conversation-confirmation";
2101
+ let submittedPage = await this.#awaitWithinSendDeadline(
2102
+ this.#submittedPageState(),
2103
+ sendProof.deadline,
2104
+ );
2105
+ if (submittedPage.status === "candidate") {
2106
+ const verifiedUrl = await this.#waitForCanonicalCandidate(
2107
+ submittedPage.conversationUrl,
2108
+ { deadline: sendProof.deadline },
2109
+ );
2110
+ submittedPage = verifiedUrl
2111
+ ? { status: "trusted", conversationUrl: verifiedUrl }
2112
+ : { status: "untrusted", conversationUrl: this.page.url() };
2113
+ }
2114
+ if (submittedPage.status !== "trusted") {
2115
+ await this.writeDiagnostics("conversation-changed-after-submit");
2116
+ throw new BrowserAdapterError(
2117
+ `${this.providerName} changed conversations after submission, so the sent `
2118
+ + "message could not be bound to the verified page. It was not sent again.",
2119
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
595
2120
  );
596
2121
  }
597
- return { attachment };
2122
+
2123
+ if (this.lastUserMessageId) {
2124
+ this.expectedRestorationUserId = this.lastUserMessageId;
2125
+ }
2126
+ this.#captureActiveTurnProof(submittedPage.conversationUrl, {
2127
+ outboundId,
2128
+ userMessageId: this.lastUserMessageId,
2129
+ userTurn: sentMessage.turn ?? null,
2130
+ });
2131
+ this.lastSendStatus = "confirmed";
2132
+ return {
2133
+ attachment,
2134
+ userMessageId: this.lastUserMessageId,
2135
+ userTurn: sentMessage.turn ?? null,
2136
+ conversationUrl: submittedPage.conversationUrl,
2137
+ conversationTargetId: this.cdpChrome?.targetId ?? null,
2138
+ assistantBaseline: {
2139
+ ids: [...assistantBaseline.ids],
2140
+ count: assistantBaseline.count,
2141
+ maxTurn: assistantBaseline.maxTurn,
2142
+ lastText: this.lastAssistantTextBeforeSend,
2143
+ },
2144
+ preOutboundMarkerIds: [
2145
+ ...userBaseline.ids,
2146
+ ...assistantBaseline.ids,
2147
+ ],
2148
+ };
2149
+ } catch (error) {
2150
+ if (error?.code === "SEND_COMMIT_UNKNOWN") {
2151
+ error.completionUnknown = true;
2152
+ error.details = { ...this.#sendDiagnosticDetails(), ...(error.details ?? {}) };
2153
+ // Invalidate before collecting diagnostics: delayed submit callbacks
2154
+ // must not enter a fallback action while this read-only capture runs.
2155
+ if (sendProof) sendProof.invalidated = true;
2156
+ const diagnostics = await this.writeDiagnostics("send-commit-unknown", error.details).catch(() => null);
2157
+ if (diagnostics?.filename) {
2158
+ error.details.diagnosticFile = diagnostics.filename;
2159
+ error.details.dom = diagnostics.report.dom;
2160
+ }
2161
+ }
2162
+ throw error;
2163
+ } finally {
2164
+ if (sendProof && this.activeSendProof === sendProof) {
2165
+ sendProof.invalidated = true;
2166
+ this.activeSendProof = null;
2167
+ }
2168
+ if (this.lastSendStatus !== "confirmed") {
2169
+ this.allowInPlaceAssistantContinuation = false;
2170
+ this.inPlaceAssistantGenerationObserved = false;
2171
+ this.#resetPendingCanonicalVerification({ clearCandidate: true });
2172
+ }
2173
+ }
598
2174
  }
599
2175
 
600
2176
  async waitForTurnComplete({
@@ -604,6 +2180,8 @@ export class BaseWebAdapter {
604
2180
  truncatedEnvelopeWindowMs = null,
605
2181
  emptyResponseWindowMs = 10_000,
606
2182
  deadRequestGraceMs = 60_000,
2183
+ readNativeImages = null,
2184
+ readNativeMusic = null,
607
2185
  onDelta,
608
2186
  }) {
609
2187
  this.requirePage();
@@ -616,11 +2194,65 @@ export class BaseWebAdapter {
616
2194
  let emptyCandidate = null;
617
2195
  let sawGenerationSignal = false;
618
2196
  let lastGenerationSignalAt = 0;
2197
+ let unverifiedPageChecks = 0;
2198
+ let nativeCandidate = null;
2199
+ let nativeStableSince = 0;
619
2200
  const detachEscCancel = this.#attachEscCancel();
620
2201
  const truncatedGraceMs = truncatedEnvelopeWindowMs
621
2202
  ?? this.truncatedEnvelopeGraceMs();
622
2203
  try {
623
2204
  while (Date.now() < deadline) {
2205
+ if (this.activeTurnConversationUrl) {
2206
+ // A main-frame navigation may nominate a provisional-to-canonical
2207
+ // transition before its saved message marker has remained stable.
2208
+ // Preserve the exact page/frame/target binding while the page-state
2209
+ // verifier below waits; no reply is read until that URL is trusted.
2210
+ if (!this.#activeTurnProofIsIntact({ allowPendingCanonicalUrl: true })) {
2211
+ await this.writeDiagnostics("conversation-changed-during-turn");
2212
+ throw new BrowserAdapterError(
2213
+ `${this.providerName} browser target changed while waiting for its reply. `
2214
+ + "No content from the substitute page was accepted.",
2215
+ {
2216
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2217
+ recoverable: false,
2218
+ },
2219
+ );
2220
+ }
2221
+ const pageState = await this.#submittedPageState();
2222
+ if (pageState.status === "candidate") {
2223
+ unverifiedPageChecks += 1;
2224
+ if (unverifiedPageChecks >= 60) {
2225
+ await this.writeDiagnostics("conversation-changed-during-turn");
2226
+ throw new BrowserAdapterError(
2227
+ `${this.providerName} could not correlate the conversation URL `
2228
+ + "that appeared while waiting for the reply.",
2229
+ {
2230
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2231
+ recoverable: false,
2232
+ },
2233
+ );
2234
+ }
2235
+ await this.page.waitForTimeout(250);
2236
+ continue;
2237
+ }
2238
+ if (pageState.status !== "trusted") {
2239
+ await this.writeDiagnostics("conversation-changed-during-turn");
2240
+ throw new BrowserAdapterError(
2241
+ `${this.providerName} left the verified conversation while waiting `
2242
+ + "for its reply. No content from the other page was accepted.",
2243
+ {
2244
+ code: "CONVERSATION_CHANGED_DURING_TURN",
2245
+ recoverable: false,
2246
+ },
2247
+ );
2248
+ }
2249
+ unverifiedPageChecks = 0;
2250
+ this.activeTurnConversationUrl = pageState.conversationUrl;
2251
+ if (this.activeTurnProof) {
2252
+ this.activeTurnProof.conversationUrl = pageState.conversationUrl;
2253
+ }
2254
+ }
2255
+
624
2256
  await this.throwIfBlockedPage();
625
2257
 
626
2258
  if (this.escCancelRequested) {
@@ -637,23 +2269,56 @@ export class BaseWebAdapter {
637
2269
  const messages = this.assistantMessages();
638
2270
  const count = await messages.count();
639
2271
  const lastMessage = count > 0 ? messages.last() : null;
640
- const candidateText = lastMessage
641
- ? await this.assistantText(lastMessage)
642
- : "";
643
2272
  const { id: candidateId, turn: candidateTurn } = lastMessage
644
2273
  ? await this.messageIdentity(lastMessage)
645
2274
  : { id: null, turn: null };
646
- const hasNewAssistant = this.isNewAssistantIdentity({
647
- id: candidateId,
648
- turn: candidateTurn,
649
- text: candidateText,
650
- });
651
-
2275
+ await this.#validateRecoveredTurnBoundary();
2276
+ if (
2277
+ (this.expectedAssistantCandidateId != null
2278
+ || this.expectedAssistantCandidateTurn != null)
2279
+ && (
2280
+ !lastMessage
2281
+ || (
2282
+ this.expectedAssistantCandidateId != null
2283
+ && candidateId !== this.expectedAssistantCandidateId
2284
+ )
2285
+ || (
2286
+ this.expectedAssistantCandidateTurn != null
2287
+ && candidateTurn !== this.expectedAssistantCandidateTurn
2288
+ )
2289
+ )
2290
+ ) {
2291
+ await this.writeDiagnostics("recovered-assistant-boundary-changed");
2292
+ throw new BrowserAdapterError(
2293
+ `${this.providerName} assistant boundary changed after recovery. `
2294
+ + "No content from the replacement reply was accepted.",
2295
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
2296
+ );
2297
+ }
2298
+ const candidateText = lastMessage
2299
+ ? await this.assistantText(lastMessage)
2300
+ : "";
652
2301
  const stopVisible = await this.#isStopButtonVisible();
653
2302
  const assistantGenerating = lastMessage
654
2303
  ? await this.isAssistantGenerating(lastMessage)
655
2304
  : false;
656
2305
  const generating = stopVisible || assistantGenerating;
2306
+ if (generating && this.allowInPlaceAssistantContinuation) {
2307
+ this.inPlaceAssistantGenerationObserved = true;
2308
+ }
2309
+ const hasNewAssistant = this.isNewAssistantIdentity({
2310
+ id: candidateId,
2311
+ turn: candidateTurn,
2312
+ text: candidateText,
2313
+ generating,
2314
+ });
2315
+ if (hasNewAssistant) {
2316
+ await this.#validateRecoveredTurnBoundary({
2317
+ assistantPresent: true,
2318
+ assistantMessageId: candidateId,
2319
+ assistantTurn: candidateTurn,
2320
+ });
2321
+ }
657
2322
  if (hasNewAssistant || generating) {
658
2323
  // A reply node or a visible stop button proves generation started;
659
2324
  // only a request with neither signal can be dead.
@@ -673,6 +2338,33 @@ export class BaseWebAdapter {
673
2338
  }
674
2339
  }
675
2340
 
2341
+ if (readNativeImages || readNativeMusic) {
2342
+ const snapshot = await (readNativeMusic ?? readNativeImages)(lastMessage);
2343
+ const media = readNativeMusic ? snapshot.music : snapshot.images;
2344
+ if (snapshot.pending || media.length > 0) {
2345
+ emptySince = 0;
2346
+ emptyCandidate = null;
2347
+ const signature = JSON.stringify([candidateId, candidateTurn, text, media]);
2348
+ if (generating || snapshot.pending || signature !== nativeCandidate) {
2349
+ nativeCandidate = signature;
2350
+ nativeStableSince = Date.now();
2351
+ } else if (Date.now() - nativeStableSince >= Math.max(2_000, stableWindowMs)) {
2352
+ await this.#validateRecoveredTurnBoundary({
2353
+ assistantPresent: true,
2354
+ assistantMessageId: candidateId,
2355
+ assistantTurn: candidateTurn,
2356
+ });
2357
+ this.lastAssistantMessageId = candidateId;
2358
+ this.lastAssistantTurn = candidateTurn;
2359
+ return readNativeMusic ? { text, nativeMusic: media } : { text, nativeImages: media };
2360
+ }
2361
+ await this.page.waitForTimeout(250);
2362
+ continue;
2363
+ }
2364
+ nativeCandidate = null;
2365
+ nativeStableSince = 0;
2366
+ }
2367
+
676
2368
  if (!text.trim() && !generating) {
677
2369
  // The model can create a real assistant turn and finish it without
678
2370
  // rendering any content. Once that exact empty node remains stopped
@@ -688,7 +2380,13 @@ export class BaseWebAdapter {
688
2380
  emptySince > 0
689
2381
  && Date.now() - emptySince >= emptyResponseWindowMs
690
2382
  ) {
2383
+ await this.#validateRecoveredTurnBoundary({
2384
+ assistantPresent: true,
2385
+ assistantMessageId: candidateId,
2386
+ assistantTurn: candidateTurn,
2387
+ });
691
2388
  this.lastAssistantMessageId = candidateId;
2389
+ this.lastAssistantTurn = candidateTurn;
692
2390
  throw new BrowserAdapterError(
693
2391
  `${this.providerName} completed an assistant turn without any content.`,
694
2392
  {
@@ -736,15 +2434,21 @@ export class BaseWebAdapter {
736
2434
  )
737
2435
  )
738
2436
  ) {
739
- this.lastAssistantMessageId = candidateId;
740
2437
  const limitMarker = await this.findUsageLimitMarker(lastMessage);
2438
+ const generationError = await this.findGenerationErrorMarker(lastMessage);
2439
+ await this.#validateRecoveredTurnBoundary({
2440
+ assistantPresent: true,
2441
+ assistantMessageId: candidateId,
2442
+ assistantTurn: candidateTurn,
2443
+ });
2444
+ this.lastAssistantMessageId = candidateId;
2445
+ this.lastAssistantTurn = candidateTurn;
741
2446
  if (limitMarker) {
742
2447
  throw new BrowserAdapterError(
743
2448
  `${this.providerName} reported a usage limit (${limitMarker}).`,
744
2449
  { code: "USAGE_LIMIT_REACHED" },
745
2450
  );
746
2451
  }
747
- const generationError = await this.findGenerationErrorMarker(lastMessage);
748
2452
  if (generationError) {
749
2453
  throw new BrowserAdapterError(
750
2454
  `${this.providerName} generation failed (${generationError}).`,
@@ -769,7 +2473,13 @@ export class BaseWebAdapter {
769
2473
  && !assistantGenerating
770
2474
  && Date.now() - stableSince >= truncatedGraceMs
771
2475
  ) {
2476
+ await this.#validateRecoveredTurnBoundary({
2477
+ assistantPresent: true,
2478
+ assistantMessageId: candidateId,
2479
+ assistantTurn: candidateTurn,
2480
+ });
772
2481
  this.lastAssistantMessageId = candidateId;
2482
+ this.lastAssistantTurn = candidateTurn;
773
2483
  return lastText.trim();
774
2484
  }
775
2485
  }
@@ -793,6 +2503,11 @@ export class BaseWebAdapter {
793
2503
  && this.sentUserTurn != null
794
2504
  && Date.now() - quietSince >= quietGraceMs
795
2505
  ) {
2506
+ await this.#validateRecoveredTurnBoundary({
2507
+ assistantPresent: hasNewAssistant,
2508
+ assistantMessageId: candidateId,
2509
+ assistantTurn: candidateTurn,
2510
+ });
796
2511
  await this.writeDiagnostics("dead-request");
797
2512
  throw new BrowserAdapterError(
798
2513
  `${this.providerName} stopped responding without completing a reply.`,
@@ -812,6 +2527,8 @@ export class BaseWebAdapter {
812
2527
  { code: "TURN_TIMEOUT" },
813
2528
  );
814
2529
  } finally {
2530
+ this.allowInPlaceAssistantContinuation = false;
2531
+ this.inPlaceAssistantGenerationObserved = false;
815
2532
  detachEscCancel?.();
816
2533
  }
817
2534
  }
@@ -827,16 +2544,34 @@ export class BaseWebAdapter {
827
2544
  }
828
2545
 
829
2546
  async #isFreshConversation() {
830
- const current = new URL(this.page.url());
831
- const base = new URL(this.baseUrl);
832
- const pattern = this.conversationUrlPattern();
833
- if (
834
- current.hostname !== base.hostname
835
- || (pattern && pattern.test(current.pathname))
836
- ) {
837
- return false;
2547
+ return this.classifyConversationUrl(this.page.url()) === "fresh"
2548
+ && await this.conversationMessages().count().catch(() => -1) === 0;
2549
+ }
2550
+
2551
+ async #waitForStableFreshConversation({ attempts = 60 } = {}) {
2552
+ let previousSignature = null;
2553
+ let stableChecks = 0;
2554
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
2555
+ this.#throwIfCancelRequested();
2556
+ const currentUrl = normalizedConversationUrl(this.page.url());
2557
+ const kind = currentUrl
2558
+ ? this.classifyConversationUrl(currentUrl)
2559
+ : "invalid";
2560
+ const totalMessages = await this.conversationMessages()
2561
+ .count()
2562
+ .catch(() => -1);
2563
+ const signature = `${currentUrl ?? ""}:${kind}:${totalMessages}`;
2564
+ if (kind !== "fresh" || totalMessages !== 0) {
2565
+ return null;
2566
+ }
2567
+ stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
2568
+ previousSignature = signature;
2569
+ if (stableChecks >= RESTORATION_STABLE_CHECKS) {
2570
+ return currentUrl;
2571
+ }
2572
+ await this.page.waitForTimeout(250);
838
2573
  }
839
- return await this.conversationMessages().count() === 0;
2574
+ return null;
840
2575
  }
841
2576
 
842
2577
  async #openNewConversation() {
@@ -897,88 +2632,521 @@ export class BaseWebAdapter {
897
2632
  return { count, ids, maxTurn };
898
2633
  }
899
2634
 
900
- async #waitForSentUserMessage(baseline, attempts = this.sentUserWaitAttempts()) {
901
- for (let attempt = 0; attempt < attempts; attempt += 1) {
2635
+ #validatedOrderedConversationSnapshot(snapshot) {
2636
+ if (!snapshot || !Array.isArray(snapshot.entries)) {
2637
+ return null;
2638
+ }
2639
+ const url = normalizedConversationUrl(snapshot.url);
2640
+ if (!url) {
2641
+ return null;
2642
+ }
2643
+
2644
+ const ids = new Set();
2645
+ const turns = new Set();
2646
+ const entries = [];
2647
+ let previousDomIndex = -1;
2648
+ let previousTurn = -1;
2649
+ for (const entry of snapshot.entries) {
2650
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
2651
+ const turn = entry?.turn;
2652
+ const domIndex = entry?.domIndex;
2653
+ if (
2654
+ !["user", "assistant"].includes(entry?.role)
2655
+ || !id
2656
+ || !Number.isSafeInteger(turn)
2657
+ || turn < 0
2658
+ || !Number.isSafeInteger(domIndex)
2659
+ || domIndex <= previousDomIndex
2660
+ || turn <= previousTurn
2661
+ || ids.has(id)
2662
+ || turns.has(turn)
2663
+ ) {
2664
+ return null;
2665
+ }
2666
+ ids.add(id);
2667
+ turns.add(turn);
2668
+ previousDomIndex = domIndex;
2669
+ previousTurn = turn;
2670
+ entries.push({
2671
+ domIndex,
2672
+ role: entry.role,
2673
+ id,
2674
+ turn,
2675
+ renderedText: String(entry.renderedText ?? ""),
2676
+ });
2677
+ }
2678
+ return { url, entries };
2679
+ }
2680
+
2681
+ #freshUserFromOrderedSnapshot(snapshot, {
2682
+ baseline,
2683
+ pageState,
2684
+ expectedText,
2685
+ outboundId,
2686
+ }) {
2687
+ const proof = this.activeSendProof;
2688
+ if (!proof || !this.#activeSendProofIsIntact()) {
2689
+ return null;
2690
+ }
2691
+ if (!sameConversationUrl(pageState.conversationUrl, proof.currentUrl)) {
2692
+ // A permitted main-frame transition can complete while the asynchronous
2693
+ // DOM snapshot is being read. Its navigation event has already advanced
2694
+ // the intact send proof, so this page-state/snapshot pair is stale, not
2695
+ // evidence of a substituted conversation. Discard the sample and verify
2696
+ // the same outbound/user again on the new route for a full stable window.
2697
+ return null;
2698
+ }
2699
+ const ordered = this.#validatedOrderedConversationSnapshot(snapshot);
2700
+ if (!ordered) {
2701
+ // A real assistant row can be inserted over more than one render frame.
2702
+ // An incomplete atomic sample neither proves nor disproves the user bubble;
2703
+ // a later valid sample must still pass the full structural checks.
2704
+ return null;
2705
+ }
2706
+ if (
2707
+ !sameConversationUrl(ordered.url, pageState.conversationUrl)
2708
+ || baseline.count !== 0
2709
+ ) {
2710
+ proof.invalidated = true;
2711
+ return null;
2712
+ }
2713
+ if (ordered.entries.length === 0) {
2714
+ if (proof.freshCandidateUserId != null) {
2715
+ proof.invalidated = true;
2716
+ }
2717
+ return null;
2718
+ }
2719
+
2720
+ const users = ordered.entries.filter((entry) => entry.role === "user");
2721
+ const matchingUsers = users.filter((entry) => (
2722
+ renderedMessageContains(entry.renderedText, expectedText, outboundId)
2723
+ ));
2724
+ const validFreshTopology = users.length === 1
2725
+ && ordered.entries[0] === users[0];
2726
+ if (!validFreshTopology || matchingUsers.length > 1) {
2727
+ proof.invalidated = true;
2728
+ return null;
2729
+ }
2730
+ if (matchingUsers.length === 0) {
2731
+ // The committed bubble may mount before its rendered text. Once its marker
2732
+ // has been observed, however, losing it is an identity change, not loading.
2733
+ if (proof.freshCandidateUserId != null) {
2734
+ proof.invalidated = true;
2735
+ }
2736
+ return null;
2737
+ }
2738
+
2739
+ const user = matchingUsers[0];
2740
+ const newByTurn = baseline.maxTurn == null || user.turn > baseline.maxTurn;
2741
+ const newById = !baseline.ids.has(user.id);
2742
+ if (!newByTurn || !newById) {
2743
+ proof.invalidated = true;
2744
+ return null;
2745
+ }
2746
+ if (
2747
+ (proof.freshCandidateUserId != null
2748
+ && proof.freshCandidateUserId !== user.id)
2749
+ || (proof.freshCandidateUserTurn != null
2750
+ && proof.freshCandidateUserTurn !== user.turn)
2751
+ || (
2752
+ !sameConversationUrl(pageState.conversationUrl, proof.sourceUrl)
2753
+ && !sameConversationUrl(pageState.conversationUrl, proof.candidateUrl)
2754
+ )
2755
+ ) {
2756
+ proof.invalidated = true;
2757
+ return null;
2758
+ }
2759
+ proof.freshCandidateUserId = user.id;
2760
+ proof.freshCandidateUserTurn = user.turn;
2761
+
2762
+ const correlationIdentity = outboundId == null
2763
+ ? comparableMessageText(expectedText)
2764
+ : `${OUTBOUND_MARKER_PREFIX}${outboundId}.`;
2765
+ return {
2766
+ id: user.id,
2767
+ turn: user.turn,
2768
+ signature: [
2769
+ proof.sendEpoch,
2770
+ proof.candidateNavigationEpoch ?? proof.startedNavigationEpoch,
2771
+ pageState.conversationUrl,
2772
+ proof.targetId ?? "",
2773
+ user.id,
2774
+ user.turn,
2775
+ correlationIdentity,
2776
+ ].join(":"),
2777
+ };
2778
+ }
2779
+
2780
+ async #waitBeforeSendDeadline(deadline, ms = 100) {
2781
+ const remaining = deadline - this.monotonicNow();
2782
+ if (remaining <= 0) {
2783
+ return false;
2784
+ }
2785
+ await this.#awaitWithinSendDeadline(
2786
+ this.waitForPoll(Math.min(ms, remaining)),
2787
+ deadline,
2788
+ );
2789
+ return true;
2790
+ }
2791
+
2792
+ async #throwCommitUnknownForPageChange() {
2793
+ await this.writeDiagnostics("conversation-changed-after-submit");
2794
+ throw new BrowserAdapterError(
2795
+ `${this.providerName} changed conversations after submission, so commit `
2796
+ + "could not be verified. The message was not sent again.",
2797
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
2798
+ );
2799
+ }
2800
+
2801
+ async #waitForSentUserMessage(baseline, {
2802
+ deadline = this.monotonicNow() + this.sendConfirmationTimeoutMs(),
2803
+ expectedText = "",
2804
+ outboundId = null,
2805
+ allowPendingFreshTransition = false,
2806
+ hasPreTransitionMarker = false,
2807
+ } = {}) {
2808
+ let candidateBubbleSignature = null;
2809
+ let candidateBubbleStableSince = 0;
2810
+ const requireFreshCorrelation = allowPendingFreshTransition;
2811
+ const requireOrderedSnapshot = requireFreshCorrelation
2812
+ && this.requiresOrderedConversationSnapshotForFreshSend();
2813
+ const withinDeadline = (promise) => this.#awaitWithinSendDeadline(
2814
+ promise,
2815
+ deadline,
2816
+ );
2817
+
2818
+ while (this.monotonicNow() < deadline) {
902
2819
  this.#throwIfCancelRequested();
903
- const messages = this.userMessages();
904
- const count = await messages.count().catch(() => 0);
905
- for (let index = count - 1; index >= 0; index -= 1) {
906
- const identity = await this.messageIdentity(messages.nth(index));
907
- const newByTurn = identity.turn != null
908
- && (
909
- baseline.maxTurn == null
910
- || identity.turn > baseline.maxTurn
911
- );
912
- const newById = Boolean(
913
- identity.id
914
- && !baseline.ids.has(identity.id),
2820
+ const pageState = await withinDeadline(this.#submittedPageState({
2821
+ allowPendingFreshTransition,
2822
+ }));
2823
+ if (pageState.status === "untrusted") {
2824
+ await this.#throwCommitUnknownForPageChange();
2825
+ }
2826
+ if (pageState.status === "candidate" && hasPreTransitionMarker) {
2827
+ // Existing conversations must first carry a stable pre-navigation marker.
2828
+ // Never bootstrap candidate trust from a message found on the candidate.
2829
+ candidateBubbleSignature = null;
2830
+ candidateBubbleStableSince = 0;
2831
+ await this.#waitBeforeSendDeadline(deadline);
2832
+ continue;
2833
+ }
2834
+
2835
+ const proofNavigated = this.activeSendProof?.candidateNavigationEpoch != null;
2836
+ const requireCorrelation = outboundId != null
2837
+ || requireFreshCorrelation
2838
+ || proofNavigated;
2839
+ let candidate = null;
2840
+ let orderedSnapshot = null;
2841
+ if (requireOrderedSnapshot) {
2842
+ orderedSnapshot = await withinDeadline(
2843
+ this.orderedConversationSnapshot().catch(() => null),
915
2844
  );
916
- if (newByTurn || newById) {
917
- this.sentUserTurn = identity.turn;
918
- return identity;
2845
+ candidate = this.#freshUserFromOrderedSnapshot(orderedSnapshot, {
2846
+ baseline,
2847
+ pageState,
2848
+ expectedText,
2849
+ outboundId,
2850
+ });
2851
+ } else {
2852
+ const snapshot = await withinDeadline(this.userMessageSnapshot());
2853
+ if (snapshot === null) {
2854
+ candidateBubbleSignature = null;
2855
+ candidateBubbleStableSince = 0;
2856
+ await this.#waitBeforeSendDeadline(deadline);
2857
+ continue;
2858
+ }
2859
+ const messages = snapshot === undefined ? this.userMessages() : null;
2860
+ const count = snapshot?.length
2861
+ ?? await withinDeadline(messages.count().catch(() => 0));
2862
+ if (this.activeSendProof) {
2863
+ this.activeSendProof.sample = { userMessageCount: count, markerMatched: false };
2864
+ }
2865
+ for (let index = count - 1; index >= 0; index -= 1) {
2866
+ const message = messages?.nth(index);
2867
+ const identity = snapshot?.[index] ?? await withinDeadline(
2868
+ this.messageIdentity(message),
2869
+ );
2870
+ const newByTurn = identity.turn != null
2871
+ && (baseline.maxTurn == null || identity.turn > baseline.maxTurn);
2872
+ const newById = Boolean(
2873
+ identity.id && !baseline.ids.has(identity.id),
2874
+ );
2875
+ if (!newByTurn && !newById) {
2876
+ continue;
2877
+ }
2878
+
2879
+ if (requireCorrelation) {
2880
+ // A context-dependent send that crossed a route boundary needs the
2881
+ // transport nonce; equal human text is not a conversation identity.
2882
+ if (!requireFreshCorrelation && outboundId == null) {
2883
+ continue;
2884
+ }
2885
+ const renderedText = snapshot?.[index].renderedText ?? await withinDeadline(
2886
+ this.userMessageText(message),
2887
+ );
2888
+ const markerMatched = renderedMessageContains(renderedText, expectedText, outboundId);
2889
+ if (this.activeSendProof) {
2890
+ this.activeSendProof.sample = {
2891
+ userMessageCount: count,
2892
+ candidateId: identity.id ?? null,
2893
+ candidateTurn: identity.turn ?? null,
2894
+ candidateIsLast: index === count - 1,
2895
+ renderedTextLength: renderedText.length,
2896
+ markerMatched,
2897
+ };
2898
+ }
2899
+ if (
2900
+ index !== count - 1
2901
+ || !markerMatched
2902
+ ) {
2903
+ continue;
2904
+ }
2905
+ if (requireFreshCorrelation) {
2906
+ const totalMessages = await withinDeadline(
2907
+ this.conversationMessages().count().catch(() => -1),
2908
+ );
2909
+ if (
2910
+ baseline.count !== 0
2911
+ || count !== 1
2912
+ || totalMessages < 1
2913
+ || totalMessages > 2
2914
+ ) {
2915
+ continue;
2916
+ }
2917
+ }
2918
+ }
2919
+ const proof = this.activeSendProof;
2920
+ candidate = {
2921
+ ...identity,
2922
+ signature: requireCorrelation
2923
+ ? [
2924
+ proof?.sendEpoch ?? "",
2925
+ proof?.candidateNavigationEpoch ?? proof?.startedNavigationEpoch ?? "",
2926
+ pageState.conversationUrl,
2927
+ proof?.targetId ?? "",
2928
+ identity.id ?? "",
2929
+ identity.turn ?? "",
2930
+ outboundId == null
2931
+ ? comparableMessageText(expectedText)
2932
+ : `${OUTBOUND_MARKER_PREFIX}${outboundId}.`,
2933
+ ].join(":")
2934
+ : null,
2935
+ };
2936
+ break;
2937
+ }
2938
+ }
2939
+
2940
+ if (!candidate) {
2941
+ if (
2942
+ !requireOrderedSnapshot
2943
+ || candidateBubbleSignature == null
2944
+ || this.activeSendProof?.invalidated
2945
+ ) {
2946
+ candidateBubbleSignature = null;
2947
+ candidateBubbleStableSince = 0;
2948
+ }
2949
+ await this.#waitBeforeSendDeadline(deadline);
2950
+ continue;
2951
+ }
2952
+
2953
+ if (requireCorrelation) {
2954
+ const observedAt = this.monotonicNow();
2955
+ if (candidate.signature !== candidateBubbleSignature) {
2956
+ candidateBubbleSignature = candidate.signature;
2957
+ candidateBubbleStableSince = observedAt;
2958
+ if (this.restorationCorrelationWindowMs() > 0) {
2959
+ await this.#waitBeforeSendDeadline(deadline);
2960
+ continue;
2961
+ }
2962
+ }
2963
+ if (
2964
+ observedAt - candidateBubbleStableSince
2965
+ < this.restorationCorrelationWindowMs()
2966
+ ) {
2967
+ await this.#waitBeforeSendDeadline(deadline);
2968
+ continue;
2969
+ }
2970
+
2971
+ if (requireOrderedSnapshot) {
2972
+ const finalSnapshot = await withinDeadline(
2973
+ this.orderedConversationSnapshot().catch(() => null),
2974
+ );
2975
+ const finalCandidate = this.#freshUserFromOrderedSnapshot(finalSnapshot, {
2976
+ baseline,
2977
+ pageState,
2978
+ expectedText,
2979
+ outboundId,
2980
+ });
2981
+ if (!finalCandidate || finalCandidate.signature !== candidate.signature) {
2982
+ if (this.activeSendProof?.invalidated) {
2983
+ candidateBubbleSignature = null;
2984
+ candidateBubbleStableSince = 0;
2985
+ }
2986
+ await this.#waitBeforeSendDeadline(deadline);
2987
+ continue;
2988
+ }
919
2989
  }
920
2990
  }
921
- await this.page.waitForTimeout(100);
2991
+
2992
+ const confirmedPage = await withinDeadline(this.#submittedPageState({
2993
+ allowPendingFreshTransition,
2994
+ }));
2995
+ if (confirmedPage.status === "untrusted") {
2996
+ await this.#throwCommitUnknownForPageChange();
2997
+ }
2998
+ if (
2999
+ !sameConversationUrl(
3000
+ confirmedPage.conversationUrl,
3001
+ pageState.conversationUrl,
3002
+ )
3003
+ || (
3004
+ pageState.status === "trusted"
3005
+ && confirmedPage.status !== "trusted"
3006
+ )
3007
+ ) {
3008
+ // The DOM may have been read across a SPA navigation. Retry against one
3009
+ // stable URL rather than binding that mixed snapshot to this send.
3010
+ candidateBubbleSignature = null;
3011
+ candidateBubbleStableSince = 0;
3012
+ await this.#waitBeforeSendDeadline(deadline);
3013
+ continue;
3014
+ }
3015
+ this.sentUserTurn = candidate.turn;
3016
+ return {
3017
+ id: candidate.id ?? null,
3018
+ turn: candidate.turn ?? null,
3019
+ conversationUrl: confirmedPage.conversationUrl,
3020
+ };
922
3021
  }
923
3022
  return null;
924
3023
  }
925
3024
 
926
3025
  async #waitForConversationHistory({
927
3026
  expectedAssistantMessageId = null,
3027
+ expectedUserMessageId = null,
928
3028
  expectedUrl = null,
3029
+ allowCanonicalTransition = false,
929
3030
  attempts = 60,
930
3031
  } = {}) {
931
3032
  let previousSignature = null;
932
3033
  let stableChecks = 0;
933
3034
  let scrolledToBottom = 0;
3035
+ const normalizedExpected = normalizedConversationUrl(expectedUrl);
3036
+
3037
+ if (
3038
+ allowCanonicalTransition
3039
+ && sameConversationUrl(this.page.url(), normalizedExpected)
3040
+ ) {
3041
+ this.#beginConversationObservation(normalizedExpected);
3042
+ }
934
3043
 
935
3044
  for (let attempt = 0; attempt < attempts; attempt += 1) {
936
3045
  this.#throwIfCancelRequested();
937
- const assistant = await this.#captureMessageIdentities(
938
- this.assistantMessages(),
3046
+ const currentUrl = normalizedConversationUrl(this.page.url());
3047
+ const currentKind = currentUrl
3048
+ ? this.classifyConversationUrl(currentUrl)
3049
+ : "invalid";
3050
+ const [assistant, user] = await Promise.all([
3051
+ this.#captureMessageIdentities(this.assistantMessages()),
3052
+ this.#captureMessageIdentities(this.userMessages()),
3053
+ ]);
3054
+ const totalMessages = await this.conversationMessages()
3055
+ .count()
3056
+ .catch(() => -1);
3057
+ const assistantMarkerFound = Boolean(
3058
+ expectedAssistantMessageId
3059
+ && assistant.ids.has(expectedAssistantMessageId)
3060
+ );
3061
+ const userMarkerFound = Boolean(
3062
+ expectedUserMessageId
3063
+ && user.ids.has(expectedUserMessageId)
3064
+ );
3065
+ const markerFound = assistantMarkerFound || userMarkerFound;
3066
+
3067
+ // Page/target continuity does not prove conversation continuity: a user can
3068
+ // navigate that tab. A provisional route's canonical candidate therefore
3069
+ // needs a persisted message id on every sample in the stability window. A
3070
+ // single stale React snapshot must never latch an unrelated canonical URL.
3071
+ const markerCorrelatedTransition = Boolean(
3072
+ allowCanonicalTransition
3073
+ && currentKind === "restorable"
3074
+ && markerFound
3075
+ );
3076
+ const expectedConversation = Boolean(
3077
+ normalizedExpected
3078
+ && (
3079
+ sameConversationUrl(currentUrl, normalizedExpected)
3080
+ || markerCorrelatedTransition
3081
+ )
939
3082
  );
3083
+
3084
+ // The expected assistant marker is near the virtualized tail. Scroll a few
3085
+ // times before falling back to stable URL + non-empty history.
940
3086
  if (
941
3087
  expectedAssistantMessageId
942
- && assistant.ids.has(expectedAssistantMessageId)
3088
+ && !assistantMarkerFound
3089
+ && scrolledToBottom < 3
943
3090
  ) {
944
- return;
945
- }
946
-
947
- // The expected resume marker is the latest reply, near the bottom of a
948
- // virtualized thread: scroll down a few times to force the tail to mount.
949
- if (expectedAssistantMessageId && scrolledToBottom < 3) {
950
3091
  await this.scrollConversationToBottom();
951
3092
  scrolledToBottom += 1;
952
3093
  }
953
3094
 
954
- const totalMessages = await this.conversationMessages()
955
- .count()
956
- .catch(() => 0);
957
3095
  const signature = [
3096
+ currentUrl ?? "",
3097
+ currentKind,
3098
+ expectedConversation,
958
3099
  totalMessages,
959
3100
  assistant.count,
960
3101
  assistant.maxTurn ?? "",
961
3102
  [...assistant.ids].join(","),
3103
+ user.count,
3104
+ user.maxTurn ?? "",
3105
+ [...user.ids].join(","),
962
3106
  ].join(":");
963
- if (totalMessages > 0 && signature === previousSignature) {
964
- stableChecks += 1;
965
- if (stableChecks >= 3) {
966
- // With an expected id we normally return as soon as it appears;
967
- // reaching stability instead means the id is not mounted or has been
968
- // deleted (providers remove transient error/limit cards, and the last
969
- // recorded reply can be one). Accept when the URL still proves this
970
- // is the expected conversation.
971
- if (
972
- !expectedAssistantMessageId
973
- || !expectedUrl
974
- || sameConversationUrl(this.page.url(), expectedUrl)
975
- ) {
976
- return;
3107
+ stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
3108
+ previousSignature = signature;
3109
+
3110
+ if (stableChecks >= RESTORATION_STABLE_CHECKS) {
3111
+ if (expectedConversation && totalMessages > 0) {
3112
+ const rebindActiveTurn = this.activeTurnConversationUrl != null;
3113
+ this.#beginConversationObservation(currentUrl, {
3114
+ arm: false,
3115
+ reset: true,
3116
+ });
3117
+ if (rebindActiveTurn) {
3118
+ this.#captureActiveTurnProof(currentUrl);
977
3119
  }
3120
+ this.#publishConversationIdentity();
3121
+ return {
3122
+ status: "restored-existing",
3123
+ conversationUrl: currentUrl,
3124
+ ...(this.cdpChrome?.targetId
3125
+ ? { targetId: this.cdpChrome.targetId }
3126
+ : {}),
3127
+ };
3128
+ }
3129
+ if (currentKind === "fresh" && totalMessages === 0) {
3130
+ // A durable/provisional route may have expired and redirected to the
3131
+ // provider's explicit empty new-chat route. The prolonged stable check
3132
+ // prevents an early composer mount from masquerading as lost history.
3133
+ this.expectedRestorationAssistantId = null;
3134
+ this.expectedRestorationUserId = null;
3135
+ this.lastAssistantMessageId = null;
3136
+ this.lastUserMessageId = null;
3137
+ this.activeTurnConversationUrl = null;
3138
+ this.#beginConversationObservation(currentUrl, {
3139
+ arm: false,
3140
+ reset: true,
3141
+ });
3142
+ return {
3143
+ status: "verified-fresh",
3144
+ conversationUrl: currentUrl,
3145
+ ...(this.cdpChrome?.targetId
3146
+ ? { targetId: this.cdpChrome.targetId }
3147
+ : {}),
3148
+ };
978
3149
  }
979
- } else {
980
- stableChecks = 0;
981
- previousSignature = signature;
982
3150
  }
983
3151
 
984
3152
  await this.page.waitForTimeout(250);
@@ -1006,8 +3174,9 @@ export class BaseWebAdapter {
1006
3174
  }
1007
3175
 
1008
3176
  // While a turn is being processed, raw-mode stdin lets ESC cancel the wait.
1009
- // Raw mode swallows Ctrl+C, so forward it as a real SIGINT so the CLI's
1010
- // existing interrupt path still runs. The returned detach restores the
3177
+ // Raw mode swallows Ctrl+C, so dispatch the CLI's SIGINT event internally.
3178
+ // process.kill(self, "SIGINT") would terminate Windows without cleanup.
3179
+ // The returned detach restores the
1011
3180
  // previous terminal mode, keeping approval prompts (which also read stdin)
1012
3181
  // working. Keys other than ESC/Ctrl+C are consumed and dropped.
1013
3182
  //
@@ -1026,7 +3195,7 @@ export class BaseWebAdapter {
1026
3195
  if (key?.name === "escape") {
1027
3196
  this.escCancelRequested = true;
1028
3197
  } else if (key?.ctrl && key?.name === "c") {
1029
- process.kill(process.pid, "SIGINT");
3198
+ process.emit("SIGINT", "SIGINT");
1030
3199
  }
1031
3200
  };
1032
3201
  stream.on("keypress", onKeypress);
@@ -1119,24 +3288,72 @@ export class BaseWebAdapter {
1119
3288
  throw new BrowserAdapterError("Browser access challenge detected.");
1120
3289
  }
1121
3290
 
1122
- async writeDiagnostics(label) {
1123
- if (!this.debug || !this.page) {
1124
- return;
3291
+ hasStructuralDiagnostics() {
3292
+ return false;
3293
+ }
3294
+
3295
+ recordConversationNavigation(_url) {}
3296
+
3297
+ async structuralDiagnostics() {
3298
+ return null;
3299
+ }
3300
+
3301
+ async collectStructuralDiagnostics() {
3302
+ if (!this.hasStructuralDiagnostics()) return null;
3303
+ let timer;
3304
+ try {
3305
+ return await Promise.race([
3306
+ Promise.resolve().then(() => this.structuralDiagnostics()).catch(() => ({ status: "unavailable" })),
3307
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ status: "timeout" }), 500); }),
3308
+ ]);
3309
+ } finally {
3310
+ clearTimeout(timer);
1125
3311
  }
1126
- const directory = path.join(this.profileDir, "..", "diagnostics");
1127
- await ensureDirectory(directory);
3312
+ }
3313
+
3314
+ async writeDiagnostics(label, details = null) {
3315
+ if (!this.page || (!this.debug && !this.hasStructuralDiagnostics())) return;
3316
+ const page = this.page;
3317
+ const proof = this.activeSendProof;
1128
3318
  const stamp = Date.now();
1129
- await Promise.all([
1130
- this.page.screenshot({
1131
- path: path.join(directory, `${stamp}-${label}.png`),
1132
- fullPage: true,
1133
- }).catch(() => null),
1134
- fs.writeFile(
1135
- path.join(directory, `${stamp}-${label}.html`),
1136
- await this.page.content(),
1137
- "utf8",
1138
- ).catch(() => null),
1139
- ]);
3319
+ // Capture the stage before awaiting CDP. Never log prompts, correlation IDs,
3320
+ // raw URLs, or arbitrary provider error text in the default JSON report.
3321
+ const report = {
3322
+ schemaVersion: 1,
3323
+ provider: this.providerName,
3324
+ label,
3325
+ capturedAt: new Date(stamp).toISOString(),
3326
+ stage: details?.stage ?? proof?.stage ?? "unknown",
3327
+ sendStatus: this.lastSendStatus,
3328
+ elapsedMs: proof?.submittedAt == null ? null : Math.round(this.monotonicNow() - proof.submittedAt),
3329
+ routes: [...(this.diagnosticRoutes ?? [])],
3330
+ };
3331
+ const directory = path.join(this.profileDir, "..", "diagnostics");
3332
+ const filename = path.join(directory, `${stamp}-${label}.json`);
3333
+ const capture = async () => {
3334
+ const dom = await this.collectStructuralDiagnostics();
3335
+ if (dom) report.dom = dom;
3336
+ if (this.debug && details != null) report.details = details;
3337
+ await ensureDirectory(directory);
3338
+ await fs.writeFile(filename, JSON.stringify(report, null, 2), "utf8");
3339
+ if (this.debug) {
3340
+ await Promise.all([
3341
+ page.screenshot({ path: path.join(directory, `${stamp}-${label}.png`), fullPage: true, timeout: 2_000 }).catch(() => null),
3342
+ page.content().then((html) => fs.writeFile(path.join(directory, `${stamp}-${label}.html`), html, "utf8")).catch(() => null),
3343
+ ]);
3344
+ }
3345
+ return { filename, report };
3346
+ };
3347
+ let timer;
3348
+ try {
3349
+ // A dead CDP connection or disk must not mask the original failure.
3350
+ return await Promise.race([
3351
+ capture().catch(() => null),
3352
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), 2_500); }),
3353
+ ]);
3354
+ } finally {
3355
+ clearTimeout(timer);
3356
+ }
1140
3357
  }
1141
3358
 
1142
3359
  // Non-private so provider subclasses (e.g. attachFiles) can guard too.