wtagent 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
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
- import {
5
- chooseModeOption,
6
- labelMatchesToken,
7
- slugMatchesToken,
8
- normalizeToken,
9
- } from "./mode-selection.js";
10
11
 
11
12
  // Re-exported so existing importers (the runtime and browser-adapter tests)
12
13
  // keep importing it from here even though it now lives on the provider-agnostic
@@ -20,11 +21,65 @@ function parseConversationTurn(value) {
20
21
  return match ? Number.parseInt(match[1], 10) : null;
21
22
  }
22
23
 
24
+ function normalizedConversationUrl(value) {
25
+ try {
26
+ const parsed = value instanceof URL ? value : new URL(value);
27
+ const pathname = parsed.pathname.replace(/\/+$/, "") || "/";
28
+ return `${parsed.origin}${pathname}`;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function validatedOrderedSnapshot(snapshot) {
35
+ const url = normalizedConversationUrl(snapshot?.url);
36
+ if (!url || !Array.isArray(snapshot?.entries)) {
37
+ return null;
38
+ }
39
+ const ids = new Set();
40
+ const turns = new Set();
41
+ const entries = [];
42
+ let previousDomIndex = -1;
43
+ let previousTurn = -1;
44
+ for (const entry of snapshot.entries) {
45
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
46
+ if (
47
+ !["user", "assistant"].includes(entry?.role)
48
+ || !id
49
+ || !Number.isSafeInteger(entry.turn)
50
+ || entry.turn < 0
51
+ || !Number.isSafeInteger(entry.domIndex)
52
+ || entry.domIndex <= previousDomIndex
53
+ || entry.turn <= previousTurn
54
+ || ids.has(id)
55
+ || turns.has(entry.turn)
56
+ ) {
57
+ return null;
58
+ }
59
+ ids.add(id);
60
+ turns.add(entry.turn);
61
+ previousDomIndex = entry.domIndex;
62
+ previousTurn = entry.turn;
63
+ entries.push({
64
+ domIndex: entry.domIndex,
65
+ role: entry.role,
66
+ id,
67
+ turn: entry.turn,
68
+ renderedText: String(entry.renderedText ?? ""),
69
+ });
70
+ }
71
+ return { url, entries };
72
+ }
73
+
74
+ function sameIdentity(entry, { id, turn }) {
75
+ return entry?.id === id && entry?.turn === turn;
76
+ }
77
+
23
78
  // ChatGPT-specific implementation of the WebModelAdapter contract. Only the
24
- // provider primitives (DOM locators, message identity, mode-switcher port,
25
- // upload, overlay handling, usage-limit card) live here; all orchestration —
26
- // launch/auth/send/reconnect and the turn-completion loop — is inherited from
27
- // BaseWebAdapter.
79
+ // provider primitives (DOM locators, message identity, upload, overlay
80
+ // handling, usage-limit card) live here; all orchestration — launch/auth/send/
81
+ // reconnect and the turn-completion loop — is inherited from BaseWebAdapter.
82
+ // Model choice is intentionally left to the user in the ChatGPT website.
28
83
  export class ChatGPTWebAdapter extends BaseWebAdapter {
29
84
  constructor(options = {}) {
30
85
  super({
@@ -40,6 +95,28 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
40
95
  return /^\/c\//;
41
96
  }
42
97
 
98
+ classifyConversationUrl(value) {
99
+ const kind = super.classifyConversationUrl(value);
100
+ if (kind !== "restorable") {
101
+ return kind;
102
+ }
103
+ const parsed = value instanceof URL ? value : new URL(value);
104
+ return /^\/c\/WEB:/.test(parsed.pathname)
105
+ ? "provisional"
106
+ : "restorable";
107
+ }
108
+
109
+ pendingAttachmentLocators() {
110
+ return [
111
+ this.page.locator(
112
+ 'form [data-testid$="-file-thumbnail"], '
113
+ + 'form [data-testid^="file-thumbnail"], '
114
+ + 'form button[aria-label*="remove file" i], '
115
+ + 'form button[aria-label*="删除文件"]',
116
+ ),
117
+ ];
118
+ }
119
+
43
120
  composerLocators() {
44
121
  return [
45
122
  this.page.locator("#prompt-textarea"),
@@ -120,6 +197,712 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
120
197
  );
121
198
  }
122
199
 
200
+ requiresOrderedConversationSnapshotForFreshSend() {
201
+ return true;
202
+ }
203
+
204
+ async orderedConversationSnapshot() {
205
+ if (typeof this.page?.evaluate !== "function") {
206
+ return null;
207
+ }
208
+ return await this.page.evaluate(() => {
209
+ const entries = [
210
+ ...document.querySelectorAll(
211
+ '[data-message-author-role="user"], [data-message-author-role="assistant"]',
212
+ ),
213
+ ].filter((element) => {
214
+ const role = element.getAttribute("data-message-author-role");
215
+ return role === "user"
216
+ || (role === "assistant" && !element.id.startsWith("request-placeholder-"));
217
+ }).map((element, domIndex) => {
218
+ const role = element.getAttribute("data-message-author-role");
219
+ const wrapper = element.closest('[data-testid^="conversation-turn-"]');
220
+ const turnMatch = wrapper?.getAttribute("data-testid")
221
+ ?.match(/^conversation-turn-(\d+)$/);
222
+ let renderedText = element.innerText ?? element.textContent ?? "";
223
+ if (role === "assistant" && !renderedText.includes("<agent_response")) {
224
+ const codeTexts = [...element.querySelectorAll("pre code")]
225
+ .map((code) => code.innerText ?? code.textContent ?? "");
226
+ const completeEnvelope = codeTexts.find((text) => {
227
+ const start = text.trim().indexOf("<agent_response");
228
+ const end = text.trim().lastIndexOf("</agent_response>");
229
+ return start >= 0 && end >= start;
230
+ });
231
+ const partialEnvelope = codeTexts.find(
232
+ (text) => text.includes("<agent_response"),
233
+ );
234
+ if (completeEnvelope) {
235
+ renderedText = completeEnvelope;
236
+ } else if (partialEnvelope) {
237
+ renderedText = partialEnvelope;
238
+ } else {
239
+ const markdown = [...element.querySelectorAll(".markdown")];
240
+ if (markdown.length > 0) {
241
+ const last = markdown.at(-1);
242
+ renderedText = last.innerText ?? last.textContent ?? "";
243
+ }
244
+ }
245
+ }
246
+ return {
247
+ domIndex,
248
+ role,
249
+ id: element.getAttribute("data-message-id"),
250
+ turn: turnMatch ? Number.parseInt(turnMatch[1], 10) : null,
251
+ renderedText,
252
+ };
253
+ });
254
+ return { url: window.location.href, entries };
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({
527
+ pendingOutbound = null,
528
+ conversationTargetId = null,
529
+ lastUserMessageId = null,
530
+ lastAssistantMessageId = null,
531
+ priorHandoff = null,
532
+ timeoutMs = this.sendConfirmationTimeoutMs(),
533
+ stableWindowMs = this.restorationCorrelationWindowMs(),
534
+ } = {}) {
535
+ this.requirePage();
536
+ const outboundId = pendingOutbound?.outboundId
537
+ ?? priorHandoff?.sourceOutboundId
538
+ ?? null;
539
+ if (!isValidOutboundCorrelationId(outboundId)) {
540
+ throw this.#pendingOutboundUncertain(
541
+ "The pending outbound has no valid transport correlation ID.",
542
+ );
543
+ }
544
+ if (
545
+ typeof conversationTargetId !== "string"
546
+ || conversationTargetId.length === 0
547
+ || !this.hasExactRecoveryTarget(conversationTargetId)
548
+ ) {
549
+ throw new BrowserAdapterError(
550
+ "The exact saved ChatGPT target is unavailable for pending outbound recovery.",
551
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
552
+ );
553
+ }
554
+
555
+ const page = this.page;
556
+ const mainFrame = typeof page.mainFrame === "function"
557
+ ? page.mainFrame()
558
+ : null;
559
+ const initialUrl = normalizedConversationUrl(page.url());
560
+ const initialKind = initialUrl
561
+ ? this.classifyConversationUrl(initialUrl)
562
+ : "invalid";
563
+ if (!["restorable", "provisional"].includes(initialKind)) {
564
+ throw this.#pendingOutboundUncertain(
565
+ "The exact ChatGPT target is not on a recognized conversation route.",
566
+ { currentUrl: initialUrl },
567
+ );
568
+ }
569
+
570
+ let navigated = false;
571
+ const navigationListener = (frame) => {
572
+ if (
573
+ !mainFrame
574
+ || frame === mainFrame
575
+ || (typeof page.mainFrame === "function" && frame === page.mainFrame())
576
+ ) {
577
+ navigated = true;
578
+ }
579
+ };
580
+ page.on?.("framenavigated", navigationListener);
581
+
582
+ const outboundKind = pendingOutbound?.kind
583
+ ?? priorHandoff?.outboundKind
584
+ ?? "runtime_message";
585
+ const requiresEmptyPrefix = ["bootstrap", "fresh_rebuild"]
586
+ .includes(outboundKind);
587
+ const expectedPreOutboundIds = new Set([
588
+ lastUserMessageId,
589
+ lastAssistantMessageId,
590
+ ...(priorHandoff?.preOutboundMarkerIds ?? []),
591
+ ...(priorHandoff?.assistantBaseline?.ids ?? []),
592
+ ].filter(Boolean));
593
+ const expectedUser = priorHandoff
594
+ ? {
595
+ id: priorHandoff.userMessageId ?? null,
596
+ turn: priorHandoff.userTurn ?? null,
597
+ }
598
+ : null;
599
+ const expectedAssistant = priorHandoff?.status === "complete"
600
+ ? {
601
+ id: priorHandoff.assistantMessageId ?? null,
602
+ turn: priorHandoff.assistantTurn ?? null,
603
+ responseHash: priorHandoff.responseHash ?? null,
604
+ }
605
+ : null;
606
+ const expectedWaitingAssistant = priorHandoff?.status === "waiting"
607
+ && priorHandoff.assistantCandidateMessageId
608
+ ? {
609
+ id: priorHandoff.assistantCandidateMessageId,
610
+ turn: priorHandoff.assistantCandidateTurn ?? null,
611
+ }
612
+ : null;
613
+ const deadline = this.monotonicNow() + timeoutMs;
614
+ let stableSignature = null;
615
+ let stableSince = 0;
616
+ let observedUser = null;
617
+ let observedAssistant = expectedWaitingAssistant ?? expectedAssistant ?? null;
618
+
619
+ try {
620
+ while (this.monotonicNow() < deadline) {
621
+ this.#assertRecoveryTarget({
622
+ page,
623
+ mainFrame,
624
+ targetId: conversationTargetId,
625
+ initialUrl,
626
+ navigated,
627
+ });
628
+ const snapshot = validatedOrderedSnapshot(
629
+ await this.#awaitWithinRecoveryDeadline(
630
+ () => this.orderedConversationSnapshot().catch(() => null),
631
+ deadline,
632
+ ),
633
+ );
634
+ if (!snapshot) {
635
+ stableSignature = null;
636
+ stableSince = 0;
637
+ await this.#waitRecoveryPoll(deadline);
638
+ continue;
639
+ }
640
+ this.#assertRecoveryTarget({
641
+ page,
642
+ mainFrame,
643
+ targetId: conversationTargetId,
644
+ initialUrl,
645
+ navigated,
646
+ });
647
+ const hasOutboundMarker = snapshot.entries.some((entry) => (
648
+ renderedMessageContainsOutboundMarker(entry.renderedText, outboundId)
649
+ ));
650
+ if (!hasOutboundMarker) {
651
+ stableSignature = null;
652
+ stableSince = 0;
653
+ await this.#waitRecoveryPoll(deadline);
654
+ continue;
655
+ }
656
+ const {
657
+ user,
658
+ assistant,
659
+ assistantBaselineEntries,
660
+ preOutboundMarkerIds,
661
+ } = this.#recoveryBoundary(snapshot, {
662
+ initialUrl,
663
+ outboundId,
664
+ observedUser,
665
+ expectedUser,
666
+ requiresEmptyPrefix,
667
+ expectedPreOutboundIds,
668
+ expectedWaitingAssistant,
669
+ });
670
+ observedUser = { id: user.id, turn: user.turn };
671
+ if (
672
+ assistant
673
+ && observedAssistant
674
+ && !sameIdentity(assistant, observedAssistant)
675
+ ) {
676
+ throw this.#pendingOutboundUncertain(
677
+ "The ChatGPT assistant successor changed during recovery.",
678
+ );
679
+ }
680
+ if (assistant && !observedAssistant) {
681
+ observedAssistant = { id: assistant.id, turn: assistant.turn };
682
+ }
683
+ if (observedAssistant && !assistant) {
684
+ stableSignature = null;
685
+ stableSince = 0;
686
+ await this.#waitRecoveryPoll(deadline);
687
+ continue;
688
+ }
689
+ let status = "waiting";
690
+ let rawResponse = null;
691
+ let responseHash = null;
692
+ if (assistant) {
693
+ const locator = await this.#awaitWithinRecoveryDeadline(
694
+ () => this.#assistantLocatorFor(assistant),
695
+ deadline,
696
+ );
697
+ if (!locator) {
698
+ stableSignature = null;
699
+ stableSince = 0;
700
+ await this.#waitRecoveryPoll(deadline);
701
+ continue;
702
+ }
703
+ const stopVisible = Boolean(await this.#awaitWithinRecoveryDeadline(
704
+ () => firstVisible(this.stopButtonLocators()),
705
+ deadline,
706
+ ));
707
+ const generating = stopVisible || await this.#awaitWithinRecoveryDeadline(
708
+ () => this.isAssistantGenerating(locator),
709
+ deadline,
710
+ );
711
+ if (!generating) {
712
+ const [generationError, usageLimit] = await this.#awaitWithinRecoveryDeadline(
713
+ () => Promise.all([
714
+ this.findGenerationErrorMarker(locator),
715
+ this.findUsageLimitMarker(locator),
716
+ ]),
717
+ deadline,
718
+ );
719
+ if (!generationError && !usageLimit) {
720
+ rawResponse = assistant.renderedText;
721
+ if (rawResponse.trim()) {
722
+ responseHash = createHash("sha256")
723
+ .update(rawResponse)
724
+ .digest("hex");
725
+ status = "complete";
726
+ }
727
+ }
728
+ }
729
+ }
730
+ if (expectedAssistant) {
731
+ if (
732
+ status !== "complete"
733
+ || !sameIdentity(assistant, expectedAssistant)
734
+ || responseHash !== expectedAssistant.responseHash
735
+ ) {
736
+ throw this.#pendingOutboundUncertain(
737
+ "The completed assistant handoff no longer matches the browser reply.",
738
+ );
739
+ }
740
+ }
741
+
742
+ const signature = [
743
+ initialUrl,
744
+ conversationTargetId,
745
+ user.id,
746
+ user.turn,
747
+ preOutboundMarkerIds.join(","),
748
+ assistant?.id ?? "",
749
+ assistant?.turn ?? "",
750
+ status,
751
+ responseHash ?? "",
752
+ ].join(":");
753
+ const observedAt = this.monotonicNow();
754
+ if (signature !== stableSignature) {
755
+ stableSignature = signature;
756
+ stableSince = observedAt;
757
+ }
758
+ if (observedAt - stableSince < stableWindowMs) {
759
+ await this.#waitRecoveryPoll(deadline);
760
+ continue;
761
+ }
762
+ if (this.monotonicNow() >= deadline) {
763
+ throw this.#recoveryDeadlineError();
764
+ }
765
+
766
+ const finalSnapshot = validatedOrderedSnapshot(
767
+ await this.#awaitWithinRecoveryDeadline(
768
+ () => this.orderedConversationSnapshot().catch(() => null),
769
+ deadline,
770
+ ),
771
+ );
772
+ if (
773
+ !finalSnapshot
774
+ || !finalSnapshot.entries.some((entry) => (
775
+ renderedMessageContainsOutboundMarker(entry.renderedText, outboundId)
776
+ ))
777
+ ) {
778
+ stableSignature = null;
779
+ stableSince = 0;
780
+ await this.#waitRecoveryPoll(deadline);
781
+ continue;
782
+ }
783
+ this.#assertRecoveryTarget({
784
+ page,
785
+ mainFrame,
786
+ targetId: conversationTargetId,
787
+ initialUrl,
788
+ navigated,
789
+ });
790
+ const finalBoundary = this.#recoveryBoundary(finalSnapshot, {
791
+ initialUrl,
792
+ outboundId,
793
+ observedUser,
794
+ expectedUser,
795
+ requiresEmptyPrefix,
796
+ expectedPreOutboundIds,
797
+ expectedWaitingAssistant,
798
+ });
799
+ if (expectedWaitingAssistant && !finalBoundary.assistant) {
800
+ stableSignature = null;
801
+ stableSince = 0;
802
+ await this.#waitRecoveryPoll(deadline);
803
+ continue;
804
+ }
805
+ if (
806
+ Boolean(finalBoundary.assistant) !== Boolean(assistant)
807
+ || (
808
+ assistant
809
+ && !sameIdentity(finalBoundary.assistant, assistant)
810
+ )
811
+ ) {
812
+ if (!assistant && finalBoundary.assistant) {
813
+ stableSignature = null;
814
+ stableSince = 0;
815
+ await this.#waitRecoveryPoll(deadline);
816
+ continue;
817
+ }
818
+ throw this.#pendingOutboundUncertain(
819
+ "The ChatGPT assistant boundary changed during recovery verification.",
820
+ );
821
+ }
822
+ if (
823
+ finalBoundary.preOutboundMarkerIds.join(",")
824
+ !== preOutboundMarkerIds.join(",")
825
+ ) {
826
+ stableSignature = null;
827
+ stableSince = 0;
828
+ await this.#waitRecoveryPoll(deadline);
829
+ continue;
830
+ }
831
+ if (status === "complete") {
832
+ const finalRawResponse = finalBoundary.assistant?.renderedText ?? "";
833
+ const finalResponseHash = createHash("sha256")
834
+ .update(finalRawResponse)
835
+ .digest("hex");
836
+ if (finalResponseHash !== responseHash) {
837
+ stableSignature = null;
838
+ stableSince = 0;
839
+ await this.#waitRecoveryPoll(deadline);
840
+ continue;
841
+ }
842
+ rawResponse = finalRawResponse;
843
+ }
844
+
845
+ const assistantBaseline = {
846
+ ids: assistantBaselineEntries.map((entry) => entry.id),
847
+ count: assistantBaselineEntries.length,
848
+ maxTurn: assistantBaselineEntries.at(-1)?.turn ?? null,
849
+ lastText: assistantBaselineEntries.at(-1)?.renderedText ?? "",
850
+ };
851
+ this.acceptRecoveredOutboundAnchor({
852
+ conversationUrl: initialUrl,
853
+ conversationTargetId,
854
+ userMessageId: user.id,
855
+ userTurn: user.turn,
856
+ assistantBaseline,
857
+ assistantCandidateMessageId: assistant?.id ?? null,
858
+ assistantCandidateTurn: assistant?.turn ?? null,
859
+ });
860
+ if (status === "complete") {
861
+ this.lastAssistantMessageId = assistant.id;
862
+ this.lastAssistantTurn = assistant.turn;
863
+ }
864
+ if (this.monotonicNow() >= deadline) {
865
+ throw this.#recoveryDeadlineError();
866
+ }
867
+ this.#assertRecoveryTarget({
868
+ page,
869
+ mainFrame,
870
+ targetId: conversationTargetId,
871
+ initialUrl,
872
+ navigated,
873
+ });
874
+ return {
875
+ status,
876
+ conversationUrl: initialUrl,
877
+ conversationTargetId,
878
+ userMessageId: user.id,
879
+ userTurn: user.turn,
880
+ preOutboundMarkerIds,
881
+ assistantBaseline,
882
+ ...(assistant
883
+ ? {
884
+ assistantCandidateMessageId: assistant.id,
885
+ assistantCandidateTurn: assistant.turn,
886
+ }
887
+ : {}),
888
+ ...(status === "complete"
889
+ ? {
890
+ assistantMessageId: assistant.id,
891
+ assistantTurn: assistant.turn,
892
+ rawResponse,
893
+ responseHash,
894
+ }
895
+ : {}),
896
+ };
897
+ }
898
+ throw this.#pendingOutboundUncertain(
899
+ "ChatGPT could not stably reconcile the pending outbound on its exact target.",
900
+ );
901
+ } finally {
902
+ page.off?.("framenavigated", navigationListener);
903
+ }
904
+ }
905
+
123
906
  async messageIdentity(message) {
124
907
  const [id, turn] = await Promise.all([
125
908
  message.getAttribute("data-message-id").catch(() => null),
@@ -160,6 +943,15 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
160
943
  if (super.isNewAssistantIdentity({ id, turn })) {
161
944
  return true;
162
945
  }
946
+ if (
947
+ !this.allowInPlaceAssistantContinuation
948
+ || !this.inPlaceAssistantGenerationObserved
949
+ || this.sentUserTurn == null
950
+ || !id
951
+ || !this.assistantIdsBeforeSend.has(id)
952
+ ) {
953
+ return false;
954
+ }
163
955
  const previous = String(this.lastAssistantTextBeforeSend ?? "");
164
956
  return previous !== "" && String(text ?? "") !== previous;
165
957
  }
@@ -337,211 +1129,4 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
337
1129
  }).catch(() => null);
