@steipete/oracle 0.18.0 → 0.20.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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +76 -76
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +170 -45
  13. package/dist/src/browser/actions/modelSelection.js +91 -4
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +364 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +156 -16
  19. package/dist/src/browser/actions/webSearch.js +96 -0
  20. package/dist/src/browser/attachRunning.js +46 -2
  21. package/dist/src/browser/attachmentValidation.js +53 -0
  22. package/dist/src/browser/cancellation.js +105 -0
  23. package/dist/src/browser/chatgptImages.js +2 -0
  24. package/dist/src/browser/chatgptThrottle.js +104 -0
  25. package/dist/src/browser/chromeLifecycle.js +272 -47
  26. package/dist/src/browser/config.js +14 -1
  27. package/dist/src/browser/detect.js +1 -1
  28. package/dist/src/browser/index.js +525 -274
  29. package/dist/src/browser/liveTabs.js +3 -4
  30. package/dist/src/browser/modelDisplay.js +9 -0
  31. package/dist/src/browser/pageActions.js +2 -2
  32. package/dist/src/browser/profileState.js +114 -9
  33. package/dist/src/browser/projectSourcesRunner.js +83 -15
  34. package/dist/src/browser/prompt.js +272 -113
  35. package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
  36. package/dist/src/browser/reattach.js +31 -4
  37. package/dist/src/browser/recoveryTarget.js +131 -0
  38. package/dist/src/browser/sessionRunner.js +73 -11
  39. package/dist/src/browser/tabLeaseRegistry.js +434 -38
  40. package/dist/src/browser/targetClaim.js +54 -0
  41. package/dist/src/browser/utils.js +20 -2
  42. package/dist/src/cli/browserConfig.js +36 -4
  43. package/dist/src/cli/browserDefaults.js +10 -0
  44. package/dist/src/cli/browserTabs.js +15 -29
  45. package/dist/src/cli/detachedSession.js +69 -0
  46. package/dist/src/cli/dryRun.js +58 -48
  47. package/dist/src/cli/harvestIntegrity.js +140 -0
  48. package/dist/src/cli/options.js +15 -0
  49. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  50. package/dist/src/cli/runOptions.js +19 -4
  51. package/dist/src/cli/serveBrowserConfig.js +24 -0
  52. package/dist/src/cli/sessionDisplay.js +13 -7
  53. package/dist/src/cli/sessionRunner.js +95 -12
  54. package/dist/src/config.js +1 -0
  55. package/dist/src/gemini-web/client.js +67 -21
  56. package/dist/src/gemini-web/executor.js +4 -0
  57. package/dist/src/gemini-web/http.js +15 -0
  58. package/dist/src/gemini-web/models.js +15 -11
  59. package/dist/src/mcp/server.js +11 -14
  60. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  61. package/dist/src/mcp/tools/consult.js +86 -24
  62. package/dist/src/mcp/tools/projectSources.js +4 -6
  63. package/dist/src/mcp/tools/sessionResources.js +1 -1
  64. package/dist/src/mcp/tools/sessions.js +2 -2
  65. package/dist/src/mcp/tools/wait.js +182 -0
  66. package/dist/src/mcp/types.js +8 -1
  67. package/dist/src/oracle/config.js +14 -0
  68. package/dist/src/oracle/errors.js +11 -0
  69. package/dist/src/oracle/geminiModels.js +1 -0
  70. package/dist/src/oracle/providerRoutePlan.js +3 -2
  71. package/dist/src/oracle/run.js +27 -4
  72. package/dist/src/remote/client.js +73 -6
  73. package/dist/src/remote/health.js +6 -2
  74. package/dist/src/remote/runSlots.js +83 -0
  75. package/dist/src/remote/server.js +346 -71
  76. package/dist/src/sessionManager.js +36 -1
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  78. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  79. package/package.json +25 -23
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/dist/bin/oracle.js +0 -683
  83. package/dist/docs-site/.nojekyll +0 -0
  84. package/dist/docs-site/CNAME +0 -1
  85. package/dist/docs-site/RELEASING.html +0 -410
  86. package/dist/docs-site/agents.html +0 -374
  87. package/dist/docs-site/anthropic.html +0 -368
  88. package/dist/docs-site/bridge.html +0 -400
  89. package/dist/docs-site/browser-mode.html +0 -588
  90. package/dist/docs-site/chromium-forks.html +0 -347
  91. package/dist/docs-site/cli-reference.html +0 -346
  92. package/dist/docs-site/configuration.html +0 -436
  93. package/dist/docs-site/favicon.svg +0 -14
  94. package/dist/docs-site/followup.html +0 -368
  95. package/dist/docs-site/gemini.html +0 -379
  96. package/dist/docs-site/grok.html +0 -325
  97. package/dist/docs-site/index.html +0 -360
  98. package/dist/docs-site/install.html +0 -335
  99. package/dist/docs-site/linux.html +0 -321
  100. package/dist/docs-site/llms.txt +0 -43
  101. package/dist/docs-site/manual-tests.html +0 -595
  102. package/dist/docs-site/mcp.html +0 -368
  103. package/dist/docs-site/multimodel.html +0 -364
  104. package/dist/docs-site/mythical-pro-agents.html +0 -360
  105. package/dist/docs-site/notifier.html +0 -338
  106. package/dist/docs-site/openai-endpoints.html +0 -387
  107. package/dist/docs-site/openrouter.html +0 -344
  108. package/dist/docs-site/quickstart.html +0 -369
  109. package/dist/docs-site/refactor/ux.html +0 -532
  110. package/dist/docs-site/sessions.html +0 -388
  111. package/dist/docs-site/social-card.png +0 -0
  112. package/dist/docs-site/social-card.svg +0 -79
  113. package/dist/docs-site/spec.html +0 -363
  114. package/dist/docs-site/testing.html +0 -320
  115. package/dist/docs-site/tui-debug.html +0 -326
  116. package/dist/docs-site/windows-work.html +0 -323
  117. package/dist/docs-site/windows.html +0 -320
  118. package/dist/markdansi/types/index.js +0 -4
  119. package/dist/oracle/bin/oracle-cli.js +0 -472
  120. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  121. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  122. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  123. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  124. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  125. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  126. package/dist/oracle/src/browser/config.js +0 -33
  127. package/dist/oracle/src/browser/constants.js +0 -40
  128. package/dist/oracle/src/browser/cookies.js +0 -210
  129. package/dist/oracle/src/browser/domDebug.js +0 -36
  130. package/dist/oracle/src/browser/index.js +0 -331
  131. package/dist/oracle/src/browser/pageActions.js +0 -5
  132. package/dist/oracle/src/browser/prompt.js +0 -88
  133. package/dist/oracle/src/browser/promptSummary.js +0 -20
  134. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  135. package/dist/oracle/src/browser/types.js +0 -1
  136. package/dist/oracle/src/browser/utils.js +0 -62
  137. package/dist/oracle/src/browserMode.js +0 -1
  138. package/dist/oracle/src/cli/browserConfig.js +0 -44
  139. package/dist/oracle/src/cli/dryRun.js +0 -59
  140. package/dist/oracle/src/cli/engine.js +0 -17
  141. package/dist/oracle/src/cli/errorUtils.js +0 -9
  142. package/dist/oracle/src/cli/help.js +0 -70
  143. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  144. package/dist/oracle/src/cli/options.js +0 -103
  145. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  146. package/dist/oracle/src/cli/rootAlias.js +0 -30
  147. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  148. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  149. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  150. package/dist/oracle/src/heartbeat.js +0 -43
  151. package/dist/oracle/src/oracle/client.js +0 -48
  152. package/dist/oracle/src/oracle/config.js +0 -29
  153. package/dist/oracle/src/oracle/errors.js +0 -101
  154. package/dist/oracle/src/oracle/files.js +0 -220
  155. package/dist/oracle/src/oracle/format.js +0 -33
  156. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  157. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  158. package/dist/oracle/src/oracle/request.js +0 -48
  159. package/dist/oracle/src/oracle/run.js +0 -444
  160. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  161. package/dist/oracle/src/oracle/types.js +0 -1
  162. package/dist/oracle/src/oracle.js +0 -9
  163. package/dist/oracle/src/sessionManager.js +0 -205
  164. package/dist/oracle/src/version.js +0 -39
  165. package/dist/scripts/chrome/browser-tools.js +0 -295
  166. package/dist/src/browser/chromeCookies.js +0 -312
  167. package/dist/src/browser/keytarShim.js +0 -56
  168. package/dist/src/browser/profileSync.js +0 -141
  169. package/dist/src/browser/windowsCookies.js +0 -219
