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