wtagent 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +163 -9
  2. package/package.json +9 -8
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +2418 -201
  7. package/src/browser/cdp-browser.js +223 -25
  8. package/src/browser/cdp-state.js +8 -5
  9. package/src/browser/chatgpt-dom.js +179 -0
  10. package/src/browser/chatgpt-web-adapter.js +831 -69
  11. package/src/browser/claude-web-adapter.js +13 -0
  12. package/src/browser/fake-web-model-adapter.js +152 -3
  13. package/src/browser/gemini-web-adapter.js +71 -20
  14. package/src/browser/glm-web-adapter.js +2 -2
  15. package/src/browser/grok-web-adapter.js +35 -3
  16. package/src/browser/rendered-text.js +11 -0
  17. package/src/cli/i18n.js +50 -20
  18. package/src/cli/main.js +124 -40
  19. package/src/cli/prompt-input.js +68 -0
  20. package/src/image/browser-image-driver.js +238 -0
  21. package/src/image/browser-image-session-pool.js +96 -0
  22. package/src/image/image-generation-service.js +101 -0
  23. package/src/image/image-provider-driver.js +22 -0
  24. package/src/image/native-image-receiver.js +66 -0
  25. package/src/image/provider-registry.js +23 -0
  26. package/src/image/providers/chatgpt-image-driver.js +211 -0
  27. package/src/image/providers/gemini-image-driver.js +100 -0
  28. package/src/image/providers/grok-image-driver.js +193 -0
  29. package/src/platform/command-launcher.js +1 -1
  30. package/src/platform/paths.js +19 -0
  31. package/src/platform/windows-diagnostics.js +9 -16
  32. package/src/policy/path-guard.js +5 -1
  33. package/src/policy/policy-engine.js +10 -5
  34. package/src/protocol/envelope.js +74 -0
  35. package/src/protocol/markers.js +16 -0
  36. package/src/protocol/prompt-builder.js +12 -9
  37. package/src/protocol/xml-protocol.js +36 -18
  38. package/src/runtime/agent-runtime.js +1383 -238
  39. package/src/session/agent-session.js +1047 -121
  40. package/src/tools/default-tools.js +1 -1
  41. package/src/tools/image-tools.js +92 -0
  42. package/src/tools/registry.js +13 -1
  43. package/docs/technical-design.md +0 -866
@@ -1,4 +1,11 @@
1
- import { BaseWebAdapter, firstVisible, hasCompleteAgentEnvelope } from "./base-web-adapter.js";
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ BaseWebAdapter,
4
+ firstVisible,
5
+ hasCompleteAgentEnvelope,
6
+ isValidOutboundCorrelationId,
7
+ renderedMessageContainsOutboundMarker,
8
+ } from "./base-web-adapter.js";
2
9
  import { BrowserAdapterError } from "../shared/errors.js";
3
10
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
4
11
 
@@ -7,11 +14,68 @@ import { isUsageLimitNotice } from "../shared/usage-limit.js";
7
14
  // base module.
8
15
  export { isConnectionLostError } from "./base-web-adapter.js";
9
16
 
17
+ import {
18
+ CHATGPT_DOM,
19
+ readChatGPTDom,
20
+ summarizeChatGPTDom,
21
+ readChatGPTMessageIdentity,
22
+ readChatGPTAssistantText,
23
+ } from "./chatgpt-dom.js";
24
+
10
25
  const CHATGPT_URL = "https://chatgpt.com/";
11
26
 
