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