wtagent 0.4.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 (34) hide show
  1. package/README.md +161 -7
  2. package/package.json +5 -4
  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 +354 -50
  7. package/src/browser/chatgpt-dom.js +179 -0
  8. package/src/browser/chatgpt-web-adapter.js +89 -125
  9. package/src/browser/fake-web-model-adapter.js +16 -1
  10. package/src/browser/gemini-web-adapter.js +61 -20
  11. package/src/browser/grok-web-adapter.js +35 -3
  12. package/src/browser/rendered-text.js +11 -0
  13. package/src/cli/i18n.js +40 -10
  14. package/src/cli/main.js +32 -3
  15. package/src/cli/prompt-input.js +68 -0
  16. package/src/image/browser-image-driver.js +238 -0
  17. package/src/image/browser-image-session-pool.js +96 -0
  18. package/src/image/image-generation-service.js +101 -0
  19. package/src/image/image-provider-driver.js +22 -0
  20. package/src/image/native-image-receiver.js +66 -0
  21. package/src/image/provider-registry.js +23 -0
  22. package/src/image/providers/chatgpt-image-driver.js +211 -0
  23. package/src/image/providers/gemini-image-driver.js +100 -0
  24. package/src/image/providers/grok-image-driver.js +193 -0
  25. package/src/policy/policy-engine.js +10 -5
  26. package/src/protocol/envelope.js +74 -0
  27. package/src/protocol/markers.js +16 -0
  28. package/src/protocol/prompt-builder.js +12 -9
  29. package/src/protocol/xml-protocol.js +36 -18
  30. package/src/runtime/agent-runtime.js +345 -65
  31. package/src/session/agent-session.js +73 -2
  32. package/src/tools/image-tools.js +92 -0
  33. package/src/tools/registry.js +13 -1
  34. package/docs/technical-design.md +0 -873
@@ -5,6 +5,8 @@ import { launchAndConnectCdpChrome } from "./cdp-browser.js";
5
5
  import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
6
6
  import { ensureDirectory } from "../platform/paths.js";
7
7
  import { BrowserAdapterError } from "../shared/errors.js";
8
+ import { hasCompleteAgentEnvelope } from "../protocol/envelope.js";
9
+ export { hasCompleteAgentEnvelope } from "../protocol/envelope.js";
8
10
 
9
11
  // Playwright error messages for a dead transport. The Chrome process itself is
10
12
  // usually still alive (e.g. the connection died while the Mac slept); these
@@ -68,17 +70,6 @@ function deltaFrom(previous, current) {
68
70
  return "";
69
71
  }
70
72
 
