@steipete/oracle 0.12.0 → 0.13.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.
@@ -149,7 +149,7 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
149
149
  });
150
150
  const probe = normalizeLoginProbe(outcome.result?.value);
151
151
  if (probe.ok) {
152
- logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)})`);
152
+ logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
153
153
  return;
154
154
  }
155
155
  const accepted = await attemptWelcomeBackLogin(Runtime, logger);
@@ -167,9 +167,9 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
167
167
  logger("Login restored via Welcome back account picker");
168
168
  return;
169
169
  }
170
- logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)})`);
170
+ logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
171
171
  }
172
- logger(`Login probe failed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
172
+ logger(`Login probe failed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
173
173
  const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
174
174
  const cookieHint = options.remoteSession
175
175
  ? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
@@ -418,51 +418,111 @@ function buildLoginProbeExpression(timeoutMs) {
418
418
  return false;
419
419
  };
420
420
 
421
- const readBackendStatus = async () => {
421
+ // Learned 2026-05-16: ChatGPT's /backend-api/* endpoints now sit behind Cloudflare bot
422
+ // mitigation. Programmatic fetch from the page can return 403 with cf-mitigated:challenge
423
+ // even when the user is logged in via cookies and the SPA renders normally. Detect that
424
+ // case via the response body shape (Cloudflare interstitial HTML) and fall back to a
425
+ // DOM-based logged-in signal instead of looping waitForLogin until the 20-min timeout.
426
+ const isCloudflareBody = (body) => {
427
+ if (typeof body !== 'string' || body.length === 0) return false;
428
+ const head = body.slice(0, 2000).toLowerCase();
429
+ return (
430
+ head.includes('cf-mitigated') ||
431
+ head.includes('cloudflare') ||
432
+ (head.includes('<style global>') && head.includes('scale-appear'))
433
+ );
434
+ };
435
+ const readBackendDetail = async () => {
422
436
  try {
423
- if (typeof fetch === 'function') {
424
- const controller = new AbortController();
425
- const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
426
- try {
427
- // Credentials included so we see a 200 only when cookies are valid.
428
- const response = await fetch('/backend-api/me', {
429
- cache: 'no-store',
430
- credentials: 'include',
431
- signal: controller.signal,
432
- });
433
- return { status: response.status || 0, error: null };
434
- } finally {
435
- clearTimeout(timeout);
437
+ if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
438
+ const controller = new AbortController();
439
+ const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
440
+ try {
441
+ const response = await fetch('/backend-api/me', {
442
+ cache: 'no-store',
443
+ credentials: 'include',
444
+ signal: controller.signal,
445
+ });
446
+ let cfBlocked = false;
447
+ if (response.status === 403 || response.status === 503 || response.status === 429) {
448
+ try {
449
+ const text = await response.clone().text();
450
+ cfBlocked = isCloudflareBody(text);
451
+ } catch {}
436
452
  }
453
+ return { status: response.status || 0, cfBlocked, error: null };
454
+ } finally {
455
+ clearTimeout(timeout);
437
456
  }
438
457
  } catch (err) {
439
- return { status: 0, error: err ? String(err) : 'unknown' };
458
+ return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
440
459
  }
441
- return { status: 0, error: null };
442
460
  };
443
461
 
444
- let { status, error } = await readBackendStatus();
462
+ const hasAppAuthSignal = () => {
463
+ // Composer must be present and visible — the auth/login page never renders one.
464
+ if (typeof document.querySelector !== 'function') return false;
465
+ const composerSelectors = [
466
+ '#prompt-textarea',
467
+ '.ProseMirror',
468
+ 'textarea[data-id="prompt-textarea"]',
469
+ 'textarea[name="prompt-textarea"]',
470
+ '[contenteditable="true"][role="textbox"]',
471
+ ];
472
+ const composer = composerSelectors.map((s) => document.querySelector(s)).find(Boolean);
473
+ if (!composer) return false;
474
+ const rect = composer.getBoundingClientRect && composer.getBoundingClientRect();
475
+ const style = window.getComputedStyle(composer);
476
+ if (!rect || rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') {
477
+ return false;
478
+ }
479
+ // Logged-in users should have an account affordance or prior chat history. Generic
480
+ // composer/model pills also appear in guest sessions, so they are not auth proof.
481
+ const profileButton = document.querySelector('[data-testid="accounts-profile-button"]');
482
+ const historyItem = document.querySelector('[data-testid^="history-item-"]');
483
+ return Boolean(profileButton || historyItem);
484
+ };
485
+
486
+ let backend = await readBackendDetail();
487
+ let status = backend.status;
488
+ let cfBlocked = backend.cfBlocked;
489
+ let error = backend.error;
445
490
  let domLoginCta = hasLoginCta();
491
+ let appAuthenticated = hasAppAuthSignal();
492
+ const isRetryableStatus = () =>
493
+ status === 0 || status === 401 || status === 403 || status === 503 || status === 429;
446
494
  const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
447
- while (!domLoginCta && Date.now() < settleDeadline) {
495
+ while (!domLoginCta && status !== 200 && isRetryableStatus() && Date.now() < settleDeadline) {
448
496
  await new Promise((resolve) => setTimeout(resolve, 100));
449
497
  domLoginCta = hasLoginCta();
450
- if (status === 0 || status === 401 || status === 403) {
451
- const next = await readBackendStatus();
452
- status = next.status;
453
- error = next.error;
454
- }
498
+ appAuthenticated = hasAppAuthSignal();
499
+ backend = await readBackendDetail();
500
+ status = backend.status;
501
+ cfBlocked = backend.cfBlocked;
502
+ error = backend.error;
455
503
  }
456
504
 
457
505
  const loginSignals = domLoginCta || onAuthPage;
506
+ // Accept the SPA-level signal only when the API path is blocked or unavailable
507
+ // (CF challenge, throttling, transient 5xx, network shaping) but the DOM shows
508
+ // an authenticated logged-in shell. Plain 401/403 remain authoritative because
509
+ // they can mean the ChatGPT session really expired.
510
+ const apiBlocked =
511
+ cfBlocked ||
512
+ status === 429 ||
513
+ status === 503 ||
514
+ status === 0;
515
+ const ok = !loginSignals && (status === 200 || (apiBlocked && appAuthenticated));
458
516
  return {
459
- ok: !loginSignals && status === 200,
517
+ ok,
460
518
  status,
461
519
  redirected: false,
462
520
  url: pageUrl,
463
521
  pageUrl,
464
522
  domLoginCta,
465
523
  onAuthPage,
524
+ appAuthenticated,
525
+ cfBlocked,
466
526
  error,
467
527
  };
468
528
  })()`;
@@ -487,6 +547,8 @@ function normalizeLoginProbe(raw) {
487
547
  pageUrl: typeof value.pageUrl === "string" ? value.pageUrl : null,
488
548
  domLoginCta: Boolean(value.domLoginCta),
489
549
  onAuthPage: Boolean(value.onAuthPage),
550
+ appAuthenticated: Boolean(value.appAuthenticated),
551
+ cfBlocked: Boolean(value.cfBlocked),
490
552
  };
491
553
  }
492
554
  export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
@@ -166,7 +166,7 @@ export async function submitPrompt(deps, prompt, logger) {
166
166
  observedLength,
167
167
  });
168
168
  }
169
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
169
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
170
  if (!clicked) {
171
171
  await input.dispatchKeyEvent({
172
172
  type: "keyDown",
@@ -183,6 +183,7 @@ export async function submitPrompt(deps, prompt, logger) {
183
183
  else {
184
184
  logger("Clicked send button");
185
185
  }
186
+ await deps.onPromptSubmitted?.();
186
187
  const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
187
188
  // Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
188
189
  return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
@@ -282,18 +283,136 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
282
283
  logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
283
284
  }
284
285
  function buildAttachmentReadyExpression(attachmentNames) {
285
- const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
286
+ const attachmentExpectations = attachmentNames.map((name) => {
287
+ const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
288
+ return {
289
+ name: normalized,
290
+ stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
291
+ extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
292
+ };
293
+ });
294
+ const namesLiteral = JSON.stringify(attachmentExpectations);
286
295
  return `(() => {
287
- const names = ${namesLiteral};
296
+ const expected = ${namesLiteral};
297
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
298
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
299
+ const hasNameBoundary = (text, name) => {
300
+ if (!name) return false;
301
+ let from = 0;
302
+ while (from < text.length) {
303
+ const index = text.indexOf(name, from);
304
+ if (index === -1) return false;
305
+ const previous = text[index - 1] || '';
306
+ const next = text[index + name.length] || '';
307
+ const previousOk = !previous || !/[a-z0-9]/.test(previous);
308
+ const nextOk = !next || !/[a-z0-9]/.test(next);
309
+ if (previousOk && nextOk) return true;
310
+ from = index + name.length;
311
+ }
312
+ return false;
313
+ };
314
+ const hasExtensionBoundary = (text, extension) => {
315
+ if (!extension) return false;
316
+ let from = 0;
317
+ while (from < text.length) {
318
+ const index = text.indexOf(extension, from);
319
+ if (index === -1) return false;
320
+ const next = text[index + extension.length] || '';
321
+ if (!next || !/[a-z0-9]/.test(next)) return true;
322
+ from = index + extension.length;
323
+ }
324
+ return false;
325
+ };
326
+ const matchesExpected = (value, item) => {
327
+ const text = normalize(value);
328
+ if (!text) return false;
329
+ if (hasNameBoundary(text, item.name)) return true;
330
+ if (
331
+ item.stem &&
332
+ item.stem.length >= 4 &&
333
+ item.extension &&
334
+ text.includes(item.stem + '(') &&
335
+ hasExtensionBoundary(text, item.extension)
336
+ ) {
337
+ return true;
338
+ }
339
+ if (text.includes('…') || text.includes('...')) {
340
+ const marker = text.includes('…') ? '…' : '...';
341
+ const [prefixRaw, suffixRaw] = text.split(marker);
342
+ const prefix = normalize(prefixRaw);
343
+ const suffix = normalize(suffixRaw);
344
+ const prefixParts = prefix.split(' ').filter(Boolean);
345
+ const suffixParts = suffix.split(' ').filter(Boolean);
346
+ const prefixCandidates = prefixParts.map((_, index) => prefixParts.slice(index).join(' '));
347
+ const suffixCandidates = suffixParts.map((_, index) =>
348
+ suffixParts.slice(0, suffixParts.length - index).join(' '),
349
+ );
350
+ if (prefixCandidates.length === 0 || suffixCandidates.length === 0) return false;
351
+ const targets = [item.name, item.stem && item.stem.length >= 4 ? item.stem : ''].filter(Boolean);
352
+ return targets.some((target) => {
353
+ return prefixCandidates.some((prefixPart) =>
354
+ suffixCandidates.some((suffixPart) => {
355
+ const strongEnough =
356
+ suffixPart.length >= 2 &&
357
+ (prefixPart.length >= 3 || (prefixPart.length >= 2 && suffixPart.length >= 4));
358
+ return strongEnough && target.startsWith(prefixPart) && target.endsWith(suffixPart);
359
+ }),
360
+ );
361
+ });
362
+ }
363
+ return false;
364
+ };
365
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
366
+ const attachmentSelectors = [
367
+ '[data-testid*="chip"]',
368
+ '[data-testid*="attachment"]',
369
+ '[data-testid*="upload"]',
370
+ '[data-testid*="file"]',
371
+ '[aria-label*="Remove file"]',
372
+ 'button[aria-label*="Remove file"]',
373
+ '[aria-label*="remove file"]',
374
+ 'button[aria-label*="remove file"]',
375
+ '[aria-label*="Remove attachment"]',
376
+ 'button[aria-label*="Remove attachment"]',
377
+ '[aria-label*="remove attachment"]',
378
+ 'button[aria-label*="remove attachment"]',
379
+ ];
380
+ const sendButton = sendSelectors
381
+ .map((selector) => document.querySelector(selector))
382
+ .find(Boolean);
383
+ const isUsableComposerRoot = (node) => {
384
+ if (!(node instanceof HTMLElement)) return false;
385
+ if (String(node.tagName || '').toLowerCase() === 'button') return false;
386
+ const testId = String(node.getAttribute?.('data-testid') || '').toLowerCase();
387
+ if (!testId.includes('composer')) return false;
388
+ return !(
389
+ testId.includes('footer') ||
390
+ testId.includes('action') ||
391
+ testId.includes('plus') ||
392
+ testId.includes('send')
393
+ );
394
+ };
395
+ const closestComposerRoot = (node) => {
396
+ let current = node instanceof HTMLElement ? node : null;
397
+ while (current) {
398
+ if (isUsableComposerRoot(current)) return current;
399
+ current = current.parentElement;
400
+ }
401
+ return null;
402
+ };
403
+ const firstComposerRoot = () =>
404
+ Array.from(document.querySelectorAll('[data-testid*="composer"]')).find(isUsableComposerRoot) || null;
288
405
  const composer =
289
- document.querySelector('[data-testid*="composer"]') ||
406
+ closestComposerRoot(sendButton) ||
407
+ sendButton?.closest?.('form') ||
408
+ firstComposerRoot() ||
290
409
  document.querySelector('form') ||
291
410
  document.body ||
292
411
  document;
293
412
  // Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
294
413
  // ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
295
414
  // only the node's own textContent/aria/title misses the match.
296
- const collectLabelHaystack = (node) => {
415
+ const collectOwnLabelHaystack = (node) => {
297
416
  if (!node) return '';
298
417
  const pieces = [];
299
418
  const pushAttrs = (el) => {
@@ -310,31 +429,21 @@ function buildAttachmentReadyExpression(attachmentNames) {
310
429
  };
311
430
  pushAttrs(node);
312
431
  pushText(node);
432
+ return pieces.join(' ').toLowerCase();
433
+ };
434
+ const collectLabelHaystack = (node) => {
435
+ if (!node) return '';
436
+ const pieces = [collectOwnLabelHaystack(node)];
437
+ const push = (el) => {
438
+ const text = collectOwnLabelHaystack(el);
439
+ if (text) pieces.push(text);
440
+ };
313
441
  const parent = node.parentElement;
314
- pushAttrs(parent);
315
- pushText(parent);
442
+ push(parent);
316
443
  const grandparent = parent?.parentElement;
317
- pushAttrs(grandparent);
318
- pushText(grandparent);
444
+ push(grandparent);
319
445
  return pieces.join(' ').toLowerCase();
320
446
  };
321
- const match = (node, name) => collectLabelHaystack(node).includes(name);
322
-
323
- // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
324
- const attachmentSelectors = [
325
- '[data-testid*="chip"]',
326
- '[data-testid*="attachment"]',
327
- '[data-testid*="upload"]',
328
- '[data-testid*="file"]',
329
- '[aria-label*="Remove file"]',
330
- 'button[aria-label*="Remove file"]',
331
- '[aria-label*="remove file"]',
332
- 'button[aria-label*="remove file"]',
333
- '[aria-label*="Remove attachment"]',
334
- 'button[aria-label*="Remove attachment"]',
335
- '[aria-label*="remove attachment"]',
336
- 'button[aria-label*="remove attachment"]',
337
- ];
338
447
  const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
339
448
  const collectChipNodes = () => {
340
449
  const seen = new Set();
@@ -353,32 +462,66 @@ function buildAttachmentReadyExpression(attachmentNames) {
353
462
  return collected;
354
463
  };
355
464
  const chipNodes = collectChipNodes();
356
-
357
- const chipsReady = names.every((name) =>
358
- chipNodes.some((node) => match(node, name)),
465
+ const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
466
+ const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
467
+ const hasEllipsisSuffix = (label) => {
468
+ const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
469
+ if (!marker) return false;
470
+ return normalize(label.split(marker)[1] || '').length > 0;
471
+ };
472
+ const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
473
+ /\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
474
+ hasEllipsisSuffix(label),
475
+ );
476
+ const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
477
+ expected.some((item) => matchesExpected(label, item)),
359
478
  );
360
- const inputsReady = names.every((name) =>
479
+
480
+ const chipsReady = (() => {
481
+ const used = new Set();
482
+ return expected.every((item) => {
483
+ const index = chipLabels.findIndex((label, candidateIndex) =>
484
+ !used.has(candidateIndex) && matchesExpected(label, item),
485
+ );
486
+ if (index === -1) return false;
487
+ used.add(index);
488
+ return true;
489
+ });
490
+ })();
491
+ const inputsReady = expected.every((item) =>
361
492
  attachmentRoots.some((root) =>
362
493
  Array.from(root.querySelectorAll('input[type="file"]')).some((el) =>
363
494
  Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
364
- file?.name?.toLowerCase?.().includes(name),
495
+ matchesExpected(file?.name, item),
365
496
  ),
366
497
  ),
367
498
  ),
368
499
  );
369
500
  // Count-based fallback: if we cannot match names individually (ChatGPT may strip
370
501
  // the filename out of attribute-readable text into a deeply nested span), but we
371
- // do see at least as many distinct chip-shaped nodes as attachments we uploaded,
372
- // and a sibling "Remove" affordance exists per chip, trust the upload.
373
- const removeAffordanceCount = chipNodes.filter((node) => {
374
- const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
375
- if (aria.includes('remove')) return true;
376
- const removeSibling = node.querySelector?.(
502
+ // do see at least as many distinct "Remove" affordances as attachments we
503
+ // uploaded, trust the upload without double-counting nested chip/remove nodes.
504
+ const removeAffordances = [];
505
+ const removeSeen = new Set();
506
+ for (const root of attachmentRoots) {
507
+ for (const node of Array.from(root.querySelectorAll(
377
508
  '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
378
- );
379
- return Boolean(removeSibling);
380
- }).length;
381
- const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
509
+ ))) {
510
+ if (!(node instanceof HTMLElement)) continue;
511
+ if (node.closest('textarea,[contenteditable="true"]')) continue;
512
+ const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
513
+ const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
514
+ const attachmentOwner = node.closest(
515
+ '[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
516
+ );
517
+ if (!fileSpecific && !attachmentOwner) continue;
518
+ if (removeSeen.has(node)) continue;
519
+ removeSeen.add(node);
520
+ removeAffordances.push(node);
521
+ }
522
+ }
523
+ const countReady =
524
+ visibleExtensionLabelsMatchExpected && removeAffordances.length >= expected.length;
382
525
 
383
526
  return chipsReady || inputsReady || countReady;
384
527
  })()`;
@@ -386,7 +529,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
386
529
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
387
530
  return buildAttachmentReadyExpression(attachmentNames);
388
531
  }
389
- async function attemptSendButton(Runtime, _logger, attachmentNames) {
532
+ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
390
533
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
391
534
  const script = `(() => {
392
535
  ${buildClickDispatcher()}
@@ -423,7 +566,8 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
423
566
  // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
424
567
  // settle slowly for multi-file uploads, but plain text sends should keep the
425
568
  // shorter historical deadline.
426
- const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
569
+ const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
570
+ const deadline = Date.now() + timeoutMs;
427
571
  while (Date.now() < deadline) {
428
572
  if (needAttachment) {
429
573
  const ready = await Runtime.evaluate({
@@ -445,16 +589,22 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
445
589
  await delay(100);
446
590
  }
447
591
  if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
448
- throw new BrowserAutomationError("Attachments never reached a clickable send button before timeout.", {
592
+ throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
449
593
  stage: "submit-prompt",
450
594
  code: "attachment-send-not-ready",
451
595
  attachmentNames,
596
+ timeoutMs,
452
597
  });
453
598
  }
454
599
  return false;
455
600
  }
456
- function sendButtonTimeoutMs(attachmentNames) {
457
- return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
601
+ function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
602
+ if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
603
+ return 20_000;
604
+ }
605
+ return typeof attachmentTimeoutMs === "number" && Number.isFinite(attachmentTimeoutMs)
606
+ ? Math.max(1_000, attachmentTimeoutMs)
607
+ : 45_000;
458
608
  }
459
609
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
460
610
  const deadline = Date.now() + timeoutMs;