@steipete/oracle 0.15.0 → 0.15.1
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.
- package/dist/bin/oracle-cli.js +14 -6
- package/dist/docs-site/bridge.html +17 -1
- package/dist/docs-site/browser-mode.html +2 -2
- package/dist/docs-site/configuration.html +12 -2
- package/dist/docs-site/openai-endpoints.html +12 -0
- package/dist/src/browser/actions/assistantResponse.js +40 -31
- package/dist/src/browser/actions/attachments.js +28 -1
- package/dist/src/browser/actions/deepResearch.js +212 -67
- package/dist/src/browser/actions/modelSelection.js +30 -7
- package/dist/src/browser/actions/promptComposer.js +71 -14
- package/dist/src/browser/actions/thinkingStatus.js +19 -1
- package/dist/src/browser/artifacts.js +191 -6
- package/dist/src/browser/chatgptFiles.js +525 -91
- package/dist/src/browser/constants.js +5 -0
- package/dist/src/browser/index.js +30 -30
- package/dist/src/browser/sessionRunner.js +9 -3
- package/dist/src/cli/bridge/client.js +4 -1
- package/dist/src/cli/bridge/doctor.js +19 -0
- package/dist/src/cli/runOptions.js +11 -2
- package/dist/src/cli/sessionDisplay.js +6 -1
- package/dist/src/cli/sessionRunner.js +28 -10
- package/dist/src/config.js +3 -0
- package/dist/src/oracle/client.js +2 -0
- package/dist/src/oracle/modelResolver.js +85 -0
- package/dist/src/oracle/multiModelRunner.js +4 -1
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/remote/client.js +253 -22
- package/dist/src/remote/health.js +27 -0
- package/dist/src/remote/server.js +239 -4
- package/dist/src/remote/types.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +13 -13
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -5,10 +5,10 @@ import { buildClickDispatcher } from "./domEvents.js";
|
|
|
5
5
|
import { captureAssistantMarkdown, readAssistantSnapshot } from "./assistantResponse.js";
|
|
6
6
|
import { BrowserAutomationError } from "../../oracle/errors.js";
|
|
7
7
|
/**
|
|
8
|
-
* Activates Deep Research mode through ChatGPT's
|
|
9
|
-
*
|
|
8
|
+
* Activates Deep Research mode through ChatGPT's composer tools menu and
|
|
9
|
+
* verifies the selected tool pill before prompt submission.
|
|
10
10
|
*/
|
|
11
|
-
export async function activateDeepResearch(Runtime,
|
|
11
|
+
export async function activateDeepResearch(Runtime, Input, logger) {
|
|
12
12
|
const expression = buildActivateDeepResearchExpression();
|
|
13
13
|
const outcome = await Runtime.evaluate({
|
|
14
14
|
expression,
|
|
@@ -32,14 +32,48 @@ export async function activateDeepResearch(Runtime, _Input, logger) {
|
|
|
32
32
|
throw new BrowserAutomationError(`"Deep research" option not found in composer dropdown.${hint} ` +
|
|
33
33
|
"This feature may require a ChatGPT Plus or Pro subscription.", { stage: "deep-research-activate", code: "dropdown-item-missing" });
|
|
34
34
|
}
|
|
35
|
-
case "pill-not-confirmed":
|
|
35
|
+
case "pill-not-confirmed": {
|
|
36
|
+
const point = result.clickPoint;
|
|
37
|
+
if (typeof point?.x === "number" && typeof point.y === "number") {
|
|
38
|
+
await clickTrustedPoint(Runtime, Input, point.x, point.y);
|
|
39
|
+
if (await waitForDeepResearchPill(Runtime)) {
|
|
40
|
+
logger("Deep Research mode activated");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
36
44
|
throw new BrowserAutomationError("Deep Research pill did not appear after selection. The UI may have changed.", { stage: "deep-research-activate", code: "pill-not-confirmed" });
|
|
45
|
+
}
|
|
37
46
|
default:
|
|
38
47
|
throw new BrowserAutomationError("Unexpected result from Deep Research activation.", {
|
|
39
48
|
stage: "deep-research-activate",
|
|
40
49
|
});
|
|
41
50
|
}
|
|
42
51
|
}
|
|
52
|
+
async function clickTrustedPoint(Runtime, Input, x, y) {
|
|
53
|
+
if (Input && typeof Input.dispatchMouseEvent === "function") {
|
|
54
|
+
await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
|
|
55
|
+
await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
56
|
+
await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await Runtime.evaluate({
|
|
60
|
+
expression: `(() => {
|
|
61
|
+
const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
|
|
62
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
63
|
+
el.click();
|
|
64
|
+
return true;
|
|
65
|
+
})()`,
|
|
66
|
+
returnByValue: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function waitForDeepResearchPill(Runtime, timeoutMs = 5000) {
|
|
70
|
+
const { result } = await Runtime.evaluate({
|
|
71
|
+
expression: buildWaitForDeepResearchPillExpression(timeoutMs),
|
|
72
|
+
awaitPromise: true,
|
|
73
|
+
returnByValue: true,
|
|
74
|
+
});
|
|
75
|
+
return Boolean(result?.value);
|
|
76
|
+
}
|
|
43
77
|
/**
|
|
44
78
|
* After prompt submission, waits for the research plan to appear and
|
|
45
79
|
* auto-confirm (~60s countdown + 10s safety margin).
|
|
@@ -737,29 +771,56 @@ export function buildDeepResearchStatusExpressionForTest() {
|
|
|
737
771
|
export function buildDeepResearchCompletionPollExpressionForTest(minTurnIndex = -1) {
|
|
738
772
|
return buildDeepResearchCompletionPollExpression(minTurnIndex);
|
|
739
773
|
}
|
|
740
|
-
function
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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:
|
|
803
|
-
|
|
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
|
|
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 =
|
|
828
|
-
if (
|
|
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
|
|
839
|
-
|
|
840
|
-
let match =
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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": {
|
|
@@ -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, deps?.attachmentTimeoutMs);
|
|
169
|
+
const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
|
|
170
170
|
if (!clicked) {
|
|
171
171
|
await input.dispatchKeyEvent({
|
|
172
172
|
type: "keyDown",
|
|
@@ -575,7 +575,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
575
575
|
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
576
576
|
return buildAttachmentReadyExpression(attachmentNames);
|
|
577
577
|
}
|
|
578
|
-
async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
|
|
578
|
+
async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attachmentTimeoutMs) {
|
|
579
579
|
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
580
580
|
const script = `(() => {
|
|
581
581
|
${buildClickDispatcher()}
|
|
@@ -604,10 +604,15 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
|
|
|
604
604
|
candidates.push(...Array.from(document.querySelectorAll(selector)));
|
|
605
605
|
}
|
|
606
606
|
const button = candidates.find((node) => isVisible(node) && isEnabled(node)) || null;
|
|
607
|
-
if (!button) return 'missing';
|
|
608
|
-
|
|
607
|
+
if (!button) return { status: 'missing' };
|
|
608
|
+
button.scrollIntoView({ block: 'center', inline: 'center' });
|
|
609
|
+
const rect = button.getBoundingClientRect();
|
|
610
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
611
|
+
return { status: 'point', x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
612
|
+
}
|
|
613
|
+
// Last-resort fallback for unusual DOMs where the button is visible but has no useful rect.
|
|
609
614
|
dispatchClickSequence(button);
|
|
610
|
-
return 'clicked';
|
|
615
|
+
return { status: 'clicked' };
|
|
611
616
|
})()`;
|
|
612
617
|
// Give attachment-bearing submissions more headroom. ChatGPT's chip render can
|
|
613
618
|
// settle slowly for multi-file uploads, but plain text sends should keep the
|
|
@@ -626,10 +631,19 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
|
|
|
626
631
|
}
|
|
627
632
|
}
|
|
628
633
|
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
629
|
-
|
|
634
|
+
const value = result.value;
|
|
635
|
+
const status = typeof value === "string" ? value : value?.status;
|
|
636
|
+
if (status === "point" &&
|
|
637
|
+
typeof value === "object" &&
|
|
638
|
+
typeof value.x === "number" &&
|
|
639
|
+
typeof value.y === "number") {
|
|
640
|
+
await clickTrustedPoint(Runtime, Input, value.x, value.y);
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
if (status === "clicked") {
|
|
630
644
|
return true;
|
|
631
645
|
}
|
|
632
|
-
if (
|
|
646
|
+
if (status === "missing") {
|
|
633
647
|
break;
|
|
634
648
|
}
|
|
635
649
|
await delay(100);
|
|
@@ -644,6 +658,23 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
|
|
|
644
658
|
}
|
|
645
659
|
return false;
|
|
646
660
|
}
|
|
661
|
+
async function clickTrustedPoint(Runtime, Input, x, y) {
|
|
662
|
+
if (Input && typeof Input.dispatchMouseEvent === "function") {
|
|
663
|
+
await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
|
|
664
|
+
await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
665
|
+
await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
await Runtime.evaluate({
|
|
669
|
+
expression: `(() => {
|
|
670
|
+
const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
|
|
671
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
672
|
+
el.click();
|
|
673
|
+
return true;
|
|
674
|
+
})()`,
|
|
675
|
+
returnByValue: true,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
647
678
|
function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
|
|
648
679
|
if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
|
|
649
680
|
return 20_000;
|
|
@@ -755,9 +786,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
755
786
|
turnsCount: normalizedTurns.length,
|
|
756
787
|
};
|
|
757
788
|
})()`;
|
|
789
|
+
let lastProbe;
|
|
758
790
|
while (Date.now() < deadline) {
|
|
759
791
|
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
760
792
|
const info = result.value;
|
|
793
|
+
if (info && typeof info === "object") {
|
|
794
|
+
lastProbe = info;
|
|
795
|
+
}
|
|
761
796
|
const turnsCount = result.value?.turnsCount;
|
|
762
797
|
const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
|
|
763
798
|
const baselineUnknown = typeof info?.baseline === "number" ? info.baseline < 0 : baselineLiteral < 0;
|
|
@@ -772,13 +807,12 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
772
807
|
}
|
|
773
808
|
await delay(100);
|
|
774
809
|
}
|
|
810
|
+
const finalProbe = await Runtime.evaluate({ expression: script, returnByValue: true })
|
|
811
|
+
.then((res) => res?.result?.value)
|
|
812
|
+
.catch(() => undefined);
|
|
813
|
+
const probe = finalProbe && typeof finalProbe === "object" ? finalProbe : lastProbe;
|
|
775
814
|
if (logger) {
|
|
776
|
-
logger(`Prompt commit check failed; latest state: ${
|
|
777
|
-
expression: script,
|
|
778
|
-
returnByValue: true,
|
|
779
|
-
})
|
|
780
|
-
.then((res) => JSON.stringify(res?.result?.value))
|
|
781
|
-
.catch(() => "unavailable")}`);
|
|
815
|
+
logger(`Prompt commit check failed; latest state: ${probe ? JSON.stringify(probe) : "unavailable"}`);
|
|
782
816
|
await logDomFailure(Runtime, logger, "prompt-commit");
|
|
783
817
|
}
|
|
784
818
|
if (prompt.trim().length >= 50_000) {
|
|
@@ -789,7 +823,30 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
789
823
|
timeoutMs,
|
|
790
824
|
});
|
|
791
825
|
}
|
|
792
|
-
throw new
|
|
826
|
+
throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (send may have failed)", {
|
|
827
|
+
stage: "submit-prompt",
|
|
828
|
+
code: "prompt-commit-timeout",
|
|
829
|
+
promptLength: prompt.trim().length,
|
|
830
|
+
timeoutMs,
|
|
831
|
+
commitProbe: probe ? summarizeCommitProbe(probe) : undefined,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
// Keep booleans/counts but replace free text with lengths so session metadata stays lean.
|
|
835
|
+
function summarizeCommitProbe(probe) {
|
|
836
|
+
return {
|
|
837
|
+
baseline: probe.baseline,
|
|
838
|
+
turnsCount: probe.turnsCount,
|
|
839
|
+
userMatched: probe.userMatched,
|
|
840
|
+
prefixMatched: probe.prefixMatched,
|
|
841
|
+
lastMatched: probe.lastMatched,
|
|
842
|
+
hasNewTurn: probe.hasNewTurn,
|
|
843
|
+
stopVisible: probe.stopVisible,
|
|
844
|
+
assistantVisible: probe.assistantVisible,
|
|
845
|
+
composerCleared: probe.composerCleared,
|
|
846
|
+
inConversation: probe.inConversation,
|
|
847
|
+
editorLength: typeof probe.editorValue === "string" ? probe.editorValue.length : undefined,
|
|
848
|
+
lastTurnLength: typeof probe.lastTurn === "string" ? probe.lastTurn.length : undefined,
|
|
849
|
+
};
|
|
793
850
|
}
|
|
794
851
|
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
795
852
|
export const __test__ = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatElapsed } from "../../oracle/format.js";
|
|
2
|
-
import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "../constants.js";
|
|
2
|
+
import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
|
|
3
3
|
const THINKING_STALE_HINT_MS = 10 * 60_000;
|
|
4
4
|
export function startThinkingStatusMonitor(Runtime, logger, options = {}) {
|
|
5
5
|
const intervalMs = resolveThinkingStatusInterval(options.intervalMs);
|
|
@@ -127,6 +127,7 @@ async function readThinkingStatus(Runtime) {
|
|
|
127
127
|
}
|
|
128
128
|
const SAFE_THINKING_STATUS_MESSAGES = new Set([
|
|
129
129
|
"active",
|
|
130
|
+
"response streaming",
|
|
130
131
|
"thinking sidecar active",
|
|
131
132
|
"thinking sidecar opened",
|
|
132
133
|
]);
|
|
@@ -157,13 +158,16 @@ function buildThinkingStatusExpression() {
|
|
|
157
158
|
'[aria-live="polite"]',
|
|
158
159
|
];
|
|
159
160
|
const keywords = ["pro thinking", "thinking", "reasoning"];
|
|
161
|
+
const stopSelector = STOP_BUTTON_SELECTORS.join(", ");
|
|
160
162
|
const selectorLiteral = JSON.stringify(selectors);
|
|
161
163
|
const keywordsLiteral = JSON.stringify(keywords);
|
|
164
|
+
const stopSelectorLiteral = JSON.stringify(stopSelector);
|
|
162
165
|
return `(async () => {
|
|
163
166
|
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
164
167
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
165
168
|
const selectors = ${selectorLiteral};
|
|
166
169
|
const keywords = ${keywordsLiteral};
|
|
170
|
+
const stopSelector = ${stopSelectorLiteral};
|
|
167
171
|
const normalize = (value) =>
|
|
168
172
|
String(value || '')
|
|
169
173
|
.normalize('NFD')
|
|
@@ -383,6 +387,20 @@ function buildThinkingStatusExpression() {
|
|
|
383
387
|
};
|
|
384
388
|
}
|
|
385
389
|
}
|
|
390
|
+
// Last-resort liveness fallback: selector drift can hide every thinking
|
|
391
|
+
// indicator while a response is still generating, and returning null here
|
|
392
|
+
// reads as "dead" downstream. The stop/interrupt control is a stable,
|
|
393
|
+
// language-independent signal that generation is active; it lives in the
|
|
394
|
+
// composer, so isComposerAdjacent must not filter it.
|
|
395
|
+
const stopVisible = Array.from(document.querySelectorAll(stopSelector)).some((node) =>
|
|
396
|
+
isVisible(node),
|
|
397
|
+
);
|
|
398
|
+
if (stopVisible) {
|
|
399
|
+
return {
|
|
400
|
+
message: 'response streaming',
|
|
401
|
+
source: 'inline',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
386
404
|
return null;
|
|
387
405
|
})()`;
|
|
388
406
|
}
|