12
- function parseConversationTurn(value) {
13
- const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
14
- return match ? Number.parseInt(match[1], 10) : null;
27
+ function normalizedConversationUrl(value) {
28
+ try {
29
+ const parsed = value instanceof URL ? value : new URL(value);
30
+ const pathname = parsed.pathname.replace(/\/+$/, "") || "/";
31
+ return `${parsed.origin}${pathname}`;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function validatedOrderedSnapshot(snapshot) {
38
+ const url = normalizedConversationUrl(snapshot?.url);
39
+ if (!url || !Array.isArray(snapshot?.entries)) {
40
+ return null;
41
+ }
42
+ const ids = new Set();
43
+ const turns = new Set();
44
+ const entries = [];
45
+ let previousDomIndex = -1;
46
+ let previousTurn = -1;
47
+ for (const entry of snapshot.entries) {
48
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
49
+ if (
50
+ !["user", "assistant"].includes(entry?.role)
51
+ || !id
52
+ || !Number.isSafeInteger(entry.turn)
53
+ || entry.turn < 0
54
+ || !Number.isSafeInteger(entry.domIndex)
55
+ || entry.domIndex <= previousDomIndex
56
+ || entry.turn <= previousTurn
57
+ || ids.has(id)
58
+ || turns.has(entry.turn)
59
+ ) {
60
+ return null;
61
+ }
62
+ ids.add(id);
63
+ turns.add(entry.turn);
64
+ previousDomIndex = entry.domIndex;
65
+ previousTurn = entry.turn;
66
+ entries.push({
67
+ domIndex: entry.domIndex,
68
+ role: entry.role,
69
+ id,
70
+ turn: entry.turn,
71
+ renderedText: String(entry.renderedText ?? ""),
72
+ });
73
+ }
74
+ return { url, entries };
75
+ }
76
+
77
+ function sameIdentity(entry, { id, turn }) {
78
+ return entry?.id === id && entry?.turn === turn;
15
79
  }
16
80
 
17
81
  // ChatGPT-specific implementation of the WebModelAdapter contract. Only the
@@ -34,26 +98,77 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
34
98
  return /^\/c\//;
35
99
  }
36
100
 
37
- composerLocators() {
101
+ classifyConversationUrl(value) {
102
+ const kind = super.classifyConversationUrl(value);
103
+ if (kind !== "restorable") {
104
+ return kind;
105
+ }
106
+ const parsed = value instanceof URL ? value : new URL(value);
107
+ return /^\/c\/(?:WEB:|local-chatgpt(?::|%3[aA]))/.test(parsed.pathname)
108
+ ? "provisional"
109
+ : "restorable";
110
+ }
111
+
112
+ async startConversation(...args) {
113
+ try {
114
+ const result = await super.startConversation(...args);
115
+ await this.collectStructuralDiagnostics();
116
+ return result;
117
+ } catch (error) {
118
+ const diagnostic = await this.writeDiagnostics("conversation-start-failed", { stage: "conversation-start" }).catch(() => null);
119
+ if (diagnostic?.filename) {
120
+ error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ hasStructuralDiagnostics() {
127
+ return true;
128
+ }
129
+
130
+ recordConversationNavigation(url) {
131
+ if (url === this.lastDiagnosticUrl) return;
132
+ this.lastDiagnosticUrl = url;
133
+ this.diagnosticRoutes ??= [];
134
+ this.diagnosticRoutes.push({ kind: this.classifyConversationUrl(url), at: Date.now() });
135
+ this.diagnosticRoutes = this.diagnosticRoutes.slice(-12);
136
+ }
137
+
138
+ async structuralDiagnostics() {
139
+ const snapshot = await this.page.evaluate(readChatGPTDom, {
140
+ selector: CHATGPT_DOM.messages, registry: CHATGPT_DOM,
141
+ });
142
+ const report = summarizeChatGPTDom(snapshot);
143
+ this.lastDomReport = report;
144
+ return report;
145
+ }
146
+
147
+ pendingAttachmentLocators() {
38
148
  return [
39
- this.page.locator("#prompt-textarea"),
40
- this.page.locator('textarea[placeholder*="Message" i]'),
41
- this.page.locator('textarea[placeholder*="消息"]'),
42
- this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
43
- this.page.locator('main div[contenteditable="true"]'),
149
+ this.page.locator(
150
+ 'form [data-testid$="-file-thumbnail"], '
151
+ + 'form [data-testid^="file-thumbnail"], '
152
+ + 'form button[aria-label*="remove file" i], '
153
+ + 'form button[aria-label*="删除文件"]',
154
+ ),
44
155
  ];
45
156
  }
46
157
 
158
+ composerLocators() {
159
+ return CHATGPT_DOM.composers.map(({ selector }) => this.page.locator(selector));
160
+ }
161
+
47
162
  sendButtonLocators() {
48
163
  return [
49
- this.page.locator('[data-testid="send-button"]'),
164
+ this.page.locator(CHATGPT_DOM.send),
50
165
  this.page.getByRole("button", { name: /send|发送/i }),
51
166
  ];
52
167
  }
53
168
 
54
169
  stopButtonLocators() {
55
170
  return [
56
- this.page.locator('[data-testid="stop-button"]'),
171
+ this.page.locator(CHATGPT_DOM.stop),
57
172
  this.page.getByRole("button", {
58
173
  name: /stop generating|stop|停止生成|停止/i,
59
174
  }),
@@ -99,38 +214,709 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
99
214
  // keeps a failed request from being read as an envelope-less reply and
100
215
  // burned through the protocol-error retry loop; the turn instead sees "no
101
216
  // reply" and recovers with the continuation nudge.
102
- return this.page.locator(
103
- '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])',
104
- );
217
+ const legacy = this.page.locator(CHATGPT_DOM.assistant.legacy);
218
+ return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.assistant.modern)) : legacy;
105
219
  }
106
220
 
107
221
  userMessages() {
108
- return this.page.locator('[data-message-author-role="user"]');
222
+ const legacy = this.page.locator(CHATGPT_DOM.user.legacy);
223
+ return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.user.modern)) : legacy;
224
+ }
225
+
226
+ async userMessageSnapshot() {
227
+ const messages = this.userMessages();
228
+ if (typeof messages.evaluateAll !== "function") {
229
+ return super.userMessageSnapshot();
230
+ }
231
+ // ChatGPT unmounts older turns as a long thread advances. count() followed
232
+ // by nth() can then wait for a vanished index until the send deadline, or
233
+ // combine one bubble's identity with another bubble's text.
234
+ return await messages.evaluateAll(readChatGPTDom).then((snapshot) => (
235
+ snapshot.entries.every((entry) => entry.role === "user" && entry.id && Number.isSafeInteger(entry.turn))
236
+ ? snapshot.entries : null
237
+ )).catch(() => null);
109
238
  }
110
239
 
111
240
  conversationMessages() {
112
- return this.page.locator(
113
- '[data-message-author-role="user"], [data-message-author-role="assistant"]',
114
- );
241
+ const legacy = this.page.locator(CHATGPT_DOM.conversationLegacy);
242
+ return legacy.or ? legacy.or(this.page.locator(`${CHATGPT_DOM.user.modern}, ${CHATGPT_DOM.assistant.modern}`)) : legacy;
115
243
  }
116
244
 
117
- async messageIdentity(message) {
118
- const [id, turn] = await Promise.all([
119
- message.getAttribute("data-message-id").catch(() => null),
120
- this.#messageTurn(message),
121
- ]);
122
- return { id, turn };
245
+ requiresOrderedConversationSnapshotForFreshSend() {
246
+ return true;
123
247
  }
124
248
 
125
- async #messageTurn(message) {
126
- if (typeof message?.evaluate !== "function") {
249
+ async orderedConversationSnapshot() {
250
+ if (typeof this.page?.evaluate !== "function") {
127
251
  return null;
128
252
  }
129
- const testId = await message.evaluate((element) => (
130
- element.closest('[data-testid^="conversation-turn-"]')
131
- ?.getAttribute("data-testid") ?? null
132
- )).catch(() => null);
133
- return parseConversationTurn(testId);
253
+ return await this.page.evaluate(readChatGPTDom,
254
+ CHATGPT_DOM.messages,
255
+ ).catch(() => null);
256
+ }
257
+
258
+ async assertRecoveredTurnTopology({
259
+ assistantPresent = false,
260
+ assistantMessageId = null,
261
+ assistantTurn = null,
262
+ } = {}) {
263
+ if (!this.recoveredTurnTopologyRequired) {
264
+ return;
265
+ }
266
+ const snapshot = validatedOrderedSnapshot(
267
+ await this.orderedConversationSnapshot(),
268
+ );
269
+ const currentUrl = normalizedConversationUrl(this.page?.url?.());
270
+ if (!snapshot || !currentUrl || snapshot.url !== currentUrl) {
271
+ throw this.#pendingOutboundUncertain(
272
+ "ChatGPT could not atomically revalidate the recovered conversation boundary.",
273
+ );
274
+ }
275
+ if (
276
+ typeof this.lastUserMessageId !== "string"
277
+ || !this.lastUserMessageId
278
+ || !Number.isSafeInteger(this.sentUserTurn)
279
+ ) {
280
+ throw this.#pendingOutboundUncertain(
281
+ "The recovered ChatGPT user-turn identity is incomplete.",
282
+ );
283
+ }
284
+
285
+ const matchingUsers = snapshot.entries.filter((entry) => (
286
+ entry.role === "user"
287
+ && sameIdentity(entry, {
288
+ id: this.lastUserMessageId,
289
+ turn: this.sentUserTurn,
290
+ })
291
+ ));
292
+ if (matchingUsers.length !== 1) {
293
+ throw this.#pendingOutboundUncertain(
294
+ "The correlated ChatGPT user turn disappeared or changed after recovery.",
295
+ );
296
+ }
297
+ const user = matchingUsers[0];
298
+ const suffix = snapshot.entries.slice(snapshot.entries.indexOf(user) + 1);
299
+ if (suffix.some((entry) => entry.role === "user")) {
300
+ throw this.#pendingOutboundUncertain(
301
+ "A later ChatGPT user turn invalidated the recovered assistant boundary.",
302
+ );
303
+ }
304
+ if (suffix.length > 1 || suffix.some((entry) => entry.role !== "assistant")) {
305
+ throw this.#pendingOutboundUncertain(
306
+ "ChatGPT assistant chronology changed after recovery.",
307
+ );
308
+ }
309
+
310
+ const assistant = suffix[0] ?? null;
311
+ if (assistant && assistant.turn !== user.turn + 1) {
312
+ throw this.#pendingOutboundUncertain(
313
+ "The recovered ChatGPT assistant successor is no longer adjacent.",
314
+ );
315
+ }
316
+ if (
317
+ (this.expectedAssistantCandidateId != null
318
+ || this.expectedAssistantCandidateTurn != null)
319
+ && (
320
+ !assistant
321
+ || (
322
+ this.expectedAssistantCandidateId != null
323
+ && assistant.id !== this.expectedAssistantCandidateId
324
+ )
325
+ || (
326
+ this.expectedAssistantCandidateTurn != null
327
+ && assistant.turn !== this.expectedAssistantCandidateTurn
328
+ )
329
+ )
330
+ ) {
331
+ throw this.#pendingOutboundUncertain(
332
+ "The persisted ChatGPT assistant successor changed after recovery.",
333
+ );
334
+ }
335
+ if (
336
+ assistantPresent
337
+ && (
338
+ !assistant
339
+ || assistant.id !== assistantMessageId
340
+ || assistant.turn !== assistantTurn
341
+ )
342
+ ) {
343
+ throw this.#pendingOutboundUncertain(
344
+ "The ChatGPT assistant node being processed is outside the recovered boundary.",
345
+ );
346
+ }
347
+ }
348
+
349
+ supportsPendingOutboundRecovery() {
350
+ return true;
351
+ }
352
+
353
+ #pendingOutboundUncertain(message, details = {}) {
354
+ return new BrowserAdapterError(message, {
355
+ code: "OUTBOUND_COMMIT_UNCERTAIN",
356
+ recoverable: false,
357
+ details,
358
+ });
359
+ }
360
+
361
+ #assertRecoveryTarget({ page, mainFrame, targetId, initialUrl, navigated }) {
362
+ if (
363
+ navigated
364
+ || this.page !== page
365
+ || (typeof page.mainFrame === "function" && page.mainFrame() !== mainFrame)
366
+ || !this.hasExactRecoveryTarget(targetId)
367
+ || normalizedConversationUrl(page.url()) !== initialUrl
368
+ ) {
369
+ throw this.#pendingOutboundUncertain(
370
+ "The exact ChatGPT target changed while reconciling the pending outbound.",
371
+ );
372
+ }
373
+ }
374
+
375
+ #recoveryDeadlineError() {
376
+ return this.#pendingOutboundUncertain(
377
+ "ChatGPT could not reconcile the pending outbound before the recovery deadline.",
378
+ );
379
+ }
380
+
381
+ async #awaitWithinRecoveryDeadline(operation, deadline) {
382
+ const remaining = deadline - this.monotonicNow();
383
+ if (remaining <= 0) {
384
+ throw this.#recoveryDeadlineError();
385
+ }
386
+ let timer;
387
+ try {
388
+ const result = await Promise.race([
389
+ Promise.resolve().then(operation),
390
+ new Promise((_, reject) => {
391
+ timer = setTimeout(
392
+ () => reject(this.#recoveryDeadlineError()),
393
+ remaining,
394
+ );
395
+ }),
396
+ ]);
397
+ if (this.monotonicNow() >= deadline) {
398
+ throw this.#recoveryDeadlineError();
399
+ }
400
+ return result;
401
+ } finally {
402
+ clearTimeout(timer);
403
+ }
404
+ }
405
+
406
+ async #waitRecoveryPoll(deadline) {
407
+ const remaining = deadline - this.monotonicNow();
408
+ await this.#awaitWithinRecoveryDeadline(
409
+ () => this.waitForPoll(Math.min(100, Math.max(1, remaining))),
410
+ deadline,
411
+ );
412
+ }
413
+
414
+ #recoveryBoundary(snapshot, {
415
+ initialUrl,
416
+ outboundId,
417
+ observedUser,
418
+ expectedUser,
419
+ requiresEmptyPrefix,
420
+ expectedPreOutboundIds,
421
+ expectedWaitingAssistant,
422
+ }) {
423
+ if (snapshot.url !== initialUrl) {
424
+ throw this.#pendingOutboundUncertain(
425
+ "ChatGPT returned a mixed-URL conversation snapshot during recovery.",
426
+ );
427
+ }
428
+ const matchingEntries = snapshot.entries.filter((entry) => (
429
+ renderedMessageContainsOutboundMarker(entry.renderedText, outboundId)
430
+ ));
431
+ if (
432
+ matchingEntries.length !== 1
433
+ || matchingEntries[0].role !== "user"
434
+ ) {
435
+ throw this.#pendingOutboundUncertain(
436
+ "The pending outbound marker is not unique to one ChatGPT user turn.",
437
+ );
438
+ }
439
+
440
+ const user = matchingEntries[0];
441
+ if (
442
+ (observedUser && !sameIdentity(user, observedUser))
443
+ || (expectedUser && !sameIdentity(user, expectedUser))
444
+ ) {
445
+ throw this.#pendingOutboundUncertain(
446
+ "The correlated ChatGPT user-turn identity changed during recovery.",
447
+ );
448
+ }
449
+ const userIndex = snapshot.entries.indexOf(user);
450
+ const prefix = snapshot.entries.slice(0, userIndex);
451
+ const suffix = snapshot.entries.slice(userIndex + 1);
452
+ if (suffix.some((entry) => entry.role === "user")) {
453
+ throw this.#pendingOutboundUncertain(
454
+ "A later ChatGPT user turn exists after the pending outbound.",
455
+ );
456
+ }
457
+ if (requiresEmptyPrefix) {
458
+ if (prefix.length !== 0 || user.turn !== 1) {
459
+ throw this.#pendingOutboundUncertain(
460
+ "Fresh pending outbound recovery found unexpected conversation history.",
461
+ );
462
+ }
463
+ } else if (
464
+ expectedPreOutboundIds.size === 0
465
+ || !prefix.some((entry) => expectedPreOutboundIds.has(entry.id))
466
+ ) {
467
+ throw this.#pendingOutboundUncertain(
468
+ "Existing-conversation recovery could not verify a pre-outbound marker.",
469
+ );
470
+ }
471
+
472
+ const successors = suffix.filter((entry) => entry.role === "assistant");
473
+ if (successors.length > 1 || successors.length !== suffix.length) {
474
+ throw this.#pendingOutboundUncertain(
475
+ "ChatGPT assistant chronology is ambiguous after the pending outbound.",
476
+ );
477
+ }
478
+ const assistant = successors[0] ?? null;
479
+ if (assistant && assistant.turn !== user.turn + 1) {
480
+ throw this.#pendingOutboundUncertain(
481
+ "Pending outbound recovery found a discontinuous assistant turn.",
482
+ );
483
+ }
484
+ if (
485
+ expectedWaitingAssistant
486
+ && assistant
487
+ && !sameIdentity(assistant, expectedWaitingAssistant)
488
+ ) {
489
+ throw this.#pendingOutboundUncertain(
490
+ "The assistant successor changed after the recovery handoff.",
491
+ );
492
+ }
493
+
494
+ const assistantBaselineEntries = prefix.filter(
495
+ (entry) => entry.role === "assistant",
496
+ );
497
+ const preOutboundMarkerIds = prefix
498
+ .filter((entry) => expectedPreOutboundIds.has(entry.id))
499
+ .map((entry) => entry.id);
500
+ return {
501
+ user,
502
+ assistant,
503
+ assistantBaselineEntries,
504
+ preOutboundMarkerIds,
505
+ };
506
+ }
507
+
508
+ async #assistantLocatorFor(entry) {
509
+ const messages = this.assistantMessages();
510
+ const count = await messages.count().catch(() => 0);
511
+ let found = null;
512
+ for (let index = 0; index < count; index += 1) {
513
+ const message = messages.nth(index);
514
+ const identity = await this.messageIdentity(message);
515
+ if (!sameIdentity(entry, identity)) {
516
+ continue;
517
+ }
518
+ if (found) {
519
+ return null;
520
+ }
521
+ found = message;
522
+ }
523
+ return found;
524
+ }
525
+
526
+ async reconcilePendingOutbound(options = {}) {
527
+ try {
528
+ return await this.#reconcilePendingOutbound(options);
529
+ } catch (error) {
530
+ const diagnostic = await this.writeDiagnostics("pending-outbound-recovery-failed", { stage: "recovery" }).catch(() => null);
531
+ if (diagnostic?.filename) {
532
+ error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
533
+ }
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ async #reconcilePendingOutbound({
539
+ pendingOutbound = null,
540
+ conversationTargetId = null,
541
+ lastUserMessageId = null,
542
+ lastAssistantMessageId = null,
543
+ priorHandoff = null,
544
+ timeoutMs = this.sendConfirmationTimeoutMs(),
545
+ stableWindowMs = this.restorationCorrelationWindowMs(),
546
+ } = {}) {
547
+ this.requirePage();
548
+ const outboundId = pendingOutbound?.outboundId
549
+ ?? priorHandoff?.sourceOutboundId
550
+ ?? null;
551
+ if (!isValidOutboundCorrelationId(outboundId)) {
552
+ throw this.#pendingOutboundUncertain(
553
+ "The pending outbound has no valid transport correlation ID.",
554
+ );
555
+ }
556
+ if (
557
+ typeof conversationTargetId !== "string"
558
+ || conversationTargetId.length === 0
559
+ || !this.hasExactRecoveryTarget(conversationTargetId)
560
+ ) {
561
+ throw new BrowserAdapterError(
562
+ "The exact saved ChatGPT target is unavailable for pending outbound recovery.",
563
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
564
+ );
565
+ }
566
+
567
+ const page = this.page;
568
+ const mainFrame = typeof page.mainFrame === "function"
569
+ ? page.mainFrame()
570
+ : null;
571
+ const initialUrl = normalizedConversationUrl(page.url());
572
+ const initialKind = initialUrl
573
+ ? this.classifyConversationUrl(initialUrl)
574
+ : "invalid";
575
+ if (!["restorable", "provisional"].includes(initialKind)) {
576
+ throw this.#pendingOutboundUncertain(
577
+ "The exact ChatGPT target is not on a recognized conversation route.",
578
+ { currentUrl: initialUrl },
579
+ );
580
+ }
581
+
582
+ let navigated = false;
583
+ const navigationListener = (frame) => {
584
+ if (
585
+ !mainFrame
586
+ || frame === mainFrame
587
+ || (typeof page.mainFrame === "function" && frame === page.mainFrame())
588
+ ) {
589
+ navigated = true;
590
+ }
591
+ };
592
+ page.on?.("framenavigated", navigationListener);
593
+
594
+ const outboundKind = pendingOutbound?.kind
595
+ ?? priorHandoff?.outboundKind
596
+ ?? "runtime_message";
597
+ const requiresEmptyPrefix = ["bootstrap", "fresh_rebuild"]
598
+ .includes(outboundKind);
599
+ const expectedPreOutboundIds = new Set([
600
+ lastUserMessageId,
601
+ lastAssistantMessageId,
602
+ ...(priorHandoff?.preOutboundMarkerIds ?? []),
603
+ ...(priorHandoff?.assistantBaseline?.ids ?? []),
604
+ ].filter(Boolean));
605
+ const expectedUser = priorHandoff
606
+ ? {
607
+ id: priorHandoff.userMessageId ?? null,
608
+ turn: priorHandoff.userTurn ?? null,
609
+ }
610
+ : null;
611
+ const expectedAssistant = priorHandoff?.status === "complete"
612
+ ? {
613
+ id: priorHandoff.assistantMessageId ?? null,
614
+ turn: priorHandoff.assistantTurn ?? null,
615
+ responseHash: priorHandoff.responseHash ?? null,
616
+ }
617
+ : null;
618
+ const expectedWaitingAssistant = priorHandoff?.status === "waiting"
619
+ && priorHandoff.assistantCandidateMessageId
620
+ ? {
621
+ id: priorHandoff.assistantCandidateMessageId,
622
+ turn: priorHandoff.assistantCandidateTurn ?? null,
623
+ }
624
+ : null;
625
+ const deadline = this.monotonicNow() + timeoutMs;
626
+ let stableSignature = null;
627
+ let stableSince = 0;
628
+ let observedUser = null;
629
+ let observedAssistant = expectedWaitingAssistant ?? expectedAssistant ?? null;
630
+
631
+ try {
632
+ while (this.monotonicNow() < deadline) {
633
+ this.#assertRecoveryTarget({
634
+ page,
635
+ mainFrame,
636
+ targetId: conversationTargetId,
637
+ initialUrl,
638
+ navigated,
639
+ });
640
+ const snapshot = validatedOrderedSnapshot(
641
+ await this.#awaitWithinRecoveryDeadline(
642
+ () => this.orderedConversationSnapshot().catch(() => null),
643
+ deadline,
644
+ ),
645
+ );
646
+ if (!snapshot) {
647
+ stableSignature = null;
648
+ stableSince = 0;
649
+ await this.#waitRecoveryPoll(deadline);
650
+ continue;
651
+ }
652
+ this.#assertRecoveryTarget({
653
+ page,
654
+ mainFrame,
655
+ targetId: conversationTargetId,
656
+ initialUrl,
657
+ navigated,
658
+ });
659
+ const hasOutboundMarker = snapshot.entries.some((entry) => (
660
+ renderedMessageContainsOutboundMarker(entry.renderedText, outboundId)
661
+ ));
662
+ if (!hasOutboundMarker) {
663
+ stableSignature = null;
664
+ stableSince = 0;
665
+ await this.#waitRecoveryPoll(deadline);
666
+ continue;
667
+ }
668
+ const {
669
+ user,
670
+ assistant,
671
+ assistantBaselineEntries,
672
+ preOutboundMarkerIds,
673
+ } = this.#recoveryBoundary(snapshot, {
674
+ initialUrl,
675
+ outboundId,
676
+ observedUser,
677
+ expectedUser,
678
+ requiresEmptyPrefix,
679
+ expectedPreOutboundIds,
680
+ expectedWaitingAssistant,
681
+ });
682
+ observedUser = { id: user.id, turn: user.turn };
683
+ if (
684
+ assistant
685
+ && observedAssistant
686
+ && !sameIdentity(assistant, observedAssistant)
687
+ ) {
688
+ throw this.#pendingOutboundUncertain(
689
+ "The ChatGPT assistant successor changed during recovery.",
690
+ );
691
+ }
692
+ if (assistant && !observedAssistant) {
693
+ observedAssistant = { id: assistant.id, turn: assistant.turn };
694
+ }
695
+ if (observedAssistant && !assistant) {
696
+ stableSignature = null;
697
+ stableSince = 0;
698
+ await this.#waitRecoveryPoll(deadline);
699
+ continue;
700
+ }
701
+ let status = "waiting";
702
+ let rawResponse = null;
703
+ let responseHash = null;
704
+ if (assistant) {
705
+ const locator = await this.#awaitWithinRecoveryDeadline(
706
+ () => this.#assistantLocatorFor(assistant),
707
+ deadline,
708
+ );
709
+ if (!locator) {
710
+ stableSignature = null;
711
+ stableSince = 0;
712
+ await this.#waitRecoveryPoll(deadline);
713
+ continue;
714
+ }
715
+ const stopVisible = Boolean(await this.#awaitWithinRecoveryDeadline(
716
+ () => firstVisible(this.stopButtonLocators()),
717
+ deadline,
718
+ ));
719
+ const generating = stopVisible || await this.#awaitWithinRecoveryDeadline(
720
+ () => this.isAssistantGenerating(locator),
721
+ deadline,
722
+ );
723
+ if (!generating) {
724
+ const [generationError, usageLimit] = await this.#awaitWithinRecoveryDeadline(
725
+ () => Promise.all([
726
+ this.findGenerationErrorMarker(locator),
727
+ this.findUsageLimitMarker(locator),
728
+ ]),
729
+ deadline,
730
+ );
731
+ if (!generationError && !usageLimit) {
732
+ rawResponse = assistant.renderedText;
733
+ if (rawResponse.trim() || priorHandoff?.nativeImages?.length > 0) {
734
+ responseHash = createHash("sha256")
735
+ .update(rawResponse)
736
+ .digest("hex");
737
+ status = "complete";
738
+ }
739
+ }
740
+ }
741
+ }
742
+ if (expectedAssistant) {
743
+ if (
744
+ status !== "complete"
745
+ || !sameIdentity(assistant, expectedAssistant)
746
+ || responseHash !== expectedAssistant.responseHash
747
+ ) {
748
+ throw this.#pendingOutboundUncertain(
749
+ "The completed assistant handoff no longer matches the browser reply.",
750
+ );
751
+ }
752
+ }
753
+
754
+ const signature = [
755
+ initialUrl,
756
+ conversationTargetId,
757
+ user.id,
758
+ user.turn,
759
+ preOutboundMarkerIds.join(","),
760
+ assistant?.id ?? "",
761
+ assistant?.turn ?? "",
762
+ status,
763
+ responseHash ?? "",
764
+ ].join(":");
765
+ const observedAt = this.monotonicNow();
766
+ if (signature !== stableSignature) {
767
+ stableSignature = signature;
768
+ stableSince = observedAt;
769
+ }
770
+ if (observedAt - stableSince < stableWindowMs) {
771
+ await this.#waitRecoveryPoll(deadline);
772
+ continue;
773
+ }
774
+ if (this.monotonicNow() >= deadline) {
775
+ throw this.#recoveryDeadlineError();
776
+ }
777
+
778
+ const finalSnapshot = validatedOrderedSnapshot(
779
+ await this.#awaitWithinRecoveryDeadline(
780
+ () => this.orderedConversationSnapshot().catch(() => null),
781
+ deadline,
782
+ ),
783
+ );
784
+ if (
785
+ !finalSnapshot
786
+ || !finalSnapshot.entries.some((entry) => (
787
+ renderedMessageContainsOutboundMarker(entry.renderedText, outboundId)
788
+ ))
789
+ ) {
790
+ stableSignature = null;
791
+ stableSince = 0;
792
+ await this.#waitRecoveryPoll(deadline);
793
+ continue;
794
+ }
795
+ this.#assertRecoveryTarget({
796
+ page,
797
+ mainFrame,
798
+ targetId: conversationTargetId,
799
+ initialUrl,
800
+ navigated,
801
+ });
802
+ const finalBoundary = this.#recoveryBoundary(finalSnapshot, {
803
+ initialUrl,
804
+ outboundId,
805
+ observedUser,
806
+ expectedUser,
807
+ requiresEmptyPrefix,
808
+ expectedPreOutboundIds,
809
+ expectedWaitingAssistant,
810
+ });
811
+ if (expectedWaitingAssistant && !finalBoundary.assistant) {
812
+ stableSignature = null;
813
+ stableSince = 0;
814
+ await this.#waitRecoveryPoll(deadline);
815
+ continue;
816
+ }
817
+ if (
818
+ Boolean(finalBoundary.assistant) !== Boolean(assistant)
819
+ || (
820
+ assistant
821
+ && !sameIdentity(finalBoundary.assistant, assistant)
822
+ )
823
+ ) {
824
+ if (!assistant && finalBoundary.assistant) {
825
+ stableSignature = null;
826
+ stableSince = 0;
827
+ await this.#waitRecoveryPoll(deadline);
828
+ continue;
829
+ }
830
+ throw this.#pendingOutboundUncertain(
831
+ "The ChatGPT assistant boundary changed during recovery verification.",
832
+ );
833
+ }
834
+ if (
835
+ finalBoundary.preOutboundMarkerIds.join(",")
836
+ !== preOutboundMarkerIds.join(",")
837
+ ) {
838
+ stableSignature = null;
839
+ stableSince = 0;
840
+ await this.#waitRecoveryPoll(deadline);
841
+ continue;
842
+ }
843
+ if (status === "complete") {
844
+ const finalRawResponse = finalBoundary.assistant?.renderedText ?? "";
845
+ const finalResponseHash = createHash("sha256")
846
+ .update(finalRawResponse)
847
+ .digest("hex");
848
+ if (finalResponseHash !== responseHash) {
849
+ stableSignature = null;
850
+ stableSince = 0;
851
+ await this.#waitRecoveryPoll(deadline);
852
+ continue;
853
+ }
854
+ rawResponse = finalRawResponse;
855
+ }
856
+
857
+ const assistantBaseline = {
858
+ ids: assistantBaselineEntries.map((entry) => entry.id),
859
+ count: assistantBaselineEntries.length,
860
+ maxTurn: assistantBaselineEntries.at(-1)?.turn ?? null,
861
+ lastText: assistantBaselineEntries.at(-1)?.renderedText ?? "",
862
+ };
863
+ this.acceptRecoveredOutboundAnchor({
864
+ conversationUrl: initialUrl,
865
+ conversationTargetId,
866
+ userMessageId: user.id,
867
+ userTurn: user.turn,
868
+ assistantBaseline,
869
+ assistantCandidateMessageId: assistant?.id ?? null,
870
+ assistantCandidateTurn: assistant?.turn ?? null,
871
+ });
872
+ if (status === "complete") {
873
+ this.lastAssistantMessageId = assistant.id;
874
+ this.lastAssistantTurn = assistant.turn;
875
+ }
876
+ if (this.monotonicNow() >= deadline) {
877
+ throw this.#recoveryDeadlineError();
878
+ }
879
+ this.#assertRecoveryTarget({
880
+ page,
881
+ mainFrame,
882
+ targetId: conversationTargetId,
883
+ initialUrl,
884
+ navigated,
885
+ });
886
+ return {
887
+ status,
888
+ conversationUrl: initialUrl,
889
+ conversationTargetId,
890
+ userMessageId: user.id,
891
+ userTurn: user.turn,
892
+ preOutboundMarkerIds,
893
+ assistantBaseline,
894
+ ...(assistant
895
+ ? {
896
+ assistantCandidateMessageId: assistant.id,
897
+ assistantCandidateTurn: assistant.turn,
898
+ }
899
+ : {}),
900
+ ...(status === "complete"
901
+ ? {
902
+ assistantMessageId: assistant.id,
903
+ assistantTurn: assistant.turn,
904
+ rawResponse,
905
+ responseHash,
906
+ }
907
+ : {}),
908
+ };
909
+ }
910
+ throw this.#pendingOutboundUncertain(
911
+ "ChatGPT could not stably reconcile the pending outbound on its exact target.",
912
+ );
913
+ } finally {
914
+ page.off?.("framenavigated", navigationListener);
915
+ }
916
+ }
917
+
918
+ async messageIdentity(message) {
919
+ return readChatGPTMessageIdentity(message);
134
920
  }