@@ -1,9 +1,17 @@
1
+ import { withoutBrowserCancellation } from "../cancellation.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
2
4
  import { buildConversationTurnCountExpression, buildConversationTurnListExpression, } from "../conversationTurns.js";
3
5
  import { delay } from "../utils.js";
4
6
  import { logDomFailure } from "../domDebug.js";
7
+ import { assertComposerNavigationSnapshot, assertComposerPlusStayedInPlace, buildAttachmentNamePattern, composerNavigationIdentityFromUrl, } from "./attachments.js";
8
+ import { buildComposerNavigationValidationExpression } from "./attachmentContext.js";
5
9
  import { buildClickDispatcher } from "./domEvents.js";
10
+ import { stageAttachmentPrompt } from "./attachmentPrompt.js";
6
11
  import { BrowserAutomationError } from "../../oracle/errors.js";
12
+ import { buildAttachmentEvidenceExpression } from "./attachmentEvidence.js";
13
+ import { buildAttachmentProgressExpression } from "./attachmentProgress.js";
14
+ import { activateWebSearch } from "./webSearch.js";
7
15
  const ENTER_KEY_EVENT = {
8
16
  key: "Enter",
9
17
  code: "Enter",
@@ -11,12 +19,33 @@ const ENTER_KEY_EVENT = {
11
19
  nativeVirtualKeyCode: 13,
12
20
  };
13
21
  const ENTER_KEY_TEXT = "\r";
22
+ const ESCAPE_KEY_EVENT = {
23
+ key: "Escape",
24
+ code: "Escape",
25
+ windowsVirtualKeyCode: 27,
26
+ nativeVirtualKeyCode: 27,
27
+ };
14
28
  export async function submitPrompt(deps, prompt, logger) {
15
29
  const { runtime, input } = deps;
30
+ const hasAttachments = Array.isArray(deps.attachmentNames) && deps.attachmentNames.length > 0;
31
+ if (hasAttachments && !deps.attachmentNavigationUrl) {
32
+ throw new BrowserAutomationError("Oracle cannot safely submit attachments without the pre-upload ChatGPT page identity.", {
33
+ stage: "submit-prompt",
34
+ code: "attachment-navigation-identity-unavailable",
35
+ });
36
+ }
37
+ if (hasAttachments) {
38
+ await assertComposerPlusStayedInPlace(runtime, deps.attachmentNavigationUrl);
39
+ }
16
40
  await waitForDomReady(runtime, logger, deps.inputTimeoutMs ?? undefined);
17
- const encodedPrompt = JSON.stringify(prompt);
18
- const focusResult = await runtime.evaluate({
19
- expression: `(() => {
41
+ let observedLength;
42
+ if (hasAttachments) {
43
+ observedLength = await stageAttachmentPrompt(runtime, prompt, deps.attachmentNavigationUrl);
44
+ }
45
+ else {
46
+ const encodedPrompt = JSON.stringify(prompt);
47
+ const focusResult = await runtime.evaluate({
48
+ expression: `(() => {
20
49
  ${buildClickDispatcher()}
21
50
  const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
22
51
  const isVisible = (node) => {
@@ -60,21 +89,21 @@ export async function submitPrompt(deps, prompt, logger) {
60
89
  }
61
90
  return { focused: false };
62
91
  })()`,
63
- returnByValue: true,
64
- awaitPromise: true,
65
- });
66
- if (!focusResult.result?.value?.focused) {
67
- await logDomFailure(runtime, logger, "focus-textarea");
68
- throw new Error("Failed to focus prompt textarea");
69
- }
70
- await input.insertText({ text: prompt });
71
- // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
72
- // before the send button becomes enabled; give it a short breather to avoid races.
73
- await delay(500);
74
- const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
75
- const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
76
- const verification = await runtime.evaluate({
77
- expression: `(() => {
92
+ returnByValue: true,
93
+ awaitPromise: true,
94
+ });
95
+ if (!focusResult.result?.value?.focused) {
96
+ await logDomFailure(runtime, logger, "focus-textarea");
97
+ throw new Error("Failed to focus prompt textarea");
98
+ }
99
+ await input.insertText({ text: prompt });
100
+ // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
101
+ // before the send button becomes enabled; give it a short breather to avoid races.
102
+ await delay(500);
103
+ const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
104
+ const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
105
+ const verification = await runtime.evaluate({
106
+ expression: `(() => {
78
107
  const editor = document.querySelector(${primarySelectorLiteral});
79
108
  const fallback = document.querySelector(${fallbackSelectorLiteral});
80
109
  const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
@@ -98,18 +127,18 @@ export async function submitPrompt(deps, prompt, logger) {
98
127
  activeValue: active ? readValue(active) : '',
99
128
  };
100
129
  })()`,
101
- returnByValue: true,
102
- });
103
- const editorTextRaw = verification.result?.value?.editorText ?? "";
104
- const fallbackValueRaw = verification.result?.value?.fallbackValue ?? "";
105
- const activeValueRaw = verification.result?.value?.activeValue ?? "";
106
- const editorTextTrimmed = editorTextRaw?.trim?.() ?? "";
107
- const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? "";
108
- const activeValueTrimmed = activeValueRaw?.trim?.() ?? "";
109
- if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
110
- // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
111
- await runtime.evaluate({
112
- expression: `(() => {
130
+ returnByValue: true,
131
+ });
132
+ const editorTextRaw = verification.result?.value?.editorText ?? "";
133
+ const fallbackValueRaw = verification.result?.value?.fallbackValue ?? "";
134
+ const activeValueRaw = verification.result?.value?.activeValue ?? "";
135
+ const editorTextTrimmed = editorTextRaw?.trim?.() ?? "";
136
+ const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? "";
137
+ const activeValueTrimmed = activeValueRaw?.trim?.() ?? "";
138
+ if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
139
+ // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
140
+ await runtime.evaluate({
141
+ expression: `(() => {
113
142
  const fallback = document.querySelector(${fallbackSelectorLiteral});
114
143
  if (fallback) {
115
144
  fallback.value = ${encodedPrompt};
@@ -123,11 +152,10 @@ export async function submitPrompt(deps, prompt, logger) {
123
152
  editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
124
153
  }
125
154
  })()`,
126
- });
127
- }
128
- const promptLength = prompt.length;
129
- const postVerification = await runtime.evaluate({
130
- expression: `(() => {
155
+ });
156
+ }
157
+ const postVerification = await runtime.evaluate({
158
+ expression: `(() => {
131
159
  const editor = document.querySelector(${primarySelectorLiteral});
132
160
  const fallback = document.querySelector(${fallbackSelectorLiteral});
133
161
  const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
@@ -151,12 +179,14 @@ export async function submitPrompt(deps, prompt, logger) {
151
179
  activeValue: active ? readValue(active) : '',
152
180
  };
153
181
  })()`,
154
- returnByValue: true,
155
- });
156
- const observedEditor = postVerification.result?.value?.editorText ?? "";
157
- const observedFallback = postVerification.result?.value?.fallbackValue ?? "";
158
- const observedActive = postVerification.result?.value?.activeValue ?? "";
159
- const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
182
+ returnByValue: true,
183
+ });
184
+ const observedEditor = postVerification.result?.value?.editorText ?? "";
185
+ const observedFallback = postVerification.result?.value?.fallbackValue ?? "";
186
+ const observedActive = postVerification.result?.value?.activeValue ?? "";
187
+ observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
188
+ }
189
+ const promptLength = prompt.length;
160
190
  if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
161
191
  // Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
162
192
  await logDomFailure(runtime, logger, "prompt-too-large");
@@ -167,28 +197,33 @@ export async function submitPrompt(deps, prompt, logger) {
167
197
  observedLength,
168
198
  });
169
199
  }
170
- const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
200
+ if (deps.webSearch)
201
+ await activateWebSearch(runtime, input, prompt, logger);
202
+ const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs, deps?.page, deps?.attachmentNavigationUrl);
171
203
  if (!clicked) {
172
- await input.dispatchKeyEvent({
173
- type: "keyDown",
174
- ...ENTER_KEY_EVENT,
175
- text: ENTER_KEY_TEXT,
176
- unmodifiedText: ENTER_KEY_TEXT,
177
- });
178
- await input.dispatchKeyEvent({
179
- type: "keyUp",
180
- ...ENTER_KEY_EVENT,
181
- });
204
+ await dispatchEnterKey(input);
182
205
  logger("Submitted prompt via Enter key");
183
206
  }
184
207
  else {
185
- logger("Clicked send button");
208
+ logger("Activated send button");
186
209
  }
187
210
  await deps.onPromptSubmitted?.();
188
211
  const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
189
212
  // Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
190
213
  return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
191
214
  }