338
1130
  }
339
1131
 
340
- // DOM port for mode-selection.js. Stable attribute slugs are preferred;
341
- // exact visible labels are a fallback for current menus that omit those
342
- // attributes. Disabled state is still read from DOM state, never from text.
343
- modeSelectionPort() {
344
- const page = this.page;
345
- return {
346
- alreadyOnMode: async (requested) => {
347
- const token = normalizeToken(requested);
348
- const switcher = await this.#findModelSwitcher();
349
- const switcherLabel = switcher
350
- ? await switcher.innerText().catch(() => "")
351
- : "";
352
- if (labelMatchesToken(switcherLabel, token)) {
353
- return true;
354
- }
355
-
356
- const messages = this.assistantMessages();
357
- if (await messages.count() === 0) {
358
- return false;
359
- }
360
- const slug = await messages.last()
361
- .getAttribute("data-message-model-slug")
362
- .catch(() => null);
363
- return Boolean(slug) && slugMatchesToken(slug, token);
364
- },
365
- hasSwitcher: async () => Boolean(await this.#findModelSwitcher()),
366
- openMenu: async (requested) => {
367
- const switcher = await this.#findModelSwitcher();
368
- if (switcher) {
369
- const opened = await switcher.click({ timeout: 5_000 })
370
- .then(() => true)
371
- .catch(() => false);
372
- if (!opened) {
373
- await switcher.click({ force: true, timeout: 5_000 })
374
- .catch(() => null);
375
- }
376
- // Wait for the Radix menu portal to attach before reading options.
377
- await page.locator('[role="menu"]:visible [role="menuitem"]:visible, [role="menu"]:visible [role="menuitemradio"]:visible')
378
- .first().waitFor({ state: "visible", timeout: 5_000 })
379
- .catch(() => null);
380
- await this.#revealNestedModeOption(requested);
381
- }
382
- },
383
- readOptions: async () => this.#readModeOptions(),
384
- clickOption: async (index) => {
385
- const option = this.#modeOptionLocators().nth(index);
386
- try {
387
- await option.click({ timeout: 5_000 });
388
- return true;
389
- } catch {
390
- return false;
391
- }
392
- },
393
- waitClosed: async () => {
394
- const ok = await page.locator('[role="menu"]').first()
395
- .waitFor({ state: "hidden", timeout: 5_000 })
396
- .then(() => true)
397
- .catch(() => false);
398
- return ok;
399
- },
400
- waitSelected: async (requested) => {
401
- const token = normalizeToken(requested);
402
- const deadline = Date.now() + 5_000;
403
- while (Date.now() < deadline) {
404
- const switcher = await this.#findModelSwitcher(500);
405
- const label = switcher
406
- ? await switcher.innerText().catch(() => "")
407
- : "";
408
- if (labelMatchesToken(label, token)) {
409
- return true;
410
- }
411
-
412
- const selectedOption = (await this.#readModeOptions()).find(
413
- (option) => option.selected
414
- && (
415
- slugMatchesToken(option.slug, token)
416
- || labelMatchesToken(option.label, token)
417
- ),
418
- );
419
- if (selectedOption) {
420
- return true;
421
- }
422
- await page.waitForTimeout(100);
423
- }
424
- return false;
425
- },
426
- closeMenu: async () => {
427
- await page.keyboard.press("Escape").catch(() => null);
428
- await page.waitForTimeout(150);
429
- },
430
- writeDiagnostics: async (label) => this.writeDiagnostics(label),
431
- };
432
- }
433
-
434
- // ---- ChatGPT-only mode-picker helpers (private) ------------------------
435
-
436
- #modeOptionLocators() {
437
- return this.page.locator(
438
- '[role="menu"]:visible [role="menuitemradio"]:visible, '
439
- + '[role="menu"]:visible [role="menuitem"]:visible',
440
- );
441
- }
442
-
443
- async #revealNestedModeOption(requested) {
444
- const present = (options) => {
445
- const choice = chooseModeOption(options, requested);
446
- return choice.status !== "unavailable";
447
- };
448
-
449
- let options = await this.#readModeOptions();
450
- if (present(options)) {
451
- return;
452
- }
453
-
454
- const submenuTriggers = this.page.locator(
455
- '[role="menu"]:visible [role="menuitem"][aria-haspopup="menu"]:visible',
456
- );
457
- const count = await submenuTriggers.count().catch(() => 0);
458
- for (let index = 0; index < count; index += 1) {
459
- const trigger = submenuTriggers.nth(index);
460
- await trigger.dispatchEvent("pointermove", { pointerType: "mouse" })
461
- .catch(() => null);
462
- await this.page.waitForTimeout(250);
463
- options = await this.#readModeOptions();
464
- if (present(options)) {
465
- return;
466
- }
467
- }
468
- }
469
-
470
- // Enumerates the open menu's options into { index, slug, label, disabled }.
471
- // `slug` is the first stable attribute found and may be empty in current
472
- // ChatGPT menus; `disabled` is read from ARIA / data-state, never from text.
473
- async #readModeOptions() {
474
- const locator = this.#modeOptionLocators();
475
- const count = await locator.count().catch(() => 0);
476
- const options = [];
477
- for (let index = 0; index < count; index += 1) {
478
- const item = locator.nth(index);
479
- const [testId, dataValue, dataTestValue, id, ariaDisabled, ariaChecked, dataDisabled, dataState, label] =
480
- await Promise.all([
481
- item.getAttribute("data-testid").catch(() => null),
482
- item.getAttribute("data-value").catch(() => null),
483
- item.getAttribute("data-test-value").catch(() => null),
484
- item.getAttribute("id").catch(() => null),
485
- item.getAttribute("aria-disabled").catch(() => null),
486
- item.getAttribute("aria-checked").catch(() => null),
487
- item.getAttribute("data-disabled").catch(() => null),
488
- item.getAttribute("data-state").catch(() => null),
489
- item.innerText().catch(() => ""),
490
- ]);
491
- const slug = [testId, dataValue, dataTestValue, id].find(Boolean) ?? "";
492
- const disabled = ariaDisabled === "true"
493
- || dataDisabled === "true"
494
- || dataDisabled === ""
495
- || dataState === "disabled"
496
- || !await item.isEnabled().catch(() => true);
497
- const selected = ariaChecked === "true" || dataState === "checked";
498
- options.push({
499
- index,
500
- slug,
501
- label: (label ?? "").trim(),
502
- disabled,
503
- selected,
504
- });
505
- }
506
- return options;
507
- }
508
-
509
- async #findModelSwitcher(timeoutMs = 45_000) {
510
- const deadline = Date.now() + timeoutMs;
511
-
512
- while (Date.now() < deadline) {
513
- const direct = await firstVisible([
514
- this.page.locator('[data-testid="model-switcher-dropdown-button"]'),
515
- this.page.locator(
516
- 'main button.__composer-pill[aria-haspopup="menu"]',
517
- ),
518
- this.page.locator('button[aria-label*="model" i]'),
519
- this.page.locator('button[aria-label*="模式"]'),
520
- ]);
521
- if (direct) {
522
- return direct;
523
- }
524
-
525
- const menuButtons = this.page.locator(
526
- 'main button[aria-haspopup="menu"]:not([data-testid^="history-item-"])',
527
- );
528
- const count = await menuButtons.count();
529
- for (let index = 0; index < count; index += 1) {
530
- const button = menuButtons.nth(index);
531
- if (!await button.isVisible().catch(() => false)) continue;
532
- const text = (await button.innerText().catch(() => "")).trim();
533
- const label = await button.getAttribute("aria-label").catch(() => "");
534
- if (
535
- /^(chatgpt|gpt(?:-[\w.]+)?|auto|instant|thinking|pro)$/i.test(text)
536
- || /model|模型|模式/i.test(label ?? "")
537
- ) {
538
- return button;
539
- }
540
- }
541
-
542
- await this.page.waitForTimeout(250);
543
- }
544
-
545
- return null;
546
- }
547
1132
  }