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.
- package/README.md +53 -35
- package/package.json +2 -2
- package/src/browser/base-web-adapter.js +1178 -0
- package/src/browser/cdp-browser.js +62 -0
- package/src/browser/cdp-state.js +52 -4
- package/src/browser/chatgpt-web-adapter.js +301 -992
- package/src/browser/claude-web-adapter.js +205 -0
- package/src/browser/deepseek-web-adapter.js +337 -0
- package/src/browser/gemini-web-adapter.js +465 -0
- package/src/browser/glm-web-adapter.js +246 -0
- package/src/browser/kimi-web-adapter.js +285 -0
- package/src/browser/provider-registry.js +199 -0
- package/src/cli/main.js +284 -60
- package/src/cli/notice-store.js +125 -0
- package/src/cli/prompt-input.js +504 -170
- package/src/cli/render-events.js +14 -8
- package/src/cli/self-update.js +145 -0
- package/src/cli/startup-notices.js +161 -0
- package/src/platform/paths.js +7 -0
- package/src/platform/windows-diagnostics.js +37 -6
- package/src/protocol/markers.js +3 -2
- package/src/protocol/prompt-builder.js +9 -5
- package/src/protocol/xml-protocol.js +193 -11
- package/src/runtime/agent-runtime.js +131 -18
- package/src/session/agent-session.js +4 -1
- package/src/session/canonical-transcript.js +2 -2
- package/src/session/session-export.js +2 -2
- package/src/shared/fetch-json.js +24 -0
- package/src/shared/limits.js +11 -1
- package/src/shared/package-info.js +23 -0
- package/src/shared/usage-limit.js +2 -2
|
@@ -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
|
+
}
|