@steipete/oracle 0.15.0 → 0.15.2

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 (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -1,14 +1,15 @@
1
- import { DEEP_RESEARCH_PLUS_BUTTON, DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, DEEP_RESEARCH_PILL_LABEL, DEEP_RESEARCH_POLL_INTERVAL_MS, DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from "../constants.js";
1
+ import { DEEP_RESEARCH_PLUS_BUTTON, DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, DEEP_RESEARCH_PILL_LABEL, DEEP_RESEARCH_POLL_INTERVAL_MS, DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from "../constants.js";
2
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { isDeepResearchIncompleteText } from "../deepResearchResult.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
5
6
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./assistantResponse.js";
6
7
  import { BrowserAutomationError } from "../../oracle/errors.js";
7
8
  /**
8
- * Activates Deep Research mode through ChatGPT's slash command, with the
9
- * composer tools menu as a fallback for older UI variants.
9
+ * Activates Deep Research mode through ChatGPT's composer tools menu and
10
+ * verifies the selected tool pill before prompt submission.
10
11
  */
11
- export async function activateDeepResearch(Runtime, _Input, logger) {
12
+ export async function activateDeepResearch(Runtime, Input, logger) {
12
13
  const expression = buildActivateDeepResearchExpression();
13
14
  const outcome = await Runtime.evaluate({
14
15
  expression,
@@ -32,14 +33,48 @@ export async function activateDeepResearch(Runtime, _Input, logger) {
32
33
  throw new BrowserAutomationError(`"Deep research" option not found in composer dropdown.${hint} ` +
33
34
  "This feature may require a ChatGPT Plus or Pro subscription.", { stage: "deep-research-activate", code: "dropdown-item-missing" });
34
35
  }
35
- case "pill-not-confirmed":
36
+ case "pill-not-confirmed": {
37
+ const point = result.clickPoint;
38
+ if (typeof point?.x === "number" && typeof point.y === "number") {
39
+ await clickTrustedPoint(Runtime, Input, point.x, point.y);
40
+ if (await waitForDeepResearchPill(Runtime)) {
41
+ logger("Deep Research mode activated");
42
+ return;
43
+ }
44
+ }
36
45
  throw new BrowserAutomationError("Deep Research pill did not appear after selection. The UI may have changed.", { stage: "deep-research-activate", code: "pill-not-confirmed" });
46
+ }
37
47
  default:
38
48
  throw new BrowserAutomationError("Unexpected result from Deep Research activation.", {
39
49
  stage: "deep-research-activate",
40
50
  });
41
51
  }
42
52
  }
53
+ async function clickTrustedPoint(Runtime, Input, x, y) {
54
+ if (Input && typeof Input.dispatchMouseEvent === "function") {
55
+ await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
56
+ await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
57
+ await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
58
+ return;
59
+ }
60
+ await Runtime.evaluate({
61
+ expression: `(() => {
62
+ const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
63
+ if (!(el instanceof HTMLElement)) return false;
64
+ el.click();
65
+ return true;
66
+ })()`,
67
+ returnByValue: true,
68
+ });
69
+ }
70
+ async function waitForDeepResearchPill(Runtime, timeoutMs = 5000) {
71
+ const { result } = await Runtime.evaluate({
72
+ expression: buildWaitForDeepResearchPillExpression(timeoutMs),
73
+ awaitPromise: true,
74
+ returnByValue: true,
75
+ });
76
+ return Boolean(result?.value);
77
+ }
43
78
  /**
44
79
  * After prompt submission, waits for the research plan to appear and
45
80
  * auto-confirm (~60s countdown + 10s safety margin).
@@ -446,9 +481,9 @@ async function readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, pageSess
446
481
  .send("Runtime.callFunctionOn", {
447
482
  objectId,
448
483
  functionDeclaration: `function() {
449
- const selector = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
450
- const turn = this.closest(selector);
451
- return turn ? Array.from(document.querySelectorAll(selector)).indexOf(turn) : null;
484
+ const turns = ${buildConversationTurnListExpression()};
485
+ const index = turns.findIndex((turn) => turn === this || turn.contains?.(this));
486
+ return index >= 0 ? index : null;
452
487
  }`,
453
488
  returnByValue: true,
454
489
  }, pageSessionId)
@@ -669,7 +704,6 @@ function buildDeepResearchStatusExpression() {
669
704
  function buildDeepResearchCompletionPollExpression(minTurnIndex) {
670
705
  const finishedSelector = JSON.stringify(FINISHED_ACTIONS_SELECTOR);
671
706
  const stopSelector = JSON.stringify(STOP_BUTTON_SELECTOR);
672
- const turnSelector = JSON.stringify(CONVERSATION_TURN_SELECTOR);
673
707
  return `(() => {
674
708
  const MIN_TURN_INDEX = ${minTurnIndex};
675
709
  const stopVisible = Boolean(document.querySelector(${stopSelector}));
@@ -685,7 +719,7 @@ function buildDeepResearchCompletionPollExpression(minTurnIndex) {
685
719
  String(node.getAttribute('data-testid') || '').toLowerCase().includes('conversation-turn') &&
686
720
  /chatgpt\\s+said/i.test(node.innerText || node.textContent || '');
687
721
  };
688
- const conversationTurns = Array.from(document.querySelectorAll(${turnSelector}));
722
+ const conversationTurns = ${buildConversationTurnListExpression()};
689
723
  const allAssistantTurns = Array.from(document.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]'));
690
724
  const scopedTurns = scopedToNewTurns
691
725
  ? conversationTurns.slice(MIN_TURN_INDEX).filter(isAssistantTurn)
@@ -737,29 +771,56 @@ export function buildDeepResearchStatusExpressionForTest() {
737
771
  export function buildDeepResearchCompletionPollExpressionForTest(minTurnIndex = -1) {
738
772
  return buildDeepResearchCompletionPollExpression(minTurnIndex);
739
773
  }
740
- function buildActivateDeepResearchExpression() {
741
- const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
742
- const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
774
+ function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPill") {
743
775
  const pillLabel = JSON.stringify(DEEP_RESEARCH_PILL_LABEL);
744
- // pillLabel is used inside the expression for verification
745
- void pillLabel;
746
- return `(async () => {
747
- ${buildClickDispatcher()}
748
-
749
- const findDeepResearchPill = () => {
750
- const pills = document.querySelectorAll('.__composer-pill-composite, .__composer-pill, [class*="composer-pill"]');
751
- for (const pill of pills) {
752
- const text = pill.textContent?.trim() || '';
753
- const aria = pill.getAttribute('aria-label') ||
776
+ return `const ${functionName} = () => {
777
+ const label = ${pillLabel}.toLowerCase();
778
+ const selectors = [
779
+ '.__composer-pill-composite',
780
+ '.__composer-pill',
781
+ '[class*="composer-pill"]',
782
+ ].join(',');
783
+ const candidates = Array.from(document.querySelectorAll(selectors));
784
+ const composerRoots = Array.from(document.querySelectorAll('[data-testid="composer"], form, [class*="composer"]'));
785
+ for (const root of composerRoots) {
786
+ candidates.push(...Array.from(root.querySelectorAll('button, [role="button"], [class*="pill"], [class*="composer-pill"]')));
787
+ }
788
+ const seen = new Set();
789
+ for (const pill of candidates) {
790
+ if (!(pill instanceof Element) || seen.has(pill)) continue;
791
+ seen.add(pill);
792
+ const rect = pill.getBoundingClientRect?.();
793
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue;
794
+ const text = (pill.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
795
+ const aria = (
796
+ pill.getAttribute('aria-label') ||
754
797
  pill.querySelector('button')?.getAttribute('aria-label') ||
755
- '';
756
- if (text.toLowerCase().includes('deep research') ||
757
- aria.toLowerCase().includes('deep research')) {
798
+ ''
799
+ ).toLowerCase();
800
+ if (text.includes(label) || aria.includes(label)) {
758
801
  return pill;
759
802
  }
760
803
  }
761
804
  return null;
762
- };
805
+ };`;
806
+ }
807
+ function buildWaitForDeepResearchPillExpression(timeoutMs) {
808
+ return `(async () => {
809
+ ${buildFindDeepResearchPillExpression()}
810
+ const deadline = Date.now() + ${JSON.stringify(Math.max(timeoutMs, 0))};
811
+ while (Date.now() < deadline) {
812
+ if (findDeepResearchPill()) return true;
813
+ await new Promise(resolve => setTimeout(resolve, 200));
814
+ }
815
+ return Boolean(findDeepResearchPill());
816
+ })()`;
817
+ }
818
+ function buildActivateDeepResearchExpression() {
819
+ const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
820
+ const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
821
+ return `(async () => {
822
+ ${buildClickDispatcher()}
823
+ ${buildFindDeepResearchPillExpression()}
763
824
 
764
825
  const waitForPill = () => new Promise((resolve) => {
765
826
  let elapsed = 0;
@@ -774,24 +835,105 @@ function buildActivateDeepResearchExpression() {
774
835
  setTimeout(tick, 200);
775
836
  });
776
837
 
777
- const clearComposer = (composer) => {
778
- if (!composer) return;
779
- if ('value' in composer) composer.value = '';
780
- else composer.textContent = '';
781
- composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
838
+ const menuItemSelector = [
839
+ '[data-radix-collection-item]',
840
+ '[role="option"]',
841
+ '[cmdk-item]',
842
+ 'button',
843
+ '[role="menuitem"]',
844
+ '[role="menuitemradio"]',
845
+ '.__menu-item',
846
+ '[class*="__menu-item"]',
847
+ '[class*="menu-item"]',
848
+ ].join(',');
849
+ const dropdownItemSelector = [
850
+ '[data-radix-collection-item]',
851
+ '[role="menuitem"]',
852
+ '[role="menuitemradio"]',
853
+ '[role="option"]',
854
+ '[cmdk-item]',
855
+ 'button',
856
+ '.__menu-item',
857
+ '[class*="__menu-item"]',
858
+ '[class*="menu-item"]',
859
+ ].join(',');
860
+ const popoverSelector = [
861
+ '.popover',
862
+ '[class*="popover"]',
863
+ '[data-radix-popper-content-wrapper]',
864
+ '[data-floating-ui-portal]',
865
+ ].join(',');
866
+ const target = ${targetText}.toLowerCase();
867
+ const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
868
+ const getText = (item) => normalizeText(item.textContent || item.getAttribute?.('aria-label') || '');
869
+ const isInPopover = (item) => Boolean(item.closest?.(popoverSelector));
870
+ const isVisible = (item) => {
871
+ const rect = item.getBoundingClientRect?.();
872
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
873
+ const style = window.getComputedStyle?.(item);
874
+ return !style || (style.visibility !== 'hidden' && style.display !== 'none');
782
875
  };
783
-
784
- const setComposerText = (composer, text) => {
785
- composer.focus?.();
786
- if ('value' in composer) composer.value = text;
787
- else composer.textContent = text;
788
- composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
876
+ const findPopoverSearchInput = () => Array.from(
877
+ document.querySelectorAll('input, textarea, [contenteditable="true"]')
878
+ ).find(item => {
879
+ const type = (item.getAttribute?.('type') || '').toLowerCase();
880
+ const testId = (item.getAttribute?.('data-testid') || '').toLowerCase();
881
+ return isInPopover(item) &&
882
+ isVisible(item) &&
883
+ type !== 'file' &&
884
+ testId !== 'upload-photos-input';
885
+ }) || null;
886
+ const setSearchText = (input, text) => {
887
+ input.focus?.();
888
+ if ('value' in input) input.value = text;
889
+ else input.textContent = text;
890
+ input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
891
+ input.dispatchEvent(new Event('change', { bubbles: true }));
789
892
  };
790
-
791
- const findDeepResearchItem = () => {
792
- const target = ${targetText}.toLowerCase();
793
- const candidates = Array.from(document.querySelectorAll('[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"]'));
794
- return candidates.find(item => (item.textContent || '').trim().toLowerCase() === target) || null;
893
+ const isDeepResearchText = (text) => (
894
+ text === target ||
895
+ text.startsWith(target + ' ') ||
896
+ text === 'get a detailed report' ||
897
+ text.startsWith('get a detailed report ') ||
898
+ (text.includes(target) && text.includes('detailed report')) ||
899
+ text.replace(/\\s+/g, '').startsWith('deepresearch')
900
+ );
901
+ const getClickableItem = (item) => item.closest?.(
902
+ '[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"], .__menu-item, [class*="__menu-item"], [class*="menu-item"]'
903
+ ) || item;
904
+ const findDeepResearchItem = (options = {}) => {
905
+ const matches = Array.from(document.querySelectorAll(menuItemSelector))
906
+ .filter(item => {
907
+ const text = getText(item);
908
+ return text &&
909
+ text.length <= 180 &&
910
+ isVisible(item) &&
911
+ (!options.requirePopover || isInPopover(item)) &&
912
+ isDeepResearchText(text);
913
+ })
914
+ .map(item => {
915
+ const text = getText(item);
916
+ const clickable = getClickableItem(item);
917
+ const exact = text === target ? 0 : 1;
918
+ const menuRow = /(^|\\s)__menu-item(\\s|$)/.test(clickable.className || '') ? 0 : 1;
919
+ return { item: clickable, score: exact + menuRow, textLength: text.length };
920
+ })
921
+ .sort((a, b) => a.score - b.score || a.textLength - b.textLength);
922
+ return matches[0]?.item || null;
923
+ };
924
+ const collectAvailableItems = (options = {}) => {
925
+ const seen = new Set();
926
+ return Array.from(document.querySelectorAll(dropdownItemSelector))
927
+ .filter(item => !options.requirePopover || isInPopover(item))
928
+ .filter(item => isVisible(item))
929
+ .map(item => (item.textContent || '').replace(/\\s+/g, ' ').trim())
930
+ .filter(text => text && text.length <= 180)
931
+ .filter(text => {
932
+ const key = text.toLowerCase();
933
+ if (seen.has(key)) return false;
934
+ seen.add(key);
935
+ return true;
936
+ });
795
937
  };
796
938
 
797
939
  // Step 0: Check if already active
@@ -799,20 +941,8 @@ function buildActivateDeepResearchExpression() {
799
941
  return { status: 'already-active' };
800
942
  }
801
943
 
802
- // Step 1: Prefer the official slash command flow.
803
- const composer = document.querySelector('[contenteditable="true"], textarea');
804
- if (composer) {
805
- setComposerText(composer, '/Deepresearch');
806
- await new Promise(resolve => setTimeout(resolve, 600));
807
- const slashItem = findDeepResearchItem();
808
- if (slashItem) {
809
- dispatchClickSequence(slashItem);
810
- if (await waitForPill()) return { status: 'activated' };
811
- }
812
- clearComposer(composer);
813
- }
814
-
815
- // Step 2: Fall back to the composer tools menu.
944
+ // Step 1: Open the composer tools menu. Avoid slash commands because they
945
+ // mutate the main composer and can be submitted as normal prompt text.
816
946
  const plusBtn = document.querySelector(${plusBtnSelector}) ||
817
947
  Array.from(document.querySelectorAll('button')).find(
818
948
  b => (b.getAttribute('aria-label') || '').toLowerCase().includes('add files')
@@ -820,14 +950,21 @@ function buildActivateDeepResearchExpression() {
820
950
  if (!plusBtn) return { status: 'plus-button-missing' };
821
951
  dispatchClickSequence(plusBtn);
822
952
 
823
- // Step 3: Wait for dropdown
953
+ // Step 2: Wait for dropdown
824
954
  const waitForDropdown = () => new Promise((resolve) => {
825
955
  let elapsed = 0;
826
956
  const tick = () => {
827
- const items = document.querySelectorAll('[data-radix-collection-item], [role="menuitem"], [role="menuitemradio"], [role="option"], [cmdk-item]');
828
- if (items.length > 0) { resolve(items); return; }
957
+ const items = collectAvailableItems({ requirePopover: true });
958
+ if (findDeepResearchItem({ requirePopover: true }) || items.some(text => {
959
+ const normalized = normalizeText(text);
960
+ return normalized.includes('add photos') ||
961
+ normalized.includes('create image') ||
962
+ normalized.includes('web search') ||
963
+ normalized.includes('deep research') ||
964
+ normalized.includes('get a detailed report');
965
+ })) { resolve(items); return; }
829
966
  elapsed += 150;
830
- if (elapsed > 3000) { resolve(null); return; }
967
+ if (elapsed > 3000) { resolve(items.length ? items : null); return; }
831
968
  setTimeout(tick, 150);
832
969
  };
833
970
  setTimeout(tick, 150);
@@ -835,25 +972,33 @@ function buildActivateDeepResearchExpression() {
835
972
  const items = await waitForDropdown();
836
973
  if (!items) return { status: 'dropdown-item-missing', available: [] };
837
974
 
838
- // Step 4: Find "Deep research" item
839
- const target = ${targetText}.toLowerCase();
840
- let match = null;
841
- const available = [];
842
- for (const item of items) {
843
- const text = (item.textContent || '').trim();
844
- available.push(text);
845
- if (text.toLowerCase() === target) {
846
- match = item;
975
+ // Step 3: Find "Deep research" item. Some ChatGPT variants only reveal it
976
+ // after typing in the tools menu search field.
977
+ let match = findDeepResearchItem({ requirePopover: true });
978
+ let available = Array.isArray(items) ? items : collectAvailableItems({ requirePopover: true });
979
+ if (!match) {
980
+ const searchInput = findPopoverSearchInput();
981
+ if (searchInput) {
982
+ setSearchText(searchInput, ${targetText});
983
+ await new Promise(resolve => setTimeout(resolve, 600));
984
+ match = findDeepResearchItem({ requirePopover: true });
985
+ available = collectAvailableItems({ requirePopover: true });
847
986
  }
848
987
  }
849
988
  if (!match) return { status: 'dropdown-item-missing', available };
850
989
 
851
- // Step 5: Click it
990
+ // Step 4: Click it
991
+ match.scrollIntoView?.({ block: 'center', inline: 'center' });
992
+ await new Promise(resolve => setTimeout(resolve, 100));
993
+ const rect = match.getBoundingClientRect();
994
+ const clickPoint = rect && rect.width > 0 && rect.height > 0
995
+ ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
996
+ : undefined;
852
997
  dispatchClickSequence(match);
853
998
 
854
- // Step 6: Verify pill appeared
999
+ // Step 5: Verify pill appeared
855
1000
  const pillConfirmed = await waitForPill();
856
- return pillConfirmed ? { status: 'activated' } : { status: 'pill-not-confirmed' };
1001
+ return pillConfirmed ? { status: 'activated' } : { status: 'pill-not-confirmed', clickPoint };
857
1002
  })()`;
858
1003
  }
859
1004
  export function buildActivateDeepResearchExpressionForTest() {
@@ -1,15 +1,38 @@
1
1
  import { COMPOSER_MODEL_SIGNAL_SELECTOR, MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
+ import { delay } from "../utils.js";
4
5
  const LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
5
6
  const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
6
- export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
7
- const outcome = await Runtime.evaluate({
8
- expression: buildModelSelectionExpression(desiredModel, strategy),
9
- awaitPromise: true,
10
- returnByValue: true,
11
- });
12
- const result = outcome.result?.value;
7
+ // The model/effort picker is a composer pill that React mounts a beat after the page
8
+ // becomes interactive (~1-4s on a cold profile, e.g. cookie-sync's throwaway Chrome).
9
+ // Re-evaluate while it is still missing, up to a bounded deadline, so selection does not
10
+ // give up before the pill renders. Only "button-missing" waits; a genuine
11
+ // "option-not-found" surfaces immediately.
12
+ const MODEL_BUTTON_WAIT_MS = 8000;
13
+ const MODEL_BUTTON_POLL_MS = 250;
14
+ export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select", options = {}) {
15
+ const buttonWaitMs = options.buttonWaitMs ?? MODEL_BUTTON_WAIT_MS;
16
+ const buttonPollMs = options.buttonPollMs ?? MODEL_BUTTON_POLL_MS;
17
+ const deadline = Date.now() + Math.max(0, buttonWaitMs);
18
+ let result;
19
+ let announcedWait = false;
20
+ for (;;) {
21
+ const outcome = await Runtime.evaluate({
22
+ expression: buildModelSelectionExpression(desiredModel, strategy),
23
+ awaitPromise: true,
24
+ returnByValue: true,
25
+ });
26
+ result = outcome.result?.value;
27
+ if (result?.status !== "button-missing" || Date.now() >= deadline) {
28
+ break;
29
+ }
30
+ if (!announcedWait) {
31
+ announcedWait = true;
32
+ logger(`Model picker button not mounted yet; waiting up to ${Math.round(buttonWaitMs / 1000)}s for the composer pill to render.`);
33
+ }
34
+ await delay(buttonPollMs);
35
+ }
13
36
  switch (result?.status) {
14
37
  case "already-selected":
15
38
  case "switched": {
@@ -1,4 +1,5 @@
1
- import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
1
+ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
2
+ import { buildConversationTurnCountExpression, buildConversationTurnListExpression, } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { logDomFailure } from "../domDebug.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
@@ -166,7 +167,7 @@ export async function submitPrompt(deps, prompt, logger) {
166
167
  observedLength,
167
168
  });
168
169
  }
169
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
+ const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
171
  if (!clicked) {
171
172
  await input.dispatchKeyEvent({
172
173
  type: "keyDown",
@@ -575,7 +576,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
575
576
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
576
577
  return buildAttachmentReadyExpression(attachmentNames);
577
578
  }
578
- async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
579
+ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attachmentTimeoutMs) {
579
580
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
580
581
  const script = `(() => {
581
582
  ${buildClickDispatcher()}
@@ -604,10 +605,15 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
604
605
  candidates.push(...Array.from(document.querySelectorAll(selector)));
605
606
  }
606
607
  const button = candidates.find((node) => isVisible(node) && isEnabled(node)) || null;
607
- if (!button) return 'missing';
608
- // Use unified pointer/mouse sequence to satisfy React handlers.
608
+ if (!button) return { status: 'missing' };
609
+ button.scrollIntoView({ block: 'center', inline: 'center' });
610
+ const rect = button.getBoundingClientRect();
611
+ if (rect.width > 0 && rect.height > 0) {
612
+ return { status: 'point', x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
613
+ }
614
+ // Last-resort fallback for unusual DOMs where the button is visible but has no useful rect.
609
615
  dispatchClickSequence(button);
610
- return 'clicked';
616
+ return { status: 'clicked' };
611
617
  })()`;
612
618
  // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
613
619
  // settle slowly for multi-file uploads, but plain text sends should keep the
@@ -626,10 +632,19 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
626
632
  }
627
633
  }
628
634
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
629
- if (result.value === "clicked") {
635
+ const value = result.value;
636
+ const status = typeof value === "string" ? value : value?.status;
637
+ if (status === "point" &&
638
+ typeof value === "object" &&
639
+ typeof value.x === "number" &&
640
+ typeof value.y === "number") {
641
+ await clickTrustedPoint(Runtime, Input, value.x, value.y);
642
+ return true;
643
+ }
644
+ if (status === "clicked") {
630
645
  return true;
631
646
  }
632
- if (result.value === "missing") {
647
+ if (status === "missing") {
633
648
  break;
634
649
  }
635
650
  await delay(100);
@@ -644,6 +659,23 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
644
659
  }
645
660
  return false;
646
661
  }
662
+ async function clickTrustedPoint(Runtime, Input, x, y) {
663
+ if (Input && typeof Input.dispatchMouseEvent === "function") {
664
+ await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
665
+ await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
666
+ await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
667
+ return;
668
+ }
669
+ await Runtime.evaluate({
670
+ expression: `(() => {
671
+ const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
672
+ if (!(el instanceof HTMLElement)) return false;
673
+ el.click();
674
+ return true;
675
+ })()`,
676
+ returnByValue: true,
677
+ });
678
+ }
647
679
  function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
648
680
  if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
649
681
  return 20_000;
@@ -660,14 +692,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
660
692
  const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
661
693
  const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
662
694
  const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
663
- const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
664
695
  let baseline = typeof baselineTurns === "number" && Number.isFinite(baselineTurns) && baselineTurns >= 0
665
696
  ? Math.floor(baselineTurns)
666
697
  : null;
667
698
  if (baseline === null) {
668
699
  try {
669
700
  const { result } = await Runtime.evaluate({
670
- expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
701
+ expression: buildConversationTurnCountExpression(),
671
702
  returnByValue: true,
672
703
  });
673
704
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -695,8 +726,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
695
726
  };
696
727
  const normalizedPrompt = normalize(${encodedPrompt});
697
728
  const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
698
- const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
699
- const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
729
+ const articles = ${buildConversationTurnListExpression()};
700
730
  const normalizedTurns = articles.map((node) => normalize(node?.innerText));
701
731
  const readValue = (node) => {
702
732
  if (!node) return '';
@@ -755,9 +785,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
755
785
  turnsCount: normalizedTurns.length,
756
786
  };
757
787
  })()`;
788
+ let lastProbe;
758
789
  while (Date.now() < deadline) {
759
790
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
760
791
  const info = result.value;
792
+ if (info && typeof info === "object") {
793
+ lastProbe = info;
794
+ }
761
795
  const turnsCount = result.value?.turnsCount;
762
796
  const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
763
797
  const baselineUnknown = typeof info?.baseline === "number" ? info.baseline < 0 : baselineLiteral < 0;
@@ -772,13 +806,12 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
772
806
  }
773
807
  await delay(100);
774
808
  }
809
+ const finalProbe = await Runtime.evaluate({ expression: script, returnByValue: true })
810
+ .then((res) => res?.result?.value)
811
+ .catch(() => undefined);
812
+ const probe = finalProbe && typeof finalProbe === "object" ? finalProbe : lastProbe;
775
813
  if (logger) {
776
- logger(`Prompt commit check failed; latest state: ${await Runtime.evaluate({
777
- expression: script,
778
- returnByValue: true,
779
- })
780
- .then((res) => JSON.stringify(res?.result?.value))
781
- .catch(() => "unavailable")}`);
814
+ logger(`Prompt commit check failed; latest state: ${probe ? JSON.stringify(probe) : "unavailable"}`);
782
815
  await logDomFailure(Runtime, logger, "prompt-commit");
783
816
  }
784
817
  if (prompt.trim().length >= 50_000) {
@@ -789,7 +822,30 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
789
822
  timeoutMs,
790
823
  });
791
824
  }
792
- throw new Error("Prompt did not appear in conversation before timeout (send may have failed)");
825
+ throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (send may have failed)", {
826
+ stage: "submit-prompt",
827
+ code: "prompt-commit-timeout",
828
+ promptLength: prompt.trim().length,
829
+ timeoutMs,
830
+ commitProbe: probe ? summarizeCommitProbe(probe) : undefined,
831
+ });
832
+ }
833
+ // Keep booleans/counts but replace free text with lengths so session metadata stays lean.
834
+ function summarizeCommitProbe(probe) {
835
+ return {
836
+ baseline: probe.baseline,
837
+ turnsCount: probe.turnsCount,
838
+ userMatched: probe.userMatched,
839
+ prefixMatched: probe.prefixMatched,
840
+ lastMatched: probe.lastMatched,
841
+ hasNewTurn: probe.hasNewTurn,
842
+ stopVisible: probe.stopVisible,
843
+ assistantVisible: probe.assistantVisible,
844
+ composerCleared: probe.composerCleared,
845
+ inConversation: probe.inConversation,
846
+ editorLength: typeof probe.editorValue === "string" ? probe.editorValue.length : undefined,
847
+ lastTurnLength: typeof probe.lastTurn === "string" ? probe.lastTurn.length : undefined,
848
+ };
793
849
  }
794
850
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
795
851
  export const __test__ = {