215
+ async function dispatchEnterKey(Input) {
216
+ await Input.dispatchKeyEvent({
217
+ type: "keyDown",
218
+ ...ENTER_KEY_EVENT,
219
+ text: ENTER_KEY_TEXT,
220
+ unmodifiedText: ENTER_KEY_TEXT,
221
+ });
222
+ await Input.dispatchKeyEvent({
223
+ type: "keyUp",
224
+ ...ENTER_KEY_EVENT,
225
+ });
226
+ }
192
227
  export async function clearPromptComposer(Runtime, logger) {
193
228
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
194
229
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
@@ -287,11 +322,12 @@ function buildAttachmentReadyExpression(attachmentNames) {
287
322
  const attachmentExpectations = attachmentNames.map((attachment) => {
288
323
  const name = typeof attachment === "string" ? attachment : attachment.name;
289
324
  const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
325
+ const generatedBundle = typeof attachment === "object" && attachment.generatedBundle === true;
290
326
  return {
291
327
  name: normalized,
292
328
  stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
293
- extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
294
- generatedBundle: typeof attachment === "object" && attachment.generatedBundle === true,
329
+ namePattern: buildAttachmentNamePattern(normalized, generatedBundle)?.source ?? "",
330
+ generatedBundle,
295
331
  };
296
332
  });
297
333
  const namesLiteral = JSON.stringify(attachmentExpectations);
@@ -299,74 +335,13 @@ function buildAttachmentReadyExpression(attachmentNames) {
299
335
  const expected = ${namesLiteral};
300
336
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
301
337
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
302
- const hasNameBoundary = (text, name) => {
303
- if (!name) return false;
304
- let from = 0;
305
- while (from < text.length) {
306
- const index = text.indexOf(name, from);
307
- if (index === -1) return false;
308
- const previous = text[index - 1] || '';
309
- const next = text[index + name.length] || '';
310
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
311
- const nextOk = !next || !/[a-z0-9._-]/.test(next);
312
- if (previousOk && nextOk) return true;
313
- from = index + name.length;
314
- }
315
- return false;
316
- };
317
- const hasStemFileBoundary = (text, stem) => {
318
- if (!stem) return false;
319
- let from = 0;
320
- while (from < text.length) {
321
- const index = text.indexOf(stem, from);
322
- if (index === -1) return false;
323
- const previous = text[index - 1] || '';
324
- const next = text[index + stem.length] || '';
325
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
326
- const nextOk = !next || !/[a-z0-9._-]/.test(next);
327
- if (previousOk && nextOk) return true;
328
- from = index + stem.length;
329
- }
330
- return false;
331
- };
332
- const hasBareStemBoundary = (text, stem) => {
333
- if (!stem) return false;
334
- let from = 0;
335
- while (from < text.length) {
336
- const index = text.indexOf(stem, from);
337
- if (index === -1) return false;
338
- const previous = text[index - 1] || '';
339
- const next = text[index + stem.length] || '';
340
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
341
- const nextOk = !next || !/[a-z0-9._(-]/.test(next);
342
- if (previousOk && nextOk) return true;
343
- from = index + stem.length;
344
- }
345
- return false;
346
- };
347
- const hasExtensionBoundary = (text, extension) => {
348
- if (!extension) return false;
349
- let from = 0;
350
- while (from < text.length) {
351
- const index = text.indexOf(extension, from);
352
- if (index === -1) return false;
353
- const next = text[index + extension.length] || '';
354
- if (!next || !/[a-z0-9]/.test(next)) return true;
355
- from = index + extension.length;
356
- }
357
- return false;
358
- };
359
338
  const matchesExpected = (value, item) => {
360
339
  const text = normalize(value);
361
340
  if (!text) return false;
362
- if (hasNameBoundary(text, item.name)) return true;
363
- if (item.generatedBundle && hasBareStemBoundary(text, item.stem)) return true;
341
+ const namePattern = item.namePattern ? new RegExp(item.namePattern, 'iu') : null;
364
342
  if (
365
- item.stem &&
366
- item.stem.length >= 4 &&
367
- item.extension &&
368
- text.includes(item.stem + '(') &&
369
- hasExtensionBoundary(text, item.extension)
343
+ namePattern &&
344
+ String(value || '').split('\\n').some((candidate) => namePattern.test(normalize(candidate)))
370
345
  ) {
371
346
  return true;
372
347
  }
@@ -465,7 +440,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
465
440
  };
466
441
  pushAttrs(node);
467
442
  pushText(node);
468
- return pieces.join(' ').toLowerCase();
443
+ return pieces.join('\\n').toLowerCase();
469
444
  };
470
445
  const collectLabelHaystack = (node) => {
471
446
  if (!node) return '';
@@ -478,7 +453,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
478
453
  push(parent);
479
454
  const grandparent = parent?.parentElement;
480
455
  push(grandparent);
481
- return pieces.join(' ').toLowerCase();
456
+ return pieces.join('\\n').toLowerCase();
482
457
  };
483
458
  const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
484
459
  const collectChipNodes = () => {
@@ -499,32 +474,11 @@ function buildAttachmentReadyExpression(attachmentNames) {
499
474
  };
500
475
  const chipNodes = collectChipNodes();
501
476
  const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
502
- const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
503
- const hasEllipsisSuffix = (label) => {
504
- const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
505
- if (!marker) return false;
506
- return normalize(label.split(marker)[1] || '').length > 0;
507
- };
508
- const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
509
- /\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
510
- hasEllipsisSuffix(label),
511
- );
512
- const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
513
- expected.some((item) => matchesExpected(label, item)),
514
- );
515
- const visibleStemOnlyMismatch = chipOwnLabels.some((label) =>
516
- expected.some(
517
- (item) =>
518
- !item.generatedBundle &&
519
- item.stem &&
520
- hasStemFileBoundary(label, item.stem) &&
521
- !matchesExpected(label, item),
522
- ),
523
- );
524
-
477
+ const uploadEvidence = ${buildAttachmentEvidenceExpression(attachmentExpectations.map((item) => item.name))};
525
478
  const chipsReady = (() => {
526
479
  const used = new Set();
527
- return expected.every((item) => {
480
+ return expected.every((item, expectedIndex) => {
481
+ if (uploadEvidence[expectedIndex]) return true;
528
482
  const index = chipLabels.findIndex((label, candidateIndex) =>
529
483
  !used.has(candidateIndex) && matchesExpected(label, item),
530
484
  );
@@ -542,42 +496,20 @@ function buildAttachmentReadyExpression(attachmentNames) {
542
496
  ),
543
497
  ),
544
498
  );
545
- // Count-based fallback: if we cannot match names individually (ChatGPT may strip
546
- // the filename out of attribute-readable text into a deeply nested span), but we
547
- // do see at least as many distinct "Remove" affordances as attachments we
548
- // uploaded, trust the upload without double-counting nested chip/remove nodes.
549
- const removeAffordances = [];
550
- const removeSeen = new Set();
551
- for (const root of attachmentRoots) {
552
- for (const node of Array.from(root.querySelectorAll(
553
- '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
554
- ))) {
555
- if (!(node instanceof HTMLElement)) continue;
556
- if (node.closest('textarea,[contenteditable="true"]')) continue;
557
- const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
558
- const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
559
- const attachmentOwner = node.closest(
560
- '[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
561
- );
562
- if (!fileSpecific && !attachmentOwner) continue;
563
- if (removeSeen.has(node)) continue;
564
- removeSeen.add(node);
565
- removeAffordances.push(node);
566
- }
567
- }
568
- const countReady =
569
- !visibleStemOnlyMismatch &&
570
- visibleExtensionLabelsMatchExpected &&
571
- removeAffordances.length >= expected.length;
572
-
573
- return chipsReady || inputsReady || countReady;
499
+ return (chipsReady || inputsReady) && !${buildAttachmentProgressExpression("composer")};
574
500
  })()`;
575
501
  }
576
502
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
577
503
  return buildAttachmentReadyExpression(attachmentNames);
578
504
  }
579
- async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attachmentTimeoutMs) {
505
+ async function attemptSendButton(Runtime, Input, logger, attachmentNames, attachmentTimeoutMs, Page, attachmentNavigationUrl) {
580
506
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
507
+ if (needAttachment && !attachmentNavigationUrl) {
508
+ throw new BrowserAutomationError("Oracle cannot safely submit attachments without the pre-upload ChatGPT page identity.", {
509
+ stage: "submit-prompt",
510
+ code: "attachment-navigation-identity-unavailable",
511
+ });
512
+ }
581
513
  const script = `(() => {
582
514
  ${buildClickDispatcher()}
583
515
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -606,10 +538,19 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
606
538
  }
607
539
  const button = candidates.find((node) => isVisible(node) && isEnabled(node)) || null;
608
540
  if (!button) return { status: 'missing' };
609
- button.scrollIntoView({ block: 'center', inline: 'center' });
610
541
  const rect = button.getBoundingClientRect();
611
542
  if (rect.width > 0 && rect.height > 0) {
612
- return { status: 'point', x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
543
+ const x = rect.left + rect.width / 2;
544
+ const y = rect.top + rect.height / 2;
545
+ // Scrolling an already-visible composer can trigger ChatGPT's layout snap.
546
+ // If scrolling is necessary, discard these coordinates and measure later.
547
+ if (x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight) {
548
+ button.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
549
+ return { status: 'settling' };
550
+ }
551
+ const hit = document.elementFromPoint(x, y);
552
+ if (!hit || !button.contains(hit)) return { status: 'settling' };
553
+ return { status: 'point', x, y };
613
554
  }
614
555
  // Last-resort fallback for unusual DOMs where the button is visible but has no useful rect.
615
556
  dispatchClickSequence(button);
@@ -620,6 +561,10 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
620
561
  // shorter historical deadline.
621
562
  const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
622
563
  const deadline = Date.now() + timeoutMs;
564
+ let previousPoint;
565
+ let activated = false;
566
+ let foundButton = false;
567
+ let attachmentMenuChecked = !needAttachment;
623
568
  while (Date.now() < deadline) {
624
569
  if (needAttachment) {
625
570
  const ready = await Runtime.evaluate({
@@ -631,13 +576,45 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
631
576
  continue;
632
577
  }
633
578
  }
579
+ if (!attachmentMenuChecked) {
580
+ await activatePageForTrustedInput(Page, logger);
581
+ activated = true;
582
+ await dismissOpenComposerPlusMenu(Runtime, Input, logger);
583
+ attachmentMenuChecked = true;
584
+ previousPoint = undefined;
585
+ await delay(150);
586
+ continue;
587
+ }
588
+ if (needAttachment) {
589
+ if (await activateExactAttachmentSendButton(Runtime, Input, logger, attachmentNavigationUrl, attachmentNames)) {
590
+ return true;
591
+ }
592
+ previousPoint = undefined;
593
+ await delay(100);
594
+ continue;
595
+ }
596
+ // Activating the target can trigger a final compositor/layout pass. Do it before
597
+ // measuring the button so the trusted click never uses stale coordinates.
598
+ if (!activated) {
599
+ await activatePageForTrustedInput(Page, logger);
600
+ activated = true;
601
+ }
634
602
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
635
603
  const value = result.value;
636
604
  const status = typeof value === "string" ? value : value?.status;
605
+ if (status === "point" || status === "settling")
606
+ foundButton = true;
637
607
  if (status === "point" &&
638
608
  typeof value === "object" &&
639
609
  typeof value.x === "number" &&
640
610
  typeof value.y === "number") {
611
+ if (!previousPoint ||
612
+ Math.abs(previousPoint.x - value.x) > 1 ||
613
+ Math.abs(previousPoint.y - value.y) > 1) {
614
+ previousPoint = { x: value.x, y: value.y };
615
+ await delay(150);
616
+ continue;
617
+ }
641
618
  await clickTrustedPoint(Runtime, Input, value.x, value.y);
642
619
  return true;
643
620
  }
@@ -647,18 +624,223 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
647
624
  if (status === "missing") {
648
625
  break;
649
626
  }
627
+ previousPoint = undefined;
650
628
  await delay(100);
651
629
  }
652
630
  if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
653
- throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
631
+ throw new BrowserAutomationError(`Attachments never reached the exact ready send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
654
632
  stage: "submit-prompt",
655
633
  code: "attachment-send-not-ready",
656
634
  attachmentNames,
657
635
  timeoutMs,
658
636
  });
659
637
  }
638
+ if (foundButton) {
639
+ throw new BrowserAutomationError("Send button did not settle before input timeout.", {
640
+ stage: "submit-prompt",
641
+ code: "send-button-unstable",
642
+ });
643
+ }
660
644
  return false;
661
645
  }
646
+ async function activateExactAttachmentSendButton(Runtime, Input, logger, attachmentNavigationUrl, attachmentNames = []) {
647
+ const probe = await Runtime.evaluate({
648
+ expression: `(() => {
649
+ const button = document.querySelector('button[data-testid="send-button"]');
650
+ if (!(button instanceof HTMLElement)) return { status: 'absent' };
651
+ const rect = button.getBoundingClientRect();
652
+ const style = window.getComputedStyle(button);
653
+ const enabled = !button.hasAttribute('disabled') &&
654
+ button.getAttribute('aria-disabled') !== 'true' &&
655
+ button.getAttribute('data-disabled') !== 'true' &&
656
+ style.pointerEvents !== 'none' &&
657
+ style.display !== 'none' &&
658
+ style.visibility !== 'hidden';
659
+ if (rect.width <= 0 || rect.height <= 0 || !enabled) return { status: 'unavailable' };
660
+ button.focus({ preventScroll: true });
661
+ return { status: document.activeElement === button ? 'focused' : 'unavailable' };
662
+ })()`,
663
+ returnByValue: true,
664
+ });
665
+ const status = probe?.result?.value?.status;
666
+ if (status !== "focused") {
667
+ return false;
668
+ }
669
+ if (!Input || typeof Input.dispatchKeyEvent !== "function") {
670
+ throw new BrowserAutomationError("ChatGPT's attachment send button is ready but Oracle cannot activate it safely.", {
671
+ stage: "submit-prompt",
672
+ code: "attachment-send-keyboard-unavailable",
673
+ });
674
+ }
675
+ const expectedIdentity = composerNavigationIdentityFromUrl(attachmentNavigationUrl ?? "");
676
+ if (!expectedIdentity || !attachmentNavigationUrl) {
677
+ throw new BrowserAutomationError("Attachment page identity is unavailable before dispatch.", {
678
+ stage: "submit-prompt",
679
+ code: "attachment-navigation-identity-unavailable",
680
+ });
681
+ }
682
+ const guardId = randomUUID();
683
+ // Recheck at event delivery too: a SPA can navigate after the CDP probe returns.
684
+ let delivery;
685
+ try {
686
+ const boundary = await Runtime.evaluate({
687
+ expression: `(() => {
688
+ const button = document.querySelector('button[data-testid="send-button"]');
689
+ const check = () => {
690
+ const navigation = ${buildComposerNavigationValidationExpression(attachmentNavigationUrl)};
691
+ const rect = button?.getBoundingClientRect();
692
+ const style = button ? window.getComputedStyle(button) : null;
693
+ return {
694
+ ...navigation,
695
+ focused: button instanceof HTMLElement && document.activeElement === button &&
696
+ document.querySelector('button[data-testid="send-button"]') === button &&
697
+ !button.hasAttribute('disabled') && button.getAttribute('aria-disabled') !== 'true' &&
698
+ button.getAttribute('data-disabled') !== 'true' && rect.width > 0 && rect.height > 0 &&
699
+ style.display !== 'none' && style.visibility !== 'hidden' && style.pointerEvents !== 'none',
700
+ attachmentsReady: ${buildAttachmentReadyExpression(attachmentNames)},
701
+ };
702
+ };
703
+ const safeCheck = () => {
704
+ try { return check(); } catch { return { currentUrl: location.href, contextMatches: false, focused: false, attachmentsReady: false }; }
705
+ };
706
+ const snapshot = safeCheck();
707
+ if (!snapshot.contextMatches || !snapshot.focused || !snapshot.attachmentsReady) return snapshot;
708
+ const existing = window.__oracleAttachmentDispatchGuard;
709
+ if (existing && !existing.finished) return { ...snapshot, focused: false };
710
+ existing?.cleanup?.();
711
+ const guard = { id: ${JSON.stringify(guardId)}, sawKeyDown: false, blocked: null, finished: false };
712
+ const detach = () => {
713
+ window.removeEventListener('keydown', onKeyDown, true);
714
+ window.removeEventListener('keyup', onKeyUp, true);
715
+ window.removeEventListener('click', onClick, true);
716
+ guard.finished = true;
717
+ };
718
+ const cancel = (event, state) => {
719
+ event.preventDefault(); event.stopImmediatePropagation(); guard.blocked = state;
720
+ };
721
+ const onKeyDown = event => {
722
+ if (event.key !== 'Enter' || !event.isTrusted) return;
723
+ guard.sawKeyDown = true;
724
+ const state = safeCheck();
725
+ if (!state.contextMatches || !state.focused || !state.attachmentsReady) cancel(event, state);
726
+ };
727
+ const onKeyUp = event => {
728
+ if (event.key === 'Enter' && event.isTrusted && guard.blocked) { cancel(event, guard.blocked); detach(); }
729
+ };
730
+ const onClick = event => {
731
+ if (!guard.sawKeyDown || !(event.target instanceof Node) ||
732
+ !(button.contains(event.target) || event.target instanceof Element && event.target.closest('button[data-testid="send-button"]'))) return;
733
+ const state = safeCheck();
734
+ if (guard.blocked || !state.contextMatches || !state.focused || !state.attachmentsReady) cancel(event, guard.blocked ?? state);
735
+ detach();
736
+ };
737
+ guard.cleanup = () => { detach(); if (window.__oracleAttachmentDispatchGuard === guard) delete window.__oracleAttachmentDispatchGuard; };
738
+ window.__oracleAttachmentDispatchGuard = guard;
739
+ window.addEventListener('keydown', onKeyDown, true);
740
+ window.addEventListener('keyup', onKeyUp, true);
741
+ window.addEventListener('click', onClick, true);
742
+ return snapshot;
743
+ })()`,
744
+ returnByValue: true,
745
+ });
746
+ const snapshot = boundary?.result?.value;
747
+ assertComposerNavigationSnapshot(attachmentNavigationUrl, snapshot);
748
+ if (snapshot?.focused !== true || snapshot.attachmentsReady !== true)
749
+ return false;
750
+ await Input.dispatchKeyEvent({
751
+ type: "keyDown",
752
+ ...ENTER_KEY_EVENT,
753
+ text: ENTER_KEY_TEXT,
754
+ unmodifiedText: ENTER_KEY_TEXT,
755
+ });
756
+ await Input.dispatchKeyEvent({ type: "keyUp", ...ENTER_KEY_EVENT });
757
+ }
758
+ finally {
759
+ const result = await withoutBrowserCancellation(() => Runtime.evaluate({
760
+ expression: `(() => {
761
+ const guard = window.__oracleAttachmentDispatchGuard;
762
+ if (guard?.id !== ${JSON.stringify(guardId)}) return null;
763
+ const summary = { sawKeyDown: guard.sawKeyDown, blocked: guard.blocked };
764
+ guard.cleanup(); return summary;
765
+ })()`,
766
+ returnByValue: true,
767
+ }).catch(() => undefined));
768
+ delivery = result?.result?.value;
769
+ }
770
+ if (delivery?.blocked) {
771
+ assertComposerNavigationSnapshot(attachmentNavigationUrl, delivery.blocked);
772
+ throw new BrowserAutomationError("Attachment focus or upload readiness changed at dispatch; the send was stopped.", {
773
+ stage: "submit-prompt",
774
+ code: "attachment-send-not-ready",
775
+ });
776
+ }
777
+ if (delivery?.sawKeyDown !== true) {
778
+ throw new BrowserAutomationError("Attachment input delivery could not be verified; do not retry automatically.", {
779
+ stage: "submit-prompt",
780
+ code: "attachment-send-ambiguous",
781
+ });
782
+ }
783
+ logger?.("Activated exact attachment send button via keyboard");
784
+ return true;
785
+ }
786
+ async function dismissOpenComposerPlusMenu(Runtime, Input, logger) {
787
+ const probe = await Runtime.evaluate({
788
+ expression: `(() => {
789
+ const selectors = ['#composer-plus-btn', 'button[data-testid="composer-plus-btn"]'];
790
+ const button = selectors
791
+ .map(selector => document.querySelector(selector))
792
+ .find(node => node instanceof HTMLElement && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
793
+ if (!(button instanceof HTMLElement)) return { status: 'absent' };
794
+ if (button.getAttribute('aria-expanded') !== 'true') return { status: 'closed' };
795
+ button.focus({ preventScroll: true });
796
+ return { status: 'open', focused: document.activeElement === button };
797
+ })()`,
798
+ returnByValue: true,
799
+ });
800
+ const status = probe?.result?.value?.status;
801
+ if (status !== "open") {
802
+ return false;
803
+ }
804
+ if (!Input || typeof Input.dispatchKeyEvent !== "function") {
805
+ throw new BrowserAutomationError("ChatGPT's attachment menu is still open and Oracle cannot dismiss it safely before sending.", {
806
+ stage: "submit-prompt",
807
+ code: "attachment-menu-dismiss-unavailable",
808
+ });
809
+ }
810
+ await Input.dispatchKeyEvent({ type: "keyDown", ...ESCAPE_KEY_EVENT });
811
+ await Input.dispatchKeyEvent({ type: "keyUp", ...ESCAPE_KEY_EVENT });
812
+ const deadline = Date.now() + 2_000;
813
+ while (Date.now() < deadline) {
814
+ const state = await Runtime.evaluate({
815
+ expression: `(() => {
816
+ const selectors = ['#composer-plus-btn', 'button[data-testid="composer-plus-btn"]'];
817
+ return !selectors.some(selector =>
818
+ document.querySelector(selector)?.getAttribute?.('aria-expanded') === 'true'
819
+ );
820
+ })()`,
821
+ returnByValue: true,
822
+ });
823
+ if (state?.result?.value === true) {
824
+ logger?.("Closed attachment menu before send");
825
+ return true;
826
+ }
827
+ await delay(50);
828
+ }
829
+ throw new BrowserAutomationError("ChatGPT's attachment menu did not close before send; prompt was left staged.", {
830
+ stage: "submit-prompt",
831
+ code: "attachment-menu-stuck-open",
832
+ });
833
+ }
834
+ async function activatePageForTrustedInput(Page, logger) {
835
+ if (!Page || typeof Page.bringToFront !== "function") {
836
+ return;
837
+ }
838
+ await Page.bringToFront().catch((error) => {
839
+ const message = error instanceof Error ? error.message : String(error);
840
+ logger?.(`[browser] Could not bring ChatGPT target to front before trusted click: ${message}`);
841
+ });
842
+ await delay(150);
843
+ }
662
844
  async function clickTrustedPoint(Runtime, Input, x, y) {
663
845
  if (Input && typeof Input.dispatchMouseEvent === "function") {
664
846
  await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
@@ -814,14 +996,8 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
814
996
  logger(`Prompt commit check failed; latest state: ${probe ? JSON.stringify(probe) : "unavailable"}`);
815
997
  await logDomFailure(Runtime, logger, "prompt-commit");
816
998
  }
817
- if (prompt.trim().length >= 50_000) {
818
- throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (likely too large).", {
819
- stage: "submit-prompt",
820
- code: "prompt-too-large",
821
- promptLength: prompt.trim().length,
822
- timeoutMs,
823
- });
824
- }
999
+ // Input was already dispatched. Even a large staged prompt may commit late;
1000
+ // classifying this as truncation would let the runner submit an upload fallback.
825
1001
  throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (send may have failed)", {
826
1002
  stage: "submit-prompt",
827
1003
  code: "prompt-commit-timeout",
@@ -849,7 +1025,9 @@ function summarizeCommitProbe(probe) {
849
1025
  }
850
1026
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
851
1027
  export const __test__ = {
1028
+ activateExactAttachmentSendButton,
852
1029
  attemptSendButton,
1030
+ dismissOpenComposerPlusMenu,
853
1031
  sendButtonTimeoutMs,
854
1032
  verifyPromptCommitted,
855
1033
  };