135
921
 
136
922
  // The redesigned UI still exposes the stop button ([data-testid="stop-button"],
@@ -154,45 +940,21 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
154
940
  if (super.isNewAssistantIdentity({ id, turn })) {
155
941
  return true;
156
942
  }
943
+ if (
944
+ !this.allowInPlaceAssistantContinuation
945
+ || !this.inPlaceAssistantGenerationObserved
946
+ || this.sentUserTurn == null
947
+ || !id
948
+ || !this.assistantIdsBeforeSend.has(id)
949
+ ) {
950
+ return false;
951
+ }
157
952
  const previous = String(this.lastAssistantTextBeforeSend ?? "");
158
953
  return previous !== "" && String(text ?? "") !== previous;
159
954
  }
160
955
 
161
956
  async assistantText(message) {
162
- // Read the whole assistant turn first. A long XML response can contain
163
- // Markdown fences inside CDATA; ChatGPT then splits the rendered response
164
- // into many <pre><code> nodes and the first node contains the opening tag
165
- // but not the closing tag. The parent innerText keeps the complete envelope
166
- // and avoids one CDP round trip per nested code block on every poll.
167
- const fullText = await message.innerText().catch(() => "");
168
- if (fullText.includes("<agent_response")) {
169
- return fullText;
170
- }
171
-
172
- // Rare fallback for alternate renderers where the parent text omits code
173
- // contents: prefer a complete code-block envelope, but retain a partial
174
- // one so streaming progress remains visible until its closing tag arrives.
175
- const codeBlocks = message.locator("pre code");
176
- const codeBlockCount = await codeBlocks.count();
177
- let partialEnvelope = "";
178
- for (let index = 0; index < codeBlockCount; index += 1) {
179
- const code = await codeBlocks.nth(index).innerText().catch(() => "");
180
- if (hasCompleteAgentEnvelope(code)) {
181
- return code;
182
- }
183
- if (!partialEnvelope && code.includes("<agent_response")) {
184
- partialEnvelope = code;
185
- }
186
- }
187
- if (partialEnvelope) {
188
- return partialEnvelope;
189
- }
190
-
191
- const markdown = message.locator(".markdown");
192
- if (await markdown.count()) {
193
- return await markdown.last().innerText().catch(() => "");
194
- }
195
- return fullText;
957
+ return readChatGPTAssistantText(message, hasCompleteAgentEnvelope);
196
958
  }
197
959
 
198
960
  // A plan/usage limit renders as an error card: error-tinted token classes
@@ -312,8 +1074,8 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
312
1074
  // Scrolls the thread container to the bottom so the latest replies mount.
313
1075
  // Best-effort — any failure is swallowed.
314
1076
  async scrollConversationToBottom() {
315
- await this.page.evaluate?.(() => {
316
- const message = document.querySelector("[data-message-author-role]");
1077
+ await this.page.evaluate?.((selector) => {
1078
+ const message = document.querySelector(selector);
317
1079
  if (!message) {
318
1080
  return;
319
1081
  }
@@ -328,7 +1090,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
328
1090
  return;
329
1091
  }
330
1092
  }
331
- }).catch(() => null);
1093
+ }, CHATGPT_DOM.messages).catch(() => null);
332
1094
  }
333
1095
 
334
1096
  }