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