71
- export function hasCompleteAgentEnvelope(text) {
72
- const trimmed = String(text ?? "").trim();
73
- const start = trimmed.indexOf("<agent_response");
74
- const endTag = "</agent_response>";
75
- const end = trimmed.lastIndexOf(endTag);
76
- // The envelope is "complete" as soon as both the opening and closing tags
77
- // are present. The web model may append trailing text or render rich cards
78
- // after the XML, so we do not require the closing tag to be the last content.
79
- return start >= 0 && end >= start;
80
- }
81
-
82
73
  function sameConversationUrl(left, right) {
83
74
  const normalizedLeft = normalizedConversationUrl(left);
84
75
  return normalizedLeft != null
@@ -220,6 +211,7 @@ export class BaseWebAdapter {
220
211
  this.lastAssistantMessageId = null;
221
212
  this.lastAssistantTurn = null;
222
213
  this.lastSendStatus = "not-submitted";
214
+ this.pendingSubmission = null;
223
215
  }
224
216
 
225
217
  // ---- provider primitives (override in subclasses) ----------------------
@@ -342,6 +334,13 @@ export class BaseWebAdapter {
342
334
  return await message.innerText().catch(() => "");
343
335
  }
344
336
 
337
+ // Virtualized providers can read identities and text in one DOM operation.
338
+ // undefined uses legacy locators; null means a supported sample failed and
339
+ // must be retried, never mixed with a different DOM revision.
340
+ async userMessageSnapshot() {
341
+ return undefined;
342
+ }
343
+
345
344
  // Providers with a shared user/assistant turn order return one atomic snapshot.
346
345
  // null keeps legacy normal-send behavior but cannot support ambiguous recovery.
347
346
  async orderedConversationSnapshot() {
@@ -415,17 +414,22 @@ export class BaseWebAdapter {
415
414
  // Submit the already-filled composer. Providers that need a native pointer
416
415
  // sequence may override this while retaining the shared post-send identity
417
416
  // checks and one-shot retry.
418
- async submitComposer(composer) {
417
+ async submitComposer(composer, { assertActive = () => {}, timeoutMs } = {}) {
418
+ assertActive();
419
419
  const sendButton = await firstVisible(this.sendButtonLocators());
420
+ assertActive();
420
421
  if (sendButton && await sendButton.isEnabled().catch(() => false)) {
421
422
  try {
422
- await sendButton.click();
423
+ assertActive();
424
+ await sendButton.click(timeoutMs == null ? {} : { timeout: timeoutMs });
423
425
  return;
424
426
  } catch {
427
+ assertActive();
425
428
  await this.dismissTransientOverlays();
426
429
  }
427
430
  }
428
- await composer.press("Enter");
431
+ assertActive();
432
+ await composer.press("Enter", timeoutMs == null ? {} : { timeout: timeoutMs });
429
433
  }
430
434
 
431
435
  // Scroll a virtualized thread so the latest replies mount. No-op by default.
@@ -485,10 +489,25 @@ export class BaseWebAdapter {
485
489
  return new BrowserAdapterError(
486
490
  `${this.providerName} did not confirm the submitted message before the causal deadline. `
487
491
  + "It was not sent again automatically.",
488
- { code: "SEND_COMMIT_UNKNOWN", recoverable: false },
492
+ {
493
+ code: "SEND_COMMIT_UNKNOWN",
494
+ recoverable: false,
495
+ details: this.#sendDiagnosticDetails(),
496
+ },
489
497
  );
490
498
  }
491
499
 
500
+ #sendDiagnosticDetails() {
501
+ const proof = this.activeSendProof;
502
+ return {
503
+ stage: proof?.stage ?? "unknown",
504
+ sendStatus: this.lastSendStatus,
505
+ elapsedMs: proof?.submittedAt == null
506
+ ? null : Math.round(this.monotonicNow() - proof.submittedAt),
507
+ ...(proof?.sample ?? {}),
508
+ };
509
+ }
510
+
492
511
  async #awaitWithinSendDeadline(promise, deadline) {
493
512
  const remaining = deadline - this.monotonicNow();
494
513
  if (remaining <= 0) {
@@ -698,7 +717,11 @@ export class BaseWebAdapter {
698
717
  return true;
699
718
  }
700
719
 
701
- #captureActiveTurnProof(conversationUrl) {
720
+ #captureActiveTurnProof(conversationUrl, {
721
+ outboundId = null,
722
+ userMessageId = null,
723
+ userTurn = null,
724
+ } = {}) {
702
725
  const normalizedUrl = normalizedConversationUrl(conversationUrl);
703
726
  this.activeTurnProof = {
704
727
  page: this.page,
@@ -708,6 +731,9 @@ export class BaseWebAdapter {
708
731
  targetId: this.cdpChrome?.targetId ?? null,
709
732
  scopeEpoch: this.conversationScopeEpoch,
710
733
  conversationUrl: normalizedUrl,
734
+ outboundId,
735
+ userMessageId,
736
+ userTurn,
711
737
  invalidated: false,
712
738
  };
713
739
  this.activeTurnConversationUrl = normalizedUrl;
@@ -903,14 +929,57 @@ export class BaseWebAdapter {
903
929
  this.expectedRestorationUserId
904
930
  && user.ids.has(this.expectedRestorationUserId)
905
931
  );
906
- if (!assistantMatched && !userMatched) {
932
+ let currentSendUserMatched = Boolean(
933
+ this.freshSendConfirmed
934
+ && this.lastUserMessageId
935
+ && user.ids.has(this.lastUserMessageId)
936
+ );
937
+ let currentSendMarker = this.lastUserMessageId;
938
+ if (
939
+ !currentSendUserMatched
940
+ && this.freshSendConfirmed
941
+ && isValidOutboundCorrelationId(this.activeTurnProof?.outboundId)
942
+ ) {
943
+ const messages = this.userMessages();
944
+ const count = await messages.count().catch(() => 0);
945
+ for (let index = count - 1; index >= 0; index -= 1) {
946
+ const message = messages.nth(index);
947
+ const identity = await this.messageIdentity(message);
948
+ if (
949
+ this.activeTurnProof.userTurn != null
950
+ && identity.turn != null
951
+ && identity.turn !== this.activeTurnProof.userTurn
952
+ ) {
953
+ continue;
954
+ }
955
+ const renderedText = await this.userMessageText(message);
956
+ if (!renderedMessageContainsOutboundMarker(
957
+ renderedText,
958
+ this.activeTurnProof.outboundId,
959
+ )) {
960
+ continue;
961
+ }
962
+ currentSendUserMatched = true;
963
+ currentSendMarker = identity.id
964
+ ?? `turn:${identity.turn ?? this.activeTurnProof.userTurn ?? "unknown"}`;
965
+ if (identity.id) {
966
+ this.lastUserMessageId = identity.id;
967
+ this.expectedRestorationUserId = identity.id;
968
+ this.activeTurnProof.userMessageId = identity.id;
969
+ }
970
+ break;
971
+ }
972
+ }
973
+ if (!assistantMatched && !userMatched && !currentSendUserMatched) {
907
974
  this.#resetPendingCanonicalVerification();
908
975
  return false;
909
976
  }
910
977
 
911
- const matchedMarker = assistantMatched
912
- ? `assistant:${this.expectedRestorationAssistantId}`
913
- : `user:${this.expectedRestorationUserId}`;
978
+ const matchedMarker = currentSendUserMatched
979
+ ? `user:${currentSendMarker}`
980
+ : assistantMatched
981
+ ? `assistant:${this.expectedRestorationAssistantId}`
982
+ : `user:${this.expectedRestorationUserId}`;
914
983
  const signature = [
915
984
  candidate,
916
985
  candidateKind,
@@ -976,6 +1045,7 @@ export class BaseWebAdapter {
976
1045
 
977
1046
  #observeConversationNavigation(value, { navigationEvent = false } = {}) {
978
1047
  const normalized = normalizedConversationUrl(value);
1048
+ if (normalized) this.recordConversationNavigation(normalized);
979
1049
  if (!normalized) {
980
1050
  if (this.activeSendProof) {
981
1051
  this.activeSendProof.invalidated = true;
@@ -997,10 +1067,18 @@ export class BaseWebAdapter {
997
1067
  // must be observed first for this send epoch.
998
1068
  this.activeSendProof.invalidated = true;
999
1069
  }
1000
- if (navigationEvent && this.activeSendProof) {
1070
+ if (
1071
+ navigationEvent
1072
+ && this.activeSendProof
1073
+ && !sameConversationUrl(normalized, this.activeSendProof.currentUrl)
1074
+ ) {
1001
1075
  // Every top-frame navigation invalidates the current proof until this exact
1002
- // event is classified as an allowed causal transition below. A prior bad
1003
- // transition is permanent; a later plausible route cannot rehabilitate it.
1076
+ // conversation change is classified as an allowed causal transition below.
1077
+ // Providers can emit several history events for one route (for example
1078
+ // adding a response-id query parameter); those retain the same normalized
1079
+ // conversation identity and must not destroy an otherwise intact proof.
1080
+ // A prior bad transition is permanent; a later plausible route cannot
1081
+ // rehabilitate it.
1004
1082
  this.activeSendProof.invalidated = true;
1005
1083
  }
1006
1084
  this.lastObservedPageUrl = normalized;
@@ -1706,6 +1784,79 @@ export class BaseWebAdapter {
1706
1784
  return this.lastAssistantTurn;
1707
1785
  }
1708
1786
 
1787
+ async captureAuxiliaryTurnCompletion({ timeoutMs = 30_000 } = {}) {
1788
+ this.requirePage();
1789
+ const deadline = this.monotonicNow() + Math.max(0, Number(timeoutMs) || 0);
1790
+ let candidate = null;
1791
+ let conversationUrl = null;
1792
+ while (this.monotonicNow() <= deadline) {
1793
+ // A fresh provider chat may allocate its durable conversation URL only
1794
+ // after the generated image has rendered. Keep the exact page/frame/
1795
+ // target binding while the saved user-message marker stabilizes; do not
1796
+ // read or accept an assistant boundary from an uncorrelated candidate URL.
1797
+ if (
1798
+ this.activeTurnConversationUrl
1799
+ && !this.#activeTurnProofIsIntact({ allowPendingCanonicalUrl: true })
1800
+ ) {
1801
+ throw new BrowserAdapterError(
1802
+ `${this.providerName} left the verified conversation during the provider operation.`,
1803
+ { code: "CONVERSATION_CHANGED_DURING_TURN", recoverable: false },
1804
+ );
1805
+ }
1806
+ const pageState = await this.#submittedPageState();
1807
+ if (pageState.status === "candidate") {
1808
+ await this.waitForPoll(250);
1809
+ continue;
1810
+ }
1811
+ if (pageState.status !== "trusted") {
1812
+ throw new BrowserAdapterError(
1813
+ `${this.providerName} left the verified conversation during the provider operation.`,
1814
+ { code: "CONVERSATION_CHANGED_DURING_TURN", recoverable: false },
1815
+ );
1816
+ }
1817
+ conversationUrl = pageState.conversationUrl;
1818
+ this.activeTurnConversationUrl = conversationUrl;
1819
+ if (this.activeTurnProof) {
1820
+ this.activeTurnProof.conversationUrl = conversationUrl;
1821
+ }
1822
+
1823
+ const messages = this.assistantMessages();
1824
+ const count = await messages.count().catch(() => 0);
1825
+ if (count > 0) {
1826
+ const message = messages.last();
1827
+ const { id, turn } = await this.messageIdentity(message);
1828
+ const text = await this.assistantText(message).catch(() => "");
1829
+ const generating = await this.isAssistantGenerating(message);
1830
+ if (
1831
+ !generating
1832
+ && this.isNewAssistantIdentity({ id, turn, text, generating: false })
1833
+ ) {
1834
+ candidate = { id, turn };
1835
+ break;
1836
+ }
1837
+ }
1838
+ await this.waitForPoll(250);
1839
+ }
1840
+ if (!candidate) {
1841
+ throw new BrowserAdapterError(
1842
+ `${this.providerName} did not expose a completed, new assistant boundary for the provider operation.`,
1843
+ { code: "AUXILIARY_TURN_UNCORRELATED", recoverable: false },
1844
+ );
1845
+ }
1846
+ await this.#validateRecoveredTurnBoundary({
1847
+ assistantPresent: true,
1848
+ assistantMessageId: candidate.id,
1849
+ assistantTurn: candidate.turn,
1850
+ });
1851
+ this.lastAssistantMessageId = candidate.id ?? null;
1852
+ this.lastAssistantTurn = candidate.turn ?? null;
1853
+ return {
1854
+ assistantMessageId: this.lastAssistantMessageId,
1855
+ assistantTurn: this.lastAssistantTurn,
1856
+ conversationUrl,
1857
+ };
1858
+ }
1859
+
1709
1860
  getLastSendStatus() {
1710
1861
  return this.lastSendStatus;
1711
1862
  }
@@ -1715,8 +1866,15 @@ export class BaseWebAdapter {
1715
1866
  maxBytes = null,
1716
1867
  outboundId = null,
1717
1868
  allowAssistantContinuation = false,
1869
+ requireAttachments = false,
1718
1870
  } = {}) {
1719
1871
  this.requirePage();
1872
+ if (this.pendingSubmission) {
1873
+ throw new BrowserAdapterError(
1874
+ "The previous submit action is still in flight. No new message was composed.",
1875
+ { code: "SEND_COMMIT_UNKNOWN", recoverable: false, details: { stage: "previous-submit" } },
1876
+ );
1877
+ }
1720
1878
  this.lastSendStatus = "not-submitted";
1721
1879
  this.expectedAssistantCandidateId = null;
1722
1880
  this.expectedAssistantCandidateTurn = null;
@@ -1792,6 +1950,8 @@ export class BaseWebAdapter {
1792
1950
  submissionStarted: false,
1793
1951
  submittedAt: null,
1794
1952
  deadline: null,
1953
+ stage: "prepare",
1954
+ sample: null,
1795
1955
  orderedBaseline: null,
1796
1956
  freshCandidateUserId: null,
1797
1957
  freshCandidateUserTurn: null,
@@ -1807,6 +1967,16 @@ export class BaseWebAdapter {
1807
1967
  let attachment = null;
1808
1968
  if (files.length > 0) {
1809
1969
  attachment = await this.attachFiles(files);
1970
+ if (requireAttachments && (attachment?.failed?.length ?? 0) > 0) {
1971
+ throw new BrowserAdapterError(
1972
+ `${this.providerName} could not attach every required file. Nothing was sent.`,
1973
+ {
1974
+ code: "ATTACHMENT_UPLOAD_FAILED",
1975
+ recoverable: false,
1976
+ details: { attachment },
1977
+ },
1978
+ );
1979
+ }
1810
1980
  }
1811
1981
 
1812
1982
  const assistantMessages = this.assistantMessages();
@@ -1884,11 +2054,25 @@ export class BaseWebAdapter {
1884
2054
  sendProof.deadline = sendProof.submittedAt
1885
2055
  + this.sendConfirmationTimeoutMs();
1886
2056
  this.lastSendStatus = "commit-unknown";
1887
- await this.#awaitWithinSendDeadline(
1888
- this.submitComposer(composer),
1889
- sendProof.deadline,
1890
- );
2057
+ sendProof.stage = "submit";
2058
+ const assertActive = () => {
2059
+ if (sendProof.invalidated || this.activeSendProof !== sendProof
2060
+ || this.monotonicNow() >= sendProof.deadline) {
2061
+ throw this.#sendDeadlineError();
2062
+ }
2063
+ };
2064
+ const submission = Promise.resolve().then(() => this.submitComposer(composer, {
2065
+ assertActive,
2066
+ timeoutMs: Math.max(1, Math.ceil(sendProof.deadline - this.monotonicNow())),
2067
+ }));
2068
+ this.pendingSubmission = submission;
2069
+ const settled = () => {
2070
+ if (this.pendingSubmission === submission) this.pendingSubmission = null;
2071
+ };
2072
+ submission.then(settled, settled);
2073
+ await this.#awaitWithinSendDeadline(submission, sendProof.deadline);
1891
2074
 
2075
+ sendProof.stage = "user-message-confirmation";
1892
2076
  const sentMessage = await this.#waitForSentUserMessage(userBaseline, {
1893
2077
  deadline: sendProof.deadline,
1894
2078
  expectedText: text,
@@ -1913,6 +2097,7 @@ export class BaseWebAdapter {
1913
2097
  this.#observeConversationNavigation(this.page.url());
1914
2098
  }
1915
2099
 
2100
+ sendProof.stage = "conversation-confirmation";
1916
2101
  let submittedPage = await this.#awaitWithinSendDeadline(
1917
2102
  this.#submittedPageState(),
1918
2103
  sendProof.deadline,
@@ -1938,7 +2123,11 @@ export class BaseWebAdapter {
1938
2123
  if (this.lastUserMessageId) {
1939
2124
  this.expectedRestorationUserId = this.lastUserMessageId;
1940
2125
  }
1941
- this.#captureActiveTurnProof(submittedPage.conversationUrl);
2126
+ this.#captureActiveTurnProof(submittedPage.conversationUrl, {
2127
+ outboundId,
2128
+ userMessageId: this.lastUserMessageId,
2129
+ userTurn: sentMessage.turn ?? null,
2130
+ });
1942
2131
  this.lastSendStatus = "confirmed";
1943
2132
  return {
1944
2133
  attachment,
@@ -1957,6 +2146,20 @@ export class BaseWebAdapter {
1957
2146
  ...assistantBaseline.ids,
1958
2147
  ],
1959
2148
  };
2149
+ } catch (error) {
2150
+ if (error?.code === "SEND_COMMIT_UNKNOWN") {
2151
+ error.completionUnknown = true;
2152
+ error.details = { ...this.#sendDiagnosticDetails(), ...(error.details ?? {}) };
2153
+ // Invalidate before collecting diagnostics: delayed submit callbacks
2154
+ // must not enter a fallback action while this read-only capture runs.
2155
+ if (sendProof) sendProof.invalidated = true;
2156
+ const diagnostics = await this.writeDiagnostics("send-commit-unknown", error.details).catch(() => null);
2157
+ if (diagnostics?.filename) {
2158
+ error.details.diagnosticFile = diagnostics.filename;
2159
+ error.details.dom = diagnostics.report.dom;
2160
+ }
2161
+ }
2162
+ throw error;
1960
2163
  } finally {
1961
2164
  if (sendProof && this.activeSendProof === sendProof) {
1962
2165
  sendProof.invalidated = true;
@@ -1977,6 +2180,8 @@ export class BaseWebAdapter {
1977
2180
  truncatedEnvelopeWindowMs = null,
1978
2181
  emptyResponseWindowMs = 10_000,
1979
2182
  deadRequestGraceMs = 60_000,
2183
+ readNativeImages = null,
2184
+ readNativeMusic = null,
1980
2185
  onDelta,
1981
2186
  }) {
1982
2187
  this.requirePage();
@@ -1990,6 +2195,8 @@ export class BaseWebAdapter {
1990
2195
  let sawGenerationSignal = false;
1991
2196
  let lastGenerationSignalAt = 0;
1992
2197
  let unverifiedPageChecks = 0;
2198
+ let nativeCandidate = null;
2199
+ let nativeStableSince = 0;
1993
2200
  const detachEscCancel = this.#attachEscCancel();
1994
2201
  const truncatedGraceMs = truncatedEnvelopeWindowMs
1995
2202
  ?? this.truncatedEnvelopeGraceMs();
@@ -2131,6 +2338,33 @@ export class BaseWebAdapter {
2131
2338
  }
2132
2339
  }
2133
2340
 
2341
+ if (readNativeImages || readNativeMusic) {
2342
+ const snapshot = await (readNativeMusic ?? readNativeImages)(lastMessage);
2343
+ const media = readNativeMusic ? snapshot.music : snapshot.images;
2344
+ if (snapshot.pending || media.length > 0) {
2345
+ emptySince = 0;
2346
+ emptyCandidate = null;
2347
+ const signature = JSON.stringify([candidateId, candidateTurn, text, media]);
2348
+ if (generating || snapshot.pending || signature !== nativeCandidate) {
2349
+ nativeCandidate = signature;
2350
+ nativeStableSince = Date.now();
2351
+ } else if (Date.now() - nativeStableSince >= Math.max(2_000, stableWindowMs)) {
2352
+ await this.#validateRecoveredTurnBoundary({
2353
+ assistantPresent: true,
2354
+ assistantMessageId: candidateId,
2355
+ assistantTurn: candidateTurn,
2356
+ });
2357
+ this.lastAssistantMessageId = candidateId;
2358
+ this.lastAssistantTurn = candidateTurn;
2359
+ return readNativeMusic ? { text, nativeMusic: media } : { text, nativeImages: media };
2360
+ }
2361
+ await this.page.waitForTimeout(250);
2362
+ continue;
2363
+ }
2364
+ nativeCandidate = null;
2365
+ nativeStableSince = 0;
2366
+ }
2367
+
2134
2368
  if (!text.trim() && !generating) {
2135
2369
  // The model can create a real assistant turn and finish it without
2136
2370
  // rendering any content. Once that exact empty node remains stopped
@@ -2615,11 +2849,22 @@ export class BaseWebAdapter {
2615
2849
  outboundId,
2616
2850
  });
2617
2851
  } else {
2618
- const messages = this.userMessages();
2619
- const count = await withinDeadline(messages.count().catch(() => 0));
2852
+ const snapshot = await withinDeadline(this.userMessageSnapshot());
2853
+ if (snapshot === null) {
2854
+ candidateBubbleSignature = null;
2855
+ candidateBubbleStableSince = 0;
2856
+ await this.#waitBeforeSendDeadline(deadline);
2857
+ continue;
2858
+ }
2859
+ const messages = snapshot === undefined ? this.userMessages() : null;
2860
+ const count = snapshot?.length
2861
+ ?? await withinDeadline(messages.count().catch(() => 0));
2862
+ if (this.activeSendProof) {
2863
+ this.activeSendProof.sample = { userMessageCount: count, markerMatched: false };
2864
+ }
2620
2865
  for (let index = count - 1; index >= 0; index -= 1) {
2621
- const message = messages.nth(index);
2622
- const identity = await withinDeadline(
2866
+ const message = messages?.nth(index);
2867
+ const identity = snapshot?.[index] ?? await withinDeadline(
2623
2868
  this.messageIdentity(message),
2624
2869
  );
2625
2870
  const newByTurn = identity.turn != null
@@ -2637,12 +2882,23 @@ export class BaseWebAdapter {
2637
2882
  if (!requireFreshCorrelation && outboundId == null) {
2638
2883
  continue;
2639
2884
  }
2640
- const renderedText = await withinDeadline(
2885
+ const renderedText = snapshot?.[index].renderedText ?? await withinDeadline(
2641
2886
  this.userMessageText(message),
2642
2887
  );
2888
+ const markerMatched = renderedMessageContains(renderedText, expectedText, outboundId);
2889
+ if (this.activeSendProof) {
2890
+ this.activeSendProof.sample = {
2891
+ userMessageCount: count,
2892
+ candidateId: identity.id ?? null,
2893
+ candidateTurn: identity.turn ?? null,
2894
+ candidateIsLast: index === count - 1,
2895
+ renderedTextLength: renderedText.length,
2896
+ markerMatched,
2897
+ };
2898
+ }
2643
2899
  if (
2644
2900
  index !== count - 1
2645
- || !renderedMessageContains(renderedText, expectedText, outboundId)
2901
+ || !markerMatched
2646
2902
  ) {
2647
2903
  continue;
2648
2904
  }
@@ -3032,24 +3288,72 @@ export class BaseWebAdapter {
3032
3288
  throw new BrowserAdapterError("Browser access challenge detected.");
3033
3289
  }
3034
3290
 
3035
- async writeDiagnostics(label) {
3036
- if (!this.debug || !this.page) {
3037
- return;
3291
+ hasStructuralDiagnostics() {
3292
+ return false;
3293
+ }
3294
+
3295
+ recordConversationNavigation(_url) {}
3296
+
3297
+ async structuralDiagnostics() {
3298
+ return null;
3299
+ }
3300
+
3301
+ async collectStructuralDiagnostics() {
3302
+ if (!this.hasStructuralDiagnostics()) return null;
3303
+ let timer;
3304
+ try {
3305
+ return await Promise.race([
3306
+ Promise.resolve().then(() => this.structuralDiagnostics()).catch(() => ({ status: "unavailable" })),
3307
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ status: "timeout" }), 500); }),
3308
+ ]);
3309
+ } finally {
3310
+ clearTimeout(timer);
3038
3311
  }
3039
- const directory = path.join(this.profileDir, "..", "diagnostics");
3040
- await ensureDirectory(directory);
3312
+ }
3313
+
3314
+ async writeDiagnostics(label, details = null) {
3315
+ if (!this.page || (!this.debug && !this.hasStructuralDiagnostics())) return;
3316
+ const page = this.page;
3317
+ const proof = this.activeSendProof;
3041
3318
  const stamp = Date.now();
3042
- await Promise.all([
3043
- this.page.screenshot({
3044
- path: path.join(directory, `${stamp}-${label}.png`),
3045
- fullPage: true,
3046
- }).catch(() => null),
3047
- fs.writeFile(
3048
- path.join(directory, `${stamp}-${label}.html`),
3049
- await this.page.content(),
3050
- "utf8",
3051
- ).catch(() => null),
3052
- ]);
3319
+ // Capture the stage before awaiting CDP. Never log prompts, correlation IDs,
3320
+ // raw URLs, or arbitrary provider error text in the default JSON report.
3321
+ const report = {
3322
+ schemaVersion: 1,
3323
+ provider: this.providerName,
3324
+ label,
3325
+ capturedAt: new Date(stamp).toISOString(),
3326
+ stage: details?.stage ?? proof?.stage ?? "unknown",
3327
+ sendStatus: this.lastSendStatus,
3328
+ elapsedMs: proof?.submittedAt == null ? null : Math.round(this.monotonicNow() - proof.submittedAt),
3329
+ routes: [...(this.diagnosticRoutes ?? [])],
3330
+ };
3331
+ const directory = path.join(this.profileDir, "..", "diagnostics");
3332
+ const filename = path.join(directory, `${stamp}-${label}.json`);
3333
+ const capture = async () => {
3334
+ const dom = await this.collectStructuralDiagnostics();
3335
+ if (dom) report.dom = dom;
3336
+ if (this.debug && details != null) report.details = details;
3337
+ await ensureDirectory(directory);
3338
+ await fs.writeFile(filename, JSON.stringify(report, null, 2), "utf8");
3339
+ if (this.debug) {
3340
+ await Promise.all([
3341
+ page.screenshot({ path: path.join(directory, `${stamp}-${label}.png`), fullPage: true, timeout: 2_000 }).catch(() => null),
3342
+ page.content().then((html) => fs.writeFile(path.join(directory, `${stamp}-${label}.html`), html, "utf8")).catch(() => null),
3343
+ ]);
3344
+ }
3345
+ return { filename, report };
3346
+ };
3347
+ let timer;
3348
+ try {
3349
+ // A dead CDP connection or disk must not mask the original failure.
3350
+ return await Promise.race([
3351
+ capture().catch(() => null),
3352
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), 2_500); }),
3353
+ ]);
3354
+ } finally {
3355
+ clearTimeout(timer);
3356
+ }
3053
3357
  }
3054
3358
 
3055
3359
  // Non-private so provider subclasses (e.g. attachFiles) can guard too.