wtagent 0.1.0 → 0.2.1

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.
@@ -0,0 +1,1178 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { launchAndConnectCdpChrome } from "./cdp-browser.js";
5
+ import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
6
+ import { ensureDirectory } from "../platform/paths.js";
7
+ import { BrowserAdapterError } from "../shared/errors.js";
8
+ import { runModeSelection } from "./mode-selection.js";
9
+
10
+ // Playwright error messages for a dead transport. The Chrome process itself is
11
+ // usually still alive (e.g. the connection died while the Mac slept); these
12
+ // errors mean "reconnect", not "the browser is gone". This is a transport-level
13
+ // concern shared by every provider, so it lives on the base module.
14
+ const CONNECTION_LOST_PATTERNS = [
15
+ "target page, context or browser has been closed",
16
+ "browser has been closed",
17
+ "page has been closed",
18
+ "connection closed",
19
+ "connection is closed",
20
+ ];
21
+
22
+ export function isConnectionLostError(error) {
23
+ const message = String(error?.message ?? "").toLowerCase();
24
+ return CONNECTION_LOST_PATTERNS.some((pattern) => message.includes(pattern));
25
+ }
26
+
27
+ // Interstitial text of the common anti-bot challenges, across the locales the
28
+ // providers ship. Used ONLY on pages with no composer (see throwIfBlockedPage),
29
+ // so a model reply mentioning these phrases can never trigger it.
30
+ const CHALLENGE_BODY_PATTERN =
31
+ /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
+
33
+ export async function firstVisible(locators) {
34
+ for (const locator of locators) {
35
+ const count = await locator.count().catch(() => 0);
36
+ for (let index = 0; index < count; index += 1) {
37
+ const item = locator.nth(index);
38
+ if (await item.isVisible().catch(() => false)) {
39
+ return item;
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function deltaFrom(previous, current) {
47
+ if (!previous) return current;
48
+ if (current.startsWith(previous)) return current.slice(previous.length);
49
+ return "";
50
+ }
51
+
52
+ export function hasCompleteAgentEnvelope(text) {
53
+ const trimmed = String(text ?? "").trim();
54
+ const start = trimmed.indexOf("<agent_response");
55
+ const endTag = "</agent_response>";
56
+ const end = trimmed.lastIndexOf(endTag);
57
+ // The envelope is "complete" as soon as both the opening and closing tags
58
+ // are present. The web model may append trailing text or render rich cards
59
+ // after the XML, so we do not require the closing tag to be the last content.
60
+ return start >= 0 && end >= start;
61
+ }
62
+
63
+ function sameConversationUrl(left, right) {
64
+ try {
65
+ const leftUrl = new URL(left);
66
+ const rightUrl = new URL(right);
67
+ return leftUrl.origin === rightUrl.origin
68
+ && leftUrl.pathname === rightUrl.pathname;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // Provider-independent orchestration for a web-AI conversation driven over CDP.
75
+ //
76
+ // The runtime talks to this class only through its public methods; everything
77
+ // that varies between providers (ChatGPT, DeepSeek, …) is isolated behind the
78
+ // overridable "primitive" methods below. A concrete provider subclass supplies
79
+ // its base URL, DOM locators, message-identity extraction, and (optionally) a
80
+ // model-switcher port; it must NOT re-implement the turn-completion loop, the
81
+ // send/auth/reconnect flow, or the WTAgent <agent_response> protocol timing.
82
+ //
83
+ // IMPORTANT (JavaScript semantics): primitives that the base dispatches to a
84
+ // provider are declared as ordinary (non-#private) methods. `#private` methods
85
+ // are resolved lexically and are NOT overridable, so a base method calling
86
+ // `this.#foo()` would never reach a subclass override. Only truly base-internal
87
+ // helpers that no subclass overrides or calls stay `#private`.
88
+ export class BaseWebAdapter {
89
+ constructor({
90
+ profileDir,
91
+ chromePath,
92
+ baseUrl,
93
+ providerName = "Web",
94
+ debug = false,
95
+ minimized = false,
96
+ stdinStream = process.stdin,
97
+ cancelOnEsc = false,
98
+ }) {
99
+ this.profileDir = path.resolve(profileDir);
100
+ this.chromePath = chromePath;
101
+ this.baseUrl = baseUrl;
102
+ this.providerName = providerName;
103
+ this.debug = debug;
104
+ this.minimized = minimized;
105
+ this.stdinStream = stdinStream;
106
+ this.cancelOnEsc = cancelOnEsc;
107
+ this.escCancelRequested = false;
108
+ this.context = null;
109
+ this.cdpChrome = null;
110
+ this.page = null;
111
+ this.assistantIdsBeforeSend = new Set();
112
+ this.assistantMaxTurnBeforeSend = null;
113
+ this.assistantCountBeforeSend = 0;
114
+ this.sentUserTurn = null;
115
+ this.lastAssistantMessageId = null;
116
+ this.lastModeSelection = null;
117
+ }
118
+
119
+ // ---- provider primitives (override in subclasses) ----------------------
120
+ // Defaults are deliberately inert: locator lists are empty, identity is
121
+ // unknown, and optional UI steps are no-ops. A provider that leaves a
122
+ // required primitive unimplemented simply never finds its controls, which
123
+ // surfaces as a clear "not found" adapter error rather than a silent guess.
124
+
125
+ conversationUrlPattern() {
126
+ // RegExp tested against a URL pathname to tell a live conversation from the
127
+ // provider's home/new-chat page. null means "no distinct conversation URL".
128
+ return null;
129
+ }
130
+
131
+ composerLocators() {
132
+ return [];
133
+ }
134
+
135
+ sendButtonLocators() {
136
+ return [];
137
+ }
138
+
139
+ stopButtonLocators() {
140
+ return [];
141
+ }
142
+
143
+ newConversationControls() {
144
+ return [];
145
+ }
146
+
147
+ loginControlLocators() {
148
+ return [];
149
+ }
150
+
151
+ authTextPattern() {
152
+ // Body text that only a signed-out/guest shell renders. The default never
153
+ // matches, so a provider that omits it relies on login controls + composer
154
+ // presence alone.
155
+ return /(?!)/;
156
+ }
157
+
158
+ // Pathname regex that only a signed-out page has (e.g. ChatGPT's /auth/…,
159
+ // DeepSeek's /sign_in, GLM's /auth). URL checks are locale-independent, so a
160
+ // provider that redirects logged-out visitors to a login path should override
161
+ // this; getAuthState() consults it before any text pattern. null = no signal.
162
+ authUrlPattern() {
163
+ return null;
164
+ }
165
+
166
+ assistantMessages() {
167
+ return this.page.locator("[data-web-adapter-unset-assistant]");
168
+ }
169
+
170
+ userMessages() {
171
+ return this.page.locator("[data-web-adapter-unset-user]");
172
+ }
173
+
174
+ conversationMessages() {
175
+ return this.page.locator("[data-web-adapter-unset-message]");
176
+ }
177
+
178
+ // Extract a provider's stable identity for one message node. `id` is a stable
179
+ // per-message id when the DOM exposes one; `turn` is a monotonic ordinal for
180
+ // the message's position in the thread. Either may be null.
181
+ async messageIdentity(_message) {
182
+ return { id: null, turn: null };
183
+ }
184
+
185
+ // Full assistant text for one message node, preserving a complete
186
+ // <agent_response> envelope even when the provider splits it across nodes.
187
+ async assistantText(message) {
188
+ return await message.innerText().catch(() => "");
189
+ }
190
+
191
+ // Returns a short human string when the message is a plan/usage-limit notice,
192
+ // or null when it is an ordinary reply. Default: never a limit.
193
+ async findUsageLimitMarker(_message) {
194
+ return null;
195
+ }
196
+
197
+ // Returns a short human string when the message is a provider-side
198
+ // generation FAILURE card (e.g. ChatGPT's "Internal Server Error" with a
199
+ // 重试 button) rather than a real answer, or null for ordinary replies.
200
+ // Such cards must never be treated as a final plain answer — the runtime
201
+ // nudges the model to regenerate instead. Default: never a failure card.
202
+ async findGenerationErrorMarker(_message) {
203
+ return null;
204
+ }
205
+
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
+ // Best-effort composer file upload. Default: nothing attached.
224
+ async attachFiles(_files) {
225
+ return { attached: [], failed: [] };
226
+ }
227
+
228
+ // Dismiss a provider's transient modal that would block the composer. No-op
229
+ // by default.
230
+ async dismissTransientOverlays() {}
231
+
232
+ // Put outbound text into the provider composer. Most sites accept
233
+ // Locator.fill(); providers whose UI depends on native focus/pointer events
234
+ // can override this primitive without duplicating sendMessage().
235
+ async fillComposer(composer, text) {
236
+ try {
237
+ await composer.fill(text);
238
+ } catch {
239
+ await this.dismissTransientOverlays();
240
+ await composer.focus();
241
+ await this.page.keyboard.press(
242
+ process.platform === "darwin" ? "Meta+A" : "Control+A",
243
+ );
244
+ await this.page.keyboard.insertText(text);
245
+ }
246
+ }
247
+
248
+ // Submit the already-filled composer. Providers that need a native pointer
249
+ // sequence may override this while retaining the shared post-send identity
250
+ // checks and one-shot retry.
251
+ async submitComposer(composer) {
252
+ const sendButton = await firstVisible(this.sendButtonLocators());
253
+ if (sendButton && await sendButton.isEnabled().catch(() => false)) {
254
+ try {
255
+ await sendButton.click();
256
+ return;
257
+ } catch {
258
+ await this.dismissTransientOverlays();
259
+ }
260
+ }
261
+ await composer.press("Enter");
262
+ }
263
+
264
+ // Scroll a virtualized thread so the latest replies mount. No-op by default.
265
+ async scrollConversationToBottom() {}
266
+
267
+ // True while the latest assistant turn is still working even if no labeled
268
+ // stop button is visible. Default false. Providers that hide generation
269
+ // behind native tool cards (Kimi) override this so the turn loop does not
270
+ // complete on a mid-tool failure and then try to send into a live reply.
271
+ async isAssistantGenerating(_message) {
272
+ return false;
273
+ }
274
+
275
+ // Extra time the latest assistant text must stay unchanged before the turn
276
+ // is accepted. Default 0. Used when a provider may still append a protocol
277
+ // envelope after a native-tool card has already gone quiet.
278
+ async extraStableWindowMs(_message, _text) {
279
+ return 0;
280
+ }
281
+
282
+ // True when the provider exposes a STRUCTURAL signal that reliably
283
+ // distinguishes "generating" from "finished" — a stop control present only
284
+ // while generating, or an action bar rendered only after completion
285
+ // (Kimi/GLM/DeepSeek).
286
+ hasReliableCompletionSignal() {
287
+ return false;
288
+ }
289
+
290
+ // How long an incomplete envelope must stay unchanged before it is accepted
291
+ // as a truncated (finished) reply. Providers with a reliable completion
292
+ // signal can use a short window; providers without one (ChatGPT's new UI
293
+ // removed [data-testid="stop-button"] and has no other structural signal)
294
+ // need a much longer window: a mid-stream pause must not be mistaken for a
295
+ // finished reply, because the follow-up format nudge aborts ChatGPT's
296
+ // in-flight generation. Genuine streaming pauses essentially never exceed
297
+ // the long window, so a truly finished-but-truncated reply is still
298
+ // recovered well before the turn timeout.
299
+ truncatedEnvelopeGraceMs() {
300
+ return this.hasReliableCompletionSignal() ? 10_000 : 90_000;
301
+ }
302
+
303
+ // How long sendMessage() waits for the user bubble to appear. GLM can take
304
+ // several seconds to commit a filled textarea, especially after a long tool
305
+ // result, so providers may widen this.
306
+ sentUserWaitAttempts() {
307
+ return 50;
308
+ }
309
+
310
+ // Decide whether `candidate` is a genuinely new assistant reply for the
311
+ // current send. Default ladder: the user turn sendMessage() observed, then a
312
+ // stable message id, then a turn high-water mark; fail closed when none is
313
+ // available (never guess by count or text — see design doc §9.3). Providers
314
+ // whose DOM lacks stable ids/turns may override this with a count baseline.
315
+ isNewAssistantIdentity({ id, turn }) {
316
+ if (this.sentUserTurn != null && turn != null) {
317
+ return turn > this.sentUserTurn;
318
+ }
319
+ if (id) {
320
+ return !this.assistantIdsBeforeSend.has(id);
321
+ }
322
+ if (turn != null && this.assistantMaxTurnBeforeSend != null) {
323
+ return turn > this.assistantMaxTurnBeforeSend;
324
+ }
325
+ return false;
326
+ }
327
+
328
+ // Multiplier applied to the dead-request grace window. Dead-request detection
329
+ // assumes a provider proves liveness with a visible stop button while it is
330
+ // working; a provider that exposes NO such signal (so `stopButtonLocators`
331
+ // never matches during generation) needs a wider window, or its long
332
+ // "thinking" phase before the first token is misread as a dead request.
333
+ // Default 1 (ChatGPT, which has a reliable stop button); providers without a
334
+ // liveness signal override this. Applies to both the initial and the
335
+ // post-signal grace.
336
+ deadRequestGraceMultiplier() {
337
+ return 1;
338
+ }
339
+
340
+ // ---- lifecycle ---------------------------------------------------------
341
+
342
+ // `preferredUrl` lets a reused Chrome pick an existing tab that already
343
+ // shows the conversation (instead of opening a new tab per run).
344
+ async launch(preferredUrl = null) {
345
+ if (this.context) {
346
+ return;
347
+ }
348
+ await ensureDirectory(this.profileDir);
349
+ const executablePath = discoverChromeExecutable(this.chromePath);
350
+
351
+ this.cdpChrome = await launchAndConnectCdpChrome({
352
+ executablePath,
353
+ profileDir: this.profileDir,
354
+ minimized: this.minimized,
355
+ preferredUrl,
356
+ });
357
+ this.context = this.cdpChrome.context;
358
+ this.page = this.cdpChrome.page;
359
+ this.page.setDefaultTimeout(15_000);
360
+ this.page.setDefaultNavigationTimeout(60_000);
361
+ await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
362
+ }
363
+
364
+ async close() {
365
+ await this.cdpChrome?.close();
366
+ this.cdpChrome = null;
367
+ this.context = null;
368
+ this.page = null;
369
+ }
370
+
371
+ // Leave Chrome open for inspection, but drop the CDP transport and the
372
+ // profile lock so this Node process can exit and the next wtagent can reuse
373
+ // the same window. close() would quit Chrome; disconnect() would keep the
374
+ // lock held by this process.
375
+ async detach() {
376
+ await this.cdpChrome?.detach?.().catch(() => null);
377
+ this.cdpChrome = null;
378
+ this.context = null;
379
+ this.page = null;
380
+ }
381
+
382
+ // Re-establishes the CDP connection to a still-alive Chrome after the
383
+ // Playwright transport died mid-run (e.g. the Mac slept). launch() reuses
384
+ // the saved CDP state, so Chrome is neither relaunched nor killed; an
385
+ // existing tab on the preferred conversation is reused when available.
386
+ async reconnect(preferredUrl = null) {
387
+ await this.cdpChrome?.disconnect?.().catch(() => null);
388
+ this.cdpChrome = null;
389
+ this.context = null;
390
+ this.page = null;
391
+ await this.launch(preferredUrl);
392
+ }
393
+
394
+ // Bring the window forward (used before asking the user to log in or solve a
395
+ // challenge). No-op when the window was never minimized.
396
+ async restoreWindow() {
397
+ if (this.minimized) {
398
+ await this.cdpChrome?.restore?.();
399
+ }
400
+ }
401
+
402
+ // Send the window back to minimized after the user is done. Only re-minimizes
403
+ // when this run launched minimized in the first place.
404
+ async minimizeWindow() {
405
+ if (this.minimized) {
406
+ await this.cdpChrome?.minimize?.();
407
+ }
408
+ }
409
+
410
+ async getAuthState() {
411
+ this.requirePage();
412
+ // Locale-independent first: a logged-out page on a known auth path is
413
+ // unauthenticated no matter which language the UI renders in.
414
+ const authUrl = this.authUrlPattern();
415
+ if (authUrl) {
416
+ try {
417
+ if (authUrl.test(new URL(this.page.url()).pathname)) {
418
+ return "unauthenticated";
419
+ }
420
+ } catch {
421
+ // Non-URL states (about:blank etc.) fall through to the DOM checks.
422
+ }
423
+ }
424
+ if (await this.#findLoginControl()) {
425
+ return "unauthenticated";
426
+ }
427
+
428
+ const body = await this.page.locator("body").innerText().catch(() => "");
429
+ if (this.authTextPattern().test(body)) {
430
+ return "unauthenticated";
431
+ }
432
+
433
+ return await this.#findComposer() ? "authenticated" : "unknown";
434
+ }
435
+
436
+ async waitForManualLogin({ timeoutMs }) {
437
+ this.requirePage();
438
+ const deadline = Date.now() + timeoutMs;
439
+ let consecutiveAuthenticatedChecks = 0;
440
+
441
+ while (Date.now() < deadline) {
442
+ this.#throwIfCancelRequested();
443
+ if (await this.getAuthState() === "authenticated") {
444
+ consecutiveAuthenticatedChecks += 1;
445
+ if (consecutiveAuthenticatedChecks >= 5) {
446
+ return;
447
+ }
448
+ } else {
449
+ consecutiveAuthenticatedChecks = 0;
450
+ }
451
+ await this.page.waitForTimeout(1_000);
452
+ }
453
+
454
+ throw new BrowserAdapterError(
455
+ `Login was not detected within ${Math.round(timeoutMs / 60_000)} minutes.`,
456
+ { code: "LOGIN_TIMEOUT" },
457
+ );
458
+ }
459
+
460
+ async startConversation(
461
+ conversationUrl = null,
462
+ { expectedAssistantMessageId = null } = {},
463
+ ) {
464
+ this.requirePage();
465
+ let target = this.baseUrl;
466
+ let resumesExistingConversation = Boolean(conversationUrl);
467
+ if (conversationUrl) {
468
+ const parsed = new URL(conversationUrl);
469
+ const base = new URL(this.baseUrl);
470
+ if (parsed.protocol !== "https:" || parsed.hostname !== base.hostname) {
471
+ throw new BrowserAdapterError(
472
+ `Refusing to open a conversation outside ${base.hostname}.`,
473
+ { code: "INVALID_CONVERSATION_URL" },
474
+ );
475
+ }
476
+ target = parsed.href;
477
+ const conversationPattern = this.conversationUrlPattern();
478
+ // A run can fail before the first message is committed. In that case its
479
+ // saved URL is the provider's home/new-chat page, not a conversation to
480
+ // hydrate. Resume it as a verified fresh chat instead of waiting forever
481
+ // for history that cannot exist.
482
+ if (conversationPattern && !conversationPattern.test(parsed.pathname)) {
483
+ resumesExistingConversation = false;
484
+ }
485
+ }
486
+
487
+ const reuseCurrent = Boolean(
488
+ conversationUrl
489
+ && sameConversationUrl(this.page.url(), target),
490
+ );
491
+ if (!reuseCurrent) {
492
+ await this.page.goto(target, { waitUntil: "domcontentloaded" });
493
+ }
494
+ const composer = await this.#waitForComposer(30_000);
495
+ if (!composer) {
496
+ throw new BrowserAdapterError(
497
+ `${this.providerName} composer was not found after opening a new conversation.`,
498
+ { code: "COMPOSER_NOT_FOUND" },
499
+ );
500
+ }
501
+
502
+ if (resumesExistingConversation) {
503
+ await this.#waitForConversationHistory({
504
+ expectedAssistantMessageId,
505
+ expectedUrl: target,
506
+ });
507
+ return;
508
+ }
509
+
510
+ if (!resumesExistingConversation && !await this.#isFreshConversation()) {
511
+ await this.#openNewConversation();
512
+ const freshComposer = await this.#waitForComposer(30_000);
513
+ if (!freshComposer || !await this.#isFreshConversation()) {
514
+ await this.writeDiagnostics("conversation-not-fresh");
515
+ throw new BrowserAdapterError(
516
+ `${this.providerName} did not open a verified empty conversation. `
517
+ + "Refusing to send a new session prompt into an existing chat.",
518
+ { code: "CONVERSATION_NOT_FRESH" },
519
+ );
520
+ }
521
+ }
522
+ }
523
+
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
+ async getConversationUrl() {
537
+ this.requirePage();
538
+ return this.page.url();
539
+ }
540
+
541
+ async getLastAssistantMessageId() {
542
+ return this.lastAssistantMessageId;
543
+ }
544
+
545
+ async sendMessage(text, { files = [], maxBytes = null } = {}) {
546
+ this.requirePage();
547
+ const messageBytes = Buffer.byteLength(String(text ?? ""), "utf8");
548
+ if (maxBytes != null && messageBytes > maxBytes) {
549
+ throw new BrowserAdapterError(
550
+ `Outbound message is ${messageBytes} bytes; the limit is ${maxBytes} bytes.`,
551
+ {
552
+ code: "OUTBOUND_MESSAGE_TOO_LARGE",
553
+ details: { messageBytes, maxBytes },
554
+ },
555
+ );
556
+ }
557
+ const urlBeforeSend = this.page.url();
558
+ const composer = await this.#waitForComposer(30_000);
559
+ if (!composer) {
560
+ throw new BrowserAdapterError(
561
+ `${this.providerName} composer is unavailable.`,
562
+ { code: "COMPOSER_NOT_FOUND" },
563
+ );
564
+ }
565
+
566
+ await this.dismissTransientOverlays();
567
+ await this.#waitUntilReadyToSend();
568
+
569
+ // Attach any @file uploads before typing/sending. Upload is best-effort: a
570
+ // failure is reported to the caller but does not block sending the text.
571
+ let attachment = null;
572
+ if (files.length > 0) {
573
+ attachment = await this.attachFiles(files);
574
+ }
575
+
576
+ const assistantMessages = this.assistantMessages();
577
+ const assistantBaseline = await this.#captureMessageIdentities(
578
+ assistantMessages,
579
+ );
580
+ const userBaseline = await this.#captureMessageIdentities(
581
+ this.userMessages(),
582
+ );
583
+ this.assistantIdsBeforeSend = assistantBaseline.ids;
584
+ this.assistantMaxTurnBeforeSend = assistantBaseline.maxTurn;
585
+ // Count baseline for providers whose DOM exposes no stable per-message id
586
+ // or turn ordinal (they override isNewAssistantIdentity to use it).
587
+ this.assistantCountBeforeSend = assistantBaseline.count;
588
+ this.lastAssistantTextBeforeSend = assistantBaseline.count > 0
589
+ ? await this.assistantText(assistantMessages.last()).catch(() => "")
590
+ : "";
591
+ this.sentUserTurn = null;
592
+
593
+ await this.fillComposer(composer, text);
594
+
595
+ await this.submitComposer(composer);
596
+
597
+ const conversationPattern = this.conversationUrlPattern();
598
+ if (
599
+ conversationPattern
600
+ && !conversationPattern.test(new URL(urlBeforeSend).pathname)
601
+ ) {
602
+ await this.page.waitForURL(
603
+ (url) => conversationPattern.test(url.pathname),
604
+ { timeout: 5_000 },
605
+ ).catch(() => null);
606
+ }
607
+
608
+ let sentMessage = await this.#waitForSentUserMessage(userBaseline);
609
+ if (!sentMessage) {
610
+ // A click/Enter can be ignored while a native-tool card is still open.
611
+ // Retry once after the composer is idle again.
612
+ await this.#waitUntilReadyToSend(5_000);
613
+ await this.submitComposer(composer);
614
+ sentMessage = await this.#waitForSentUserMessage(userBaseline);
615
+ }
616
+ if (!sentMessage) {
617
+ // The model never rendered the message: the send did not register (a
618
+ // disabled send button, a missed Enter, or a transient UI state). Fail
619
+ // loudly instead of pretending the message went out — otherwise the
620
+ // runtime waits for a reply that was never received.
621
+ await this.writeDiagnostics("send-not-detected");
622
+ throw new BrowserAdapterError(
623
+ `${this.providerName} did not render the sent message; the send may have failed.`,
624
+ { code: "SEND_NOT_DETECTED" },
625
+ );
626
+ }
627
+ return { attachment };
628
+ }
629
+
630
+ async waitForTurnComplete({
631
+ timeoutMs,
632
+ stableWindowMs,
633
+ staleStopWindowMs = 15_000,
634
+ truncatedEnvelopeWindowMs = null,
635
+ emptyResponseWindowMs = 10_000,
636
+ deadRequestGraceMs = 60_000,
637
+ onDelta,
638
+ }) {
639
+ this.requirePage();
640
+ const deadline = Date.now() + timeoutMs;
641
+ const startedAt = Date.now();
642
+ let lastText = "";
643
+ let stableSince = 0;
644
+ let sawAssistant = false;
645
+ let emptySince = 0;
646
+ let emptyCandidate = null;
647
+ let sawGenerationSignal = false;
648
+ let lastGenerationSignalAt = 0;
649
+ const detachEscCancel = this.#attachEscCancel();
650
+ const truncatedGraceMs = truncatedEnvelopeWindowMs
651
+ ?? this.truncatedEnvelopeGraceMs();
652
+ try {
653
+ while (Date.now() < deadline) {
654
+ await this.throwIfBlockedPage();
655
+
656
+ if (this.escCancelRequested) {
657
+ // ESC or Ctrl+C during processing: stop generating and hand control
658
+ // back. Clicking the provider's stop button halts the in-flight reply.
659
+ await this.#clickStopButton();
660
+ this.escCancelRequested = false;
661
+ throw new BrowserAdapterError(
662
+ "Turn cancelled by user.",
663
+ { code: "TURN_CANCELLED" },
664
+ );
665
+ }
666
+
667
+ const messages = this.assistantMessages();
668
+ const count = await messages.count();
669
+ const lastMessage = count > 0 ? messages.last() : null;
670
+ const candidateText = lastMessage
671
+ ? await this.assistantText(lastMessage)
672
+ : "";
673
+ const { id: candidateId, turn: candidateTurn } = lastMessage
674
+ ? await this.messageIdentity(lastMessage)
675
+ : { id: null, turn: null };
676
+ const hasNewAssistant = this.isNewAssistantIdentity({
677
+ id: candidateId,
678
+ turn: candidateTurn,
679
+ text: candidateText,
680
+ });
681
+
682
+ const stopVisible = await this.#isStopButtonVisible();
683
+ const assistantGenerating = lastMessage
684
+ ? await this.isAssistantGenerating(lastMessage)
685
+ : false;
686
+ const generating = stopVisible || assistantGenerating;
687
+ if (hasNewAssistant || generating) {
688
+ // A reply node or a visible stop button proves generation started;
689
+ // only a request with neither signal can be dead.
690
+ sawGenerationSignal = true;
691
+ lastGenerationSignalAt = Date.now();
692
+ }
693
+
694
+ if (hasNewAssistant) {
695
+ sawAssistant = true;
696
+ const text = candidateText;
697
+ if (text !== lastText) {
698
+ const delta = deltaFrom(lastText, text);
699
+ lastText = text;
700
+ stableSince = Date.now();
701
+ if (delta) {
702
+ await onDelta?.(delta);
703
+ }
704
+ }
705
+
706
+ if (!text.trim() && !generating) {
707
+ // The model can create a real assistant turn and finish it without
708
+ // rendering any content. Once that exact empty node remains stopped
709
+ // for a short grace period, fail early instead of waiting for the
710
+ // full model timeout. A different node restarts the grace period.
711
+ const candidateIdentity = candidateId
712
+ ?? (candidateTurn == null ? null : `turn:${candidateTurn}`);
713
+ if (candidateIdentity !== emptyCandidate) {
714
+ emptyCandidate = candidateIdentity;
715
+ emptySince = Date.now();
716
+ }
717
+ if (
718
+ emptySince > 0
719
+ && Date.now() - emptySince >= emptyResponseWindowMs
720
+ ) {
721
+ this.lastAssistantMessageId = candidateId;
722
+ throw new BrowserAdapterError(
723
+ `${this.providerName} completed an assistant turn without any content.`,
724
+ {
725
+ code: "EMPTY_ASSISTANT_RESPONSE",
726
+ details: {
727
+ assistantMessageId: candidateId,
728
+ assistantTurn: candidateTurn,
729
+ },
730
+ },
731
+ );
732
+ }
733
+ } else {
734
+ // Generation is still active, or text has begun rendering. Only an
735
+ // empty and stopped reply should consume the empty-response window.
736
+ emptyCandidate = null;
737
+ emptySince = 0;
738
+ }
739
+ // If the reply looks like protocol XML, never accept it until BOTH the
740
+ // opening and closing tags are present. During streaming the text can
741
+ // briefly go quiet (or the stop button flip off) after "<agent_response"
742
+ // is painted but before "</agent_response>" arrives; accepting there
743
+ // hands the parser a truncated envelope. Non-protocol chatter (no
744
+ // "<agent_response") is unaffected and still completes on the stable
745
+ // window below.
746
+ const looksLikeProtocol = lastText.includes("<agent_response");
747
+ const envelopeReady = !looksLikeProtocol
748
+ || hasCompleteAgentEnvelope(lastText);
749
+ const extraStableMs = lastMessage
750
+ ? await this.extraStableWindowMs(lastMessage, lastText)
751
+ : 0;
752
+ if (
753
+ lastText.trim()
754
+ && stableSince > 0
755
+ && envelopeReady
756
+ && !assistantGenerating
757
+ && (
758
+ (
759
+ !stopVisible
760
+ && Date.now() - stableSince >= stableWindowMs + extraStableMs
761
+ )
762
+ || (
763
+ stopVisible
764
+ && hasCompleteAgentEnvelope(lastText)
765
+ && Date.now() - stableSince >= staleStopWindowMs
766
+ )
767
+ )
768
+ ) {
769
+ this.lastAssistantMessageId = candidateId;
770
+ const limitMarker = await this.findUsageLimitMarker(lastMessage);
771
+ if (limitMarker) {
772
+ throw new BrowserAdapterError(
773
+ `${this.providerName} reported a usage limit (${limitMarker}).`,
774
+ { code: "USAGE_LIMIT_REACHED" },
775
+ );
776
+ }
777
+ const generationError = await this.findGenerationErrorMarker(lastMessage);
778
+ if (generationError) {
779
+ throw new BrowserAdapterError(
780
+ `${this.providerName} generation failed (${generationError}).`,
781
+ { code: "GENERATION_FAILED" },
782
+ );
783
+ }
784
+ return lastText.trim();
785
+ }
786
+
787
+ // A reply that STARTED a protocol envelope but ended without its
788
+ // closing tag. Once the text has been unchanged for the provider's
789
+ // truncated-envelope grace window (short for providers with a
790
+ // reliable completion signal, long otherwise), treat it as finished
791
+ // — truncated, not paused mid-stream — and hand it back so the
792
+ // runtime's protocol parser can nudge the model to continue instead
793
+ // of waiting out the whole turn timeout.
794
+ if (
795
+ lastText.trim()
796
+ && looksLikeProtocol
797
+ && !envelopeReady
798
+ && !stopVisible
799
+ && !assistantGenerating
800
+ && Date.now() - stableSince >= truncatedGraceMs
801
+ ) {
802
+ this.lastAssistantMessageId = candidateId;
803
+ return lastText.trim();
804
+ }
805
+ }
806
+
807
+ // Dead-request detection: the user message was sent but the model
808
+ // stopped producing signals — either it never started (no node, no stop
809
+ // button) or a started generation went quiet for several grace periods
810
+ // (stream dropped, server-side abort, usage limit). A node or visible
811
+ // stop button means generation is alive and resets the clock. Recover
812
+ // with a continuation nudge instead of waiting out the full timeout.
813
+ const generationActive = hasNewAssistant || generating;
814
+ const quietSince = sawGenerationSignal
815
+ ? lastGenerationSignalAt
816
+ : startedAt;
817
+ const graceMs = deadRequestGraceMs * this.deadRequestGraceMultiplier();
818
+ const quietGraceMs = sawGenerationSignal
819
+ ? graceMs * 3
820
+ : graceMs;
821
+ if (
822
+ !generationActive
823
+ && this.sentUserTurn != null
824
+ && Date.now() - quietSince >= quietGraceMs
825
+ ) {
826
+ await this.writeDiagnostics("dead-request");
827
+ throw new BrowserAdapterError(
828
+ `${this.providerName} stopped responding without completing a reply.`,
829
+ {
830
+ code: "DEAD_ASSISTANT_REQUEST",
831
+ details: { sentUserTurn: this.sentUserTurn },
832
+ },
833
+ );
834
+ }
835
+
836
+ await this.page.waitForTimeout(sawAssistant ? 250 : 500);
837
+ }
838
+
839
+ await this.writeDiagnostics("turn-timeout");
840
+ throw new BrowserAdapterError(
841
+ `${this.providerName} turn did not complete within ${Math.round(timeoutMs / 1000)} seconds.`,
842
+ { code: "TURN_TIMEOUT" },
843
+ );
844
+ } finally {
845
+ detachEscCancel?.();
846
+ }
847
+ }
848
+
849
+ // ---- generic base-internal helpers (never overridden) ------------------
850
+
851
+ async #findComposer() {
852
+ return await firstVisible(this.composerLocators());
853
+ }
854
+
855
+ async #findLoginControl() {
856
+ return await firstVisible(this.loginControlLocators());
857
+ }
858
+
859
+ async #isFreshConversation() {
860
+ const current = new URL(this.page.url());
861
+ const base = new URL(this.baseUrl);
862
+ const pattern = this.conversationUrlPattern();
863
+ if (
864
+ current.hostname !== base.hostname
865
+ || (pattern && pattern.test(current.pathname))
866
+ ) {
867
+ return false;
868
+ }
869
+ return await this.conversationMessages().count() === 0;
870
+ }
871
+
872
+ async #openNewConversation() {
873
+ const control = await firstVisible(this.newConversationControls());
874
+ if (control) {
875
+ await control.click().catch(() => null);
876
+ await this.page.waitForTimeout(500);
877
+ }
878
+ if (!await this.#isFreshConversation()) {
879
+ await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
880
+ }
881
+ }
882
+
883
+ async #waitForComposer(timeoutMs) {
884
+ const deadline = Date.now() + timeoutMs;
885
+ while (Date.now() < deadline) {
886
+ this.#throwIfCancelRequested();
887
+ const composer = await this.#findComposer();
888
+ if (composer) return composer;
889
+ await this.page.waitForTimeout(500);
890
+ }
891
+ return null;
892
+ }
893
+
894
+ async #waitUntilReadyToSend(timeoutMs = 30_000) {
895
+ const deadline = Date.now() + timeoutMs;
896
+ while (Date.now() < deadline) {
897
+ this.#throwIfCancelRequested();
898
+ const stopVisible = await this.#isStopButtonVisible();
899
+ const messages = this.assistantMessages();
900
+ const count = await messages.count().catch(() => 0);
901
+ const lastAssistant = count > 0 ? messages.last() : null;
902
+ const assistantGenerating = lastAssistant
903
+ ? await this.isAssistantGenerating(lastAssistant)
904
+ : false;
905
+ if (!stopVisible && !assistantGenerating) {
906
+ return;
907
+ }
908
+ await this.page.waitForTimeout(250);
909
+ }
910
+ }
911
+
912
+ async #captureMessageIdentities(messages) {
913
+ const count = await messages.count().catch(() => 0);
914
+ const ids = new Set();
915
+ let maxTurn = null;
916
+ for (let index = 0; index < count; index += 1) {
917
+ const identity = await this.messageIdentity(messages.nth(index));
918
+ if (identity.id) {
919
+ ids.add(identity.id);
920
+ }
921
+ if (identity.turn != null) {
922
+ maxTurn = maxTurn == null
923
+ ? identity.turn
924
+ : Math.max(maxTurn, identity.turn);
925
+ }
926
+ }
927
+ return { count, ids, maxTurn };
928
+ }
929
+
930
+ async #waitForSentUserMessage(baseline, attempts = this.sentUserWaitAttempts()) {
931
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
932
+ this.#throwIfCancelRequested();
933
+ const messages = this.userMessages();
934
+ const count = await messages.count().catch(() => 0);
935
+ for (let index = count - 1; index >= 0; index -= 1) {
936
+ const identity = await this.messageIdentity(messages.nth(index));
937
+ const newByTurn = identity.turn != null
938
+ && (
939
+ baseline.maxTurn == null
940
+ || identity.turn > baseline.maxTurn
941
+ );
942
+ const newById = Boolean(
943
+ identity.id
944
+ && !baseline.ids.has(identity.id),
945
+ );
946
+ if (newByTurn || newById) {
947
+ this.sentUserTurn = identity.turn;
948
+ return identity;
949
+ }
950
+ }
951
+ await this.page.waitForTimeout(100);
952
+ }
953
+ return null;
954
+ }
955
+
956
+ async #waitForConversationHistory({
957
+ expectedAssistantMessageId = null,
958
+ expectedUrl = null,
959
+ attempts = 60,
960
+ } = {}) {
961
+ let previousSignature = null;
962
+ let stableChecks = 0;
963
+ let scrolledToBottom = 0;
964
+
965
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
966
+ this.#throwIfCancelRequested();
967
+ const assistant = await this.#captureMessageIdentities(
968
+ this.assistantMessages(),
969
+ );
970
+ if (
971
+ expectedAssistantMessageId
972
+ && assistant.ids.has(expectedAssistantMessageId)
973
+ ) {
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
+ await this.scrollConversationToBottom();
981
+ scrolledToBottom += 1;
982
+ }
983
+
984
+ const totalMessages = await this.conversationMessages()
985
+ .count()
986
+ .catch(() => 0);
987
+ const signature = [
988
+ totalMessages,
989
+ assistant.count,
990
+ assistant.maxTurn ?? "",
991
+ [...assistant.ids].join(","),
992
+ ].join(":");
993
+ if (totalMessages > 0 && signature === previousSignature) {
994
+ stableChecks += 1;
995
+ if (stableChecks >= 3) {
996
+ // With an expected id we normally return as soon as it appears;
997
+ // reaching stability instead means the id is not mounted or has been
998
+ // deleted (providers remove transient error/limit cards, and the last
999
+ // recorded reply can be one). Accept when the URL still proves this
1000
+ // is the expected conversation.
1001
+ if (
1002
+ !expectedAssistantMessageId
1003
+ || !expectedUrl
1004
+ || sameConversationUrl(this.page.url(), expectedUrl)
1005
+ ) {
1006
+ return;
1007
+ }
1008
+ }
1009
+ } else {
1010
+ stableChecks = 0;
1011
+ previousSignature = signature;
1012
+ }
1013
+
1014
+ await this.page.waitForTimeout(250);
1015
+ }
1016
+
1017
+ await this.writeDiagnostics("conversation-history-mismatch");
1018
+ const detail = expectedAssistantMessageId
1019
+ ? ` Expected assistant message ${expectedAssistantMessageId} was not found.`
1020
+ : " Existing conversation history did not become stable.";
1021
+ throw new BrowserAdapterError(
1022
+ `${this.providerName} conversation history could not be verified.${detail}`,
1023
+ { code: "CONVERSATION_HISTORY_MISMATCH" },
1024
+ );
1025
+ }
1026
+
1027
+ async #isStopButtonVisible() {
1028
+ return Boolean(await firstVisible(this.stopButtonLocators()));
1029
+ }
1030
+
1031
+ async #clickStopButton() {
1032
+ const stop = await firstVisible(this.stopButtonLocators());
1033
+ if (stop) {
1034
+ await stop.click({ timeout: 3_000 }).catch(() => null);
1035
+ }
1036
+ }
1037
+
1038
+ // While a turn is being processed, raw-mode stdin lets ESC cancel the wait.
1039
+ // Raw mode swallows Ctrl+C, so forward it as a real SIGINT so the CLI's
1040
+ // existing interrupt path still runs. The returned detach restores the
1041
+ // previous terminal mode, keeping approval prompts (which also read stdin)
1042
+ // working. Keys other than ESC/Ctrl+C are consumed and dropped.
1043
+ //
1044
+ // A pending cancel request (Ctrl+C pressed before this wait started, e.g.
1045
+ // while a tool was running) must cancel the wait immediately, so the flag is
1046
+ // deliberately NOT reset on attach.
1047
+ #attachEscCancel() {
1048
+ if (!this.cancelOnEsc || !this.stdinStream?.isTTY) {
1049
+ return null;
1050
+ }
1051
+ const stream = this.stdinStream;
1052
+ emitKeypressEvents(stream);
1053
+ const previousRaw = stream.isRaw;
1054
+ stream.setRawMode(true);
1055
+ const onKeypress = (_chunk, key) => {
1056
+ if (key?.name === "escape") {
1057
+ this.escCancelRequested = true;
1058
+ } else if (key?.ctrl && key?.name === "c") {
1059
+ process.kill(process.pid, "SIGINT");
1060
+ }
1061
+ };
1062
+ stream.on("keypress", onKeypress);
1063
+ // A previous detach may have left the stream paused; a paused stream never
1064
+ // delivers keypress events, so make sure reads are active.
1065
+ stream.resume();
1066
+ return () => {
1067
+ stream.removeListener("keypress", onKeypress);
1068
+ stream.setRawMode(previousRaw);
1069
+ // readline's emitKeypressEvents keeps its internal 'data' listener on the
1070
+ // stream even after the last 'keypress' listener is removed, which leaves
1071
+ // the TTY read active and pins the event loop open — the process would
1072
+ // never exit. Pause the stream so the loop can drain between turns and at
1073
+ // shutdown; the next attach resumes it.
1074
+ stream.pause();
1075
+ };
1076
+ }
1077
+
1078
+ // Throws TURN_CANCELLED when a cancel was requested before or during a wait.
1079
+ // Called from the bounded polling loops below (send/login/composer waits) so
1080
+ // a Ctrl+C lands promptly even outside waitForTurnComplete.
1081
+ #throwIfCancelRequested() {
1082
+ if (this.escCancelRequested) {
1083
+ throw new BrowserAdapterError(
1084
+ "Turn cancelled by user.",
1085
+ { code: "TURN_CANCELLED" },
1086
+ );
1087
+ }
1088
+ }
1089
+
1090
+ // Detects a CAPTCHA/anti-bot challenge (Cloudflare et al.) and surfaces the
1091
+ // window for the user. Provider-independent infrastructure; a provider with a
1092
+ // different challenge surface may override it.
1093
+ //
1094
+ // Detection is deliberately layered from most to least reliable:
1095
+ // 1. challenge URL (locale-independent),
1096
+ // 2. challenge DOM elements (locale-independent),
1097
+ // 3. title tokens (English titles only),
1098
+ // 4. a multilingual body-text pattern — but ONLY when no composer is on
1099
+ // the page. A normal chat page always has its composer, so assistant
1100
+ // text that merely mentions "安全验证" / "Just a moment" in a reply can
1101
+ // never false-positive; a challenge page has no composer at all.
1102
+ async throwIfBlockedPage() {
1103
+ let pageUrl = "";
1104
+ try {
1105
+ pageUrl = this.page.url();
1106
+ } catch {
1107
+ // Fall through to the DOM checks below.
1108
+ }
1109
+ const challengeUrl = pageUrl.includes("challenges.cloudflare.com")
1110
+ || pageUrl.includes("/cdn-cgi/");
1111
+
1112
+ const title = await this.page.title().catch(() => "");
1113
+ const titleTokens = new Set(title.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1114
+ const challengeTitle =
1115
+ (titleTokens.has("cloudflare") && titleTokens.has("attention")) ||
1116
+ (titleTokens.has("verify") && titleTokens.has("human")) ||
1117
+ (titleTokens.has("security") && titleTokens.has("check")) ||
1118
+ (titleTokens.has("just") && titleTokens.has("moment"));
1119
+
1120
+ const challengeElement = await firstVisible([
1121
+ this.page.locator('iframe[src*="challenges.cloudflare.com"]'),
1122
+ this.page.locator('input[name="cf-turnstile-response"]'),
1123
+ this.page.locator('#challenge-stage'),
1124
+ this.page.locator('form[action*="/cdn-cgi/challenge-platform/"]'),
1125
+ this.page.locator('[data-testid="challenge-stage"]'),
1126
+ ]);
1127
+
1128
+ if (!challengeUrl && !challengeTitle && !challengeElement) {
1129
+ // Only bother reading body text when the page has no composer — a
1130
+ // composer proves this is the normal chat UI, not an interstitial.
1131
+ const composer = await this.#findComposer();
1132
+ if (!composer) {
1133
+ const body = typeof this.page.evaluate === "function"
1134
+ ? await this.page
1135
+ .evaluate(() => (document.body.textContent ?? "").slice(0, 1500))
1136
+ .catch(() => "")
1137
+ : "";
1138
+ if (!CHALLENGE_BODY_PATTERN.test(body)) {
1139
+ return;
1140
+ }
1141
+ } else {
1142
+ return;
1143
+ }
1144
+ }
1145
+
1146
+ // A CAPTCHA/challenge needs the user's eyes and hands — surface the window
1147
+ // if it was minimized before reporting the block.
1148
+ await this.restoreWindow();
1149
+ throw new BrowserAdapterError("Browser access challenge detected.");
1150
+ }
1151
+
1152
+ async writeDiagnostics(label) {
1153
+ if (!this.debug || !this.page) {
1154
+ return;
1155
+ }
1156
+ const directory = path.join(this.profileDir, "..", "diagnostics");
1157
+ await ensureDirectory(directory);
1158
+ const stamp = Date.now();
1159
+ await Promise.all([
1160
+ this.page.screenshot({
1161
+ path: path.join(directory, `${stamp}-${label}.png`),
1162
+ fullPage: true,
1163
+ }).catch(() => null),
1164
+ fs.writeFile(
1165
+ path.join(directory, `${stamp}-${label}.html`),
1166
+ await this.page.content(),
1167
+ "utf8",
1168
+ ).catch(() => null),
1169
+ ]);
1170
+ }
1171
+
1172
+ // Non-private so provider subclasses (e.g. attachFiles) can guard too.
1173
+ requirePage() {
1174
+ if (!this.page) {
1175
+ throw new BrowserAdapterError("Browser has not been launched.");
1176
+ }
1177
+ }
1178
+ }