@steipete/oracle 0.11.1 → 0.12.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/README.md +55 -10
- package/dist/bin/oracle-cli.js +440 -98
- package/dist/src/browser/actions/modelSelection.js +74 -20
- package/dist/src/browser/actions/navigation.js +5 -3
- package/dist/src/browser/actions/promptComposer.js +76 -18
- package/dist/src/browser/actions/thinkingTime.js +133 -19
- package/dist/src/browser/constants.js +1 -1
- package/dist/src/browser/index.js +78 -9
- package/dist/src/browser/manualLoginProfile.js +54 -0
- package/dist/src/browser/projectSourcesRunner.js +16 -5
- package/dist/src/browser/prompt.js +56 -37
- package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
- package/dist/src/browser/reattachability.js +22 -0
- package/dist/src/browser/sessionRunner.js +73 -1
- package/dist/src/browser/utils.js +1 -47
- package/dist/src/browser/zipBundle.js +152 -0
- package/dist/src/cli/browserConfig.js +13 -11
- package/dist/src/cli/browserDefaults.js +2 -1
- package/dist/src/cli/docsCheck.js +186 -0
- package/dist/src/cli/engine.js +11 -4
- package/dist/src/cli/options.js +12 -6
- package/dist/src/cli/perfTrace.js +242 -0
- package/dist/src/cli/promptRequirement.js +2 -0
- package/dist/src/cli/providerDoctor.js +85 -0
- package/dist/src/cli/runOptions.js +46 -16
- package/dist/src/cli/sessionDisplay.js +47 -4
- package/dist/src/cli/sessionLifecycle.js +38 -0
- package/dist/src/cli/sessionRunner.js +272 -3
- package/dist/src/cli/sessionTable.js +2 -1
- package/dist/src/duration.js +47 -0
- package/dist/src/mcp/tools/consult.js +19 -3
- package/dist/src/mcp/types.js +1 -0
- package/dist/src/mcp/utils.js +4 -1
- package/dist/src/oracle/baseUrl.js +17 -0
- package/dist/src/oracle/client.js +1 -22
- package/dist/src/oracle/config.js +17 -4
- package/dist/src/oracle/gemini.js +2 -22
- package/dist/src/oracle/geminiModels.js +21 -0
- package/dist/src/oracle/modelResolver.js +7 -1
- package/dist/src/oracle/multiModelRunner.js +20 -2
- package/dist/src/oracle/providerFailures.js +204 -0
- package/dist/src/oracle/providerRoutePlan.js +308 -0
- package/dist/src/oracle/providerRouting.js +92 -0
- package/dist/src/oracle/run.js +104 -107
- package/dist/src/oracle.js +1 -0
- package/dist/src/remote/client.js +8 -0
- package/dist/src/remote/server.js +26 -0
- package/dist/src/sessionManager.js +43 -23
- package/package.json +15 -12
|
@@ -1,6 +1,8 @@
|
|
|
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
|
+
const LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
|
|
5
|
+
const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
|
|
4
6
|
export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
|
|
5
7
|
const outcome = await Runtime.evaluate({
|
|
6
8
|
expression: buildModelSelectionExpression(desiredModel, strategy),
|
|
@@ -17,7 +19,15 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
|
|
|
17
19
|
assertResolvedModelSelection(desiredModel, label);
|
|
18
20
|
}
|
|
19
21
|
logger(`Model picker: ${label}`);
|
|
20
|
-
return
|
|
22
|
+
return {
|
|
23
|
+
requestedModel: desiredModel,
|
|
24
|
+
resolvedLabel: label,
|
|
25
|
+
strategy,
|
|
26
|
+
status: result.status,
|
|
27
|
+
verified: strategy !== "current",
|
|
28
|
+
source: "chatgpt-model-picker",
|
|
29
|
+
capturedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
21
31
|
}
|
|
22
32
|
case "option-not-found": {
|
|
23
33
|
await logDomFailure(Runtime, logger, "model-switcher-option");
|
|
@@ -38,23 +48,35 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
|
|
|
38
48
|
function assertResolvedModelSelection(desiredModel, resolvedLabel) {
|
|
39
49
|
const desired = desiredModel.toLowerCase();
|
|
40
50
|
const resolved = resolvedLabel.toLowerCase();
|
|
41
|
-
const wantsGpt55Pro = desired === "
|
|
51
|
+
const wantsGpt55Pro = desired === "pro" ||
|
|
52
|
+
desired === "chatgpt pro" ||
|
|
53
|
+
desired === "gpt-5.5-pro" ||
|
|
42
54
|
desired.includes("5.5 pro") ||
|
|
43
55
|
desired.includes("5-5 pro") ||
|
|
44
56
|
(desired.includes("pro") && desired.includes("extended"));
|
|
45
57
|
if (!wantsGpt55Pro || !resolved) {
|
|
46
58
|
return;
|
|
47
59
|
}
|
|
48
|
-
|
|
49
|
-
resolved
|
|
50
|
-
resolved.includes("
|
|
51
|
-
resolved.includes("extended") ||
|
|
52
|
-
resolved.includes("gpt-5.5-pro") ||
|
|
53
|
-
resolved.includes("gpt 5 5 pro");
|
|
54
|
-
if (!hasProSignal || (resolved.includes("thinking") && !resolved.includes("pro"))) {
|
|
60
|
+
if (!hasCurrentProSignal(resolved) ||
|
|
61
|
+
hasLegacyProVersionLabel(resolved) ||
|
|
62
|
+
resolved.includes("thinking")) {
|
|
55
63
|
throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
|
|
56
64
|
}
|
|
57
65
|
}
|
|
66
|
+
function normalizeResolvedModelLabel(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
69
|
+
.replace(/\s+/g, " ")
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
function hasCurrentProSignal(resolved) {
|
|
73
|
+
return normalizeResolvedModelLabel(resolved).split(" ").includes("pro");
|
|
74
|
+
}
|
|
75
|
+
function hasLegacyProVersionLabel(resolved) {
|
|
76
|
+
const normalized = normalizeResolvedModelLabel(resolved);
|
|
77
|
+
return (LEGACY_PRO_VERSION_WORD_TOKENS.some((token) => normalized.includes(token)) ||
|
|
78
|
+
LEGACY_PRO_VERSION_COMPACT_TOKENS.some((token) => resolved.includes(token)));
|
|
79
|
+
}
|
|
58
80
|
export function assertResolvedModelSelectionForTest(desiredModel, resolvedLabel) {
|
|
59
81
|
assertResolvedModelSelection(desiredModel, resolvedLabel);
|
|
60
82
|
}
|
|
@@ -101,6 +123,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
101
123
|
.replace(/\\s+/g, ' ')
|
|
102
124
|
.trim();
|
|
103
125
|
};
|
|
126
|
+
const hasToken = (value, token) => normalizeText(value).split(' ').includes(token);
|
|
104
127
|
// Normalize every candidate token to keep fuzzy matching deterministic.
|
|
105
128
|
const normalizedTarget = normalizeText(PRIMARY_LABEL);
|
|
106
129
|
const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
|
|
@@ -121,8 +144,16 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
121
144
|
const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
|
|
122
145
|
const wantsInstant = normalizedTarget.includes('instant');
|
|
123
146
|
const wantsThinking = normalizedTarget.includes('thinking');
|
|
147
|
+
const targetUsesCurrentGpt55Alias =
|
|
148
|
+
desiredVersion === '5-5' || normalizedTarget === 'pro' || normalizedTarget === 'chatgpt pro';
|
|
149
|
+
const labelHasProWord = (label) => label === 'pro' || label.startsWith('pro ') || label.includes(' pro ') || label.endsWith(' pro');
|
|
150
|
+
const legacyProVersionTokens = ['5 4', '5 2', '5 1', '5 0', 'gpt54', 'gpt52', 'gpt51', 'gpt50', 'gpt 5 pro'];
|
|
151
|
+
const labelHasLegacyProVersion = (value) => {
|
|
152
|
+
const label = normalizeText(value);
|
|
153
|
+
return legacyProVersionTokens.some((token) => label.includes(token));
|
|
154
|
+
};
|
|
124
155
|
const isTargetGpt55VisibleAlias = (value) => {
|
|
125
|
-
if (
|
|
156
|
+
if (!targetUsesCurrentGpt55Alias) return false;
|
|
126
157
|
const label = normalizeText(value);
|
|
127
158
|
if (wantsPro) {
|
|
128
159
|
// ChatGPT UI as of 2026-05: the picker shows just "Pro" (no longer "Pro Extended").
|
|
@@ -138,7 +169,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
138
169
|
return false;
|
|
139
170
|
};
|
|
140
171
|
const hasProComposerPill = () => Boolean(
|
|
141
|
-
document.
|
|
172
|
+
Array.from(document.querySelectorAll('button.__composer-pill, button[aria-label]'))
|
|
173
|
+
.filter((node) => {
|
|
174
|
+
const label = normalizeText(node.getAttribute?.('aria-label') ?? '');
|
|
175
|
+
return node.matches?.('button.__composer-pill') || label.includes('click to remove');
|
|
176
|
+
})
|
|
177
|
+
.some((node) => {
|
|
178
|
+
const label = normalizeText(
|
|
179
|
+
(node.getAttribute?.('aria-label') ?? '') + ' ' + (node.textContent ?? '')
|
|
180
|
+
);
|
|
181
|
+
return hasToken(label, 'pro') && !hasToken(label, 'thinking');
|
|
182
|
+
})
|
|
142
183
|
);
|
|
143
184
|
|
|
144
185
|
const button = document.querySelector(BUTTON_SELECTOR);
|
|
@@ -174,7 +215,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
174
215
|
const resolved = label || '';
|
|
175
216
|
if (!wantsPro || !hasProComposerPill()) return resolved;
|
|
176
217
|
const normalized = normalizeText(resolved);
|
|
177
|
-
if (!normalized
|
|
218
|
+
if (!normalized) return resolved;
|
|
219
|
+
if (normalized.includes('thinking')) return 'Pro';
|
|
220
|
+
if (normalized.includes('pro')) return resolved;
|
|
178
221
|
return resolved + ' + Pro';
|
|
179
222
|
};
|
|
180
223
|
const getResolvedLabel = (fallback) =>
|
|
@@ -190,7 +233,15 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
190
233
|
const normalizedLabel = normalizeText(getButtonLabel());
|
|
191
234
|
if (!normalizedLabel) return false;
|
|
192
235
|
if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
|
|
193
|
-
if (
|
|
236
|
+
if (
|
|
237
|
+
wantsPro &&
|
|
238
|
+
hasProComposerPill() &&
|
|
239
|
+
(normalizedLabel === 'chatgpt' ||
|
|
240
|
+
normalizedLabel === 'extended' ||
|
|
241
|
+
normalizedLabel === 'standard' ||
|
|
242
|
+
normalizedLabel === 'heavy' ||
|
|
243
|
+
normalizedLabel === 'light')
|
|
244
|
+
) {
|
|
194
245
|
return true;
|
|
195
246
|
}
|
|
196
247
|
if (desiredVersion) {
|
|
@@ -200,7 +251,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
200
251
|
if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
|
|
201
252
|
if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
|
|
202
253
|
}
|
|
203
|
-
if (wantsPro &&
|
|
254
|
+
if (wantsPro && labelHasLegacyProVersion(normalizedLabel)) return false;
|
|
255
|
+
if (wantsPro && !labelHasProWord(normalizedLabel)) return false;
|
|
204
256
|
if (wantsInstant && !normalizedLabel.includes('instant')) return false;
|
|
205
257
|
if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
|
|
206
258
|
// Also reject if button has variants we DON'T want
|
|
@@ -218,6 +270,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
218
270
|
if (!signal) {
|
|
219
271
|
return COMPOSER_SIGNAL_ALLOW_BLANK;
|
|
220
272
|
}
|
|
273
|
+
if (wantsPro && labelHasLegacyProVersion(signal)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
221
276
|
if (COMPOSER_SIGNAL_EXCLUDES.some((token) => token && signal.includes(token))) {
|
|
222
277
|
return false;
|
|
223
278
|
}
|
|
@@ -356,15 +411,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
356
411
|
const candidateGpt55VisibleAlias = isTargetGpt55VisibleAlias(normalizedText);
|
|
357
412
|
const candidateHasThinking =
|
|
358
413
|
normalizedText.includes('thinking') || normalizedTestId.includes('thinking');
|
|
414
|
+
const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
|
|
359
415
|
const candidateHasPro =
|
|
360
416
|
candidateGpt55VisibleAlias ||
|
|
361
|
-
normalizedText
|
|
362
|
-
normalizedText.startsWith('pro ') ||
|
|
363
|
-
normalizedText.includes(' pro ') ||
|
|
364
|
-
normalizedText.endsWith(' pro') ||
|
|
417
|
+
labelHasProWord(normalizedText) ||
|
|
365
418
|
normalizedText.includes('proresearch') ||
|
|
366
419
|
normalizedTestId.includes('pro');
|
|
367
420
|
if (wantsPro && candidateHasThinking) return 0;
|
|
421
|
+
if (wantsPro && candidateHasLegacyProVersion) return 0;
|
|
368
422
|
if (wantsPro && !candidateHasPro) return 0;
|
|
369
423
|
if (wantsThinking && candidateHasPro) return 0;
|
|
370
424
|
if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
|
|
@@ -407,10 +461,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
407
461
|
}
|
|
408
462
|
// If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
|
|
409
463
|
if (wantsPro) {
|
|
410
|
-
if (!normalizedText
|
|
464
|
+
if (!labelHasProWord(normalizedText)) {
|
|
411
465
|
score -= 80;
|
|
412
466
|
}
|
|
413
|
-
} else if (normalizedText
|
|
467
|
+
} else if (labelHasProWord(normalizedText)) {
|
|
414
468
|
score -= 40;
|
|
415
469
|
}
|
|
416
470
|
// Similarly for Thinking variant
|
|
@@ -388,9 +388,8 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
388
388
|
if (!text) return false;
|
|
389
389
|
const normalized = text.toLowerCase().trim();
|
|
390
390
|
return (
|
|
391
|
-
['log in', 'login', 'sign in', 'signin', '
|
|
392
|
-
|
|
393
|
-
) ||
|
|
391
|
+
['log in', 'login', 'sign in', 'signin', 'sign up for free'].includes(normalized) ||
|
|
392
|
+
normalized.startsWith('continue with') ||
|
|
394
393
|
normalized.includes('get responses tailored to you') ||
|
|
395
394
|
normalized.includes('log in to get answers')
|
|
396
395
|
);
|
|
@@ -490,3 +489,6 @@ function normalizeLoginProbe(raw) {
|
|
|
490
489
|
onAuthPage: Boolean(value.onAuthPage),
|
|
491
490
|
};
|
|
492
491
|
}
|
|
492
|
+
export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
|
|
493
|
+
return buildLoginProbeExpression(timeoutMs);
|
|
494
|
+
}
|
|
@@ -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);
|
|
@@ -290,17 +291,35 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
290
291
|
document.querySelector('form') ||
|
|
291
292
|
document.body ||
|
|
292
293
|
document;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
294
|
+
// Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
|
|
295
|
+
// ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
|
|
296
|
+
// only the node's own textContent/aria/title misses the match.
|
|
297
|
+
const collectLabelHaystack = (node) => {
|
|
298
|
+
if (!node) return '';
|
|
299
|
+
const pieces = [];
|
|
300
|
+
const pushAttrs = (el) => {
|
|
301
|
+
if (!el || typeof el.getAttribute !== 'function') return;
|
|
302
|
+
for (const attr of ['aria-label', 'title', 'data-testid', 'data-tooltip', 'data-tooltip-content']) {
|
|
303
|
+
const v = el.getAttribute(attr);
|
|
304
|
+
if (v) pieces.push(v);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const pushText = (el) => {
|
|
308
|
+
if (!el) return;
|
|
309
|
+
const text = (el.innerText ?? el.textContent ?? '').trim();
|
|
310
|
+
if (text) pieces.push(text);
|
|
311
|
+
};
|
|
312
|
+
pushAttrs(node);
|
|
313
|
+
pushText(node);
|
|
314
|
+
const parent = node.parentElement;
|
|
315
|
+
pushAttrs(parent);
|
|
316
|
+
pushText(parent);
|
|
317
|
+
const grandparent = parent?.parentElement;
|
|
318
|
+
pushAttrs(grandparent);
|
|
319
|
+
pushText(grandparent);
|
|
320
|
+
return pieces.join(' ').toLowerCase();
|
|
321
|
+
};
|
|
322
|
+
const match = (node, name) => collectLabelHaystack(node).includes(name);
|
|
304
323
|
|
|
305
324
|
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
306
325
|
const attachmentSelectors = [
|
|
@@ -312,13 +331,32 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
312
331
|
'button[aria-label*="Remove file"]',
|
|
313
332
|
'[aria-label*="remove file"]',
|
|
314
333
|
'button[aria-label*="remove file"]',
|
|
334
|
+
'[aria-label*="Remove attachment"]',
|
|
335
|
+
'button[aria-label*="Remove attachment"]',
|
|
336
|
+
'[aria-label*="remove attachment"]',
|
|
337
|
+
'button[aria-label*="remove attachment"]',
|
|
315
338
|
];
|
|
316
|
-
const attachmentRoots = Array.from(new Set([composer
|
|
339
|
+
const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
|
|
340
|
+
const collectChipNodes = () => {
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
const collected = [];
|
|
343
|
+
for (const root of attachmentRoots) {
|
|
344
|
+
for (const node of Array.from(root.querySelectorAll(attachmentSelectors.join(',')))) {
|
|
345
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
346
|
+
// Skip elements clearly inside the editable input (composer textarea may contain
|
|
347
|
+
// filename text in the user's prompt — avoid mistaking that for a chip).
|
|
348
|
+
if (node.closest('textarea,[contenteditable="true"]')) continue;
|
|
349
|
+
if (seen.has(node)) continue;
|
|
350
|
+
seen.add(node);
|
|
351
|
+
collected.push(node);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return collected;
|
|
355
|
+
};
|
|
356
|
+
const chipNodes = collectChipNodes();
|
|
317
357
|
|
|
318
358
|
const chipsReady = names.every((name) =>
|
|
319
|
-
|
|
320
|
-
Array.from(root.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
|
|
321
|
-
),
|
|
359
|
+
chipNodes.some((node) => match(node, name)),
|
|
322
360
|
);
|
|
323
361
|
const inputsReady = names.every((name) =>
|
|
324
362
|
attachmentRoots.some((root) =>
|
|
@@ -329,14 +367,28 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
329
367
|
),
|
|
330
368
|
),
|
|
331
369
|
);
|
|
370
|
+
// Count-based fallback: if we cannot match names individually (ChatGPT may strip
|
|
371
|
+
// the filename out of attribute-readable text into a deeply nested span), but we
|
|
372
|
+
// do see at least as many distinct chip-shaped nodes as attachments we uploaded,
|
|
373
|
+
// and a sibling "Remove" affordance exists per chip, trust the upload.
|
|
374
|
+
const removeAffordanceCount = chipNodes.filter((node) => {
|
|
375
|
+
const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
|
|
376
|
+
if (aria.includes('remove')) return true;
|
|
377
|
+
const removeSibling = node.querySelector?.(
|
|
378
|
+
'[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
|
|
379
|
+
);
|
|
380
|
+
return Boolean(removeSibling);
|
|
381
|
+
}).length;
|
|
382
|
+
const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
|
|
332
383
|
|
|
333
|
-
return chipsReady || inputsReady;
|
|
384
|
+
return chipsReady || inputsReady || countReady;
|
|
334
385
|
})()`;
|
|
335
386
|
}
|
|
336
387
|
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
337
388
|
return buildAttachmentReadyExpression(attachmentNames);
|
|
338
389
|
}
|
|
339
390
|
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
391
|
+
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
340
392
|
const script = `(() => {
|
|
341
393
|
${buildClickDispatcher()}
|
|
342
394
|
const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
@@ -369,9 +421,11 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
369
421
|
dispatchClickSequence(button);
|
|
370
422
|
return 'clicked';
|
|
371
423
|
})()`;
|
|
372
|
-
|
|
424
|
+
// Give attachment-bearing submissions more headroom. ChatGPT's chip render can
|
|
425
|
+
// settle slowly for multi-file uploads, but plain text sends should keep the
|
|
426
|
+
// shorter historical deadline.
|
|
427
|
+
const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
|
|
373
428
|
while (Date.now() < deadline) {
|
|
374
|
-
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
375
429
|
if (needAttachment) {
|
|
376
430
|
const ready = await Runtime.evaluate({
|
|
377
431
|
expression: buildAttachmentReadyExpression(attachmentNames),
|
|
@@ -400,6 +454,9 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
400
454
|
}
|
|
401
455
|
return false;
|
|
402
456
|
}
|
|
457
|
+
function sendButtonTimeoutMs(attachmentNames) {
|
|
458
|
+
return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
|
|
459
|
+
}
|
|
403
460
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
|
|
404
461
|
const deadline = Date.now() + timeoutMs;
|
|
405
462
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
@@ -542,5 +599,6 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
542
599
|
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
543
600
|
export const __test__ = {
|
|
544
601
|
attemptSendButton,
|
|
602
|
+
sendButtonTimeoutMs,
|
|
545
603
|
verifyPromptCommitted,
|
|
546
604
|
};
|
|
@@ -11,9 +11,11 @@ import { buildClickDispatcher } from "./domEvents.js";
|
|
|
11
11
|
*
|
|
12
12
|
* @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
|
|
13
13
|
*/
|
|
14
|
-
export async function ensureThinkingTime(Runtime, level, logger) {
|
|
15
|
-
const result = await evaluateThinkingTimeSelection(Runtime, level);
|
|
14
|
+
export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
|
|
15
|
+
const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
|
|
16
16
|
const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
|
|
17
|
+
const targetModelKind = inferThinkingTargetModelKind(desiredModel);
|
|
18
|
+
const strictProEffort = targetModelKind === "pro" && level === "extended";
|
|
17
19
|
switch (result?.status) {
|
|
18
20
|
case "already-selected":
|
|
19
21
|
logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
|
|
@@ -23,13 +25,26 @@ export async function ensureThinkingTime(Runtime, level, logger) {
|
|
|
23
25
|
return;
|
|
24
26
|
case "chip-not-found":
|
|
25
27
|
case "menu-not-found":
|
|
26
|
-
case "option-not-found":
|
|
28
|
+
case "option-not-found":
|
|
29
|
+
case "model-kind-not-found": {
|
|
27
30
|
await logDomFailure(Runtime, logger, `thinking-${result.status}`);
|
|
28
|
-
|
|
31
|
+
const kindHint = result.status === "model-kind-not-found" && result.modelKind
|
|
32
|
+
? ` for ${result.modelKind}`
|
|
33
|
+
: targetModelKind
|
|
34
|
+
? ` for ${targetModelKind}`
|
|
35
|
+
: "";
|
|
36
|
+
const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`;
|
|
37
|
+
if (strictProEffort) {
|
|
38
|
+
throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
|
|
39
|
+
}
|
|
40
|
+
logger(`${message}; continuing with ChatGPT default.`);
|
|
29
41
|
return;
|
|
30
42
|
}
|
|
31
43
|
default: {
|
|
32
44
|
await logDomFailure(Runtime, logger, "thinking-time-unknown");
|
|
45
|
+
if (strictProEffort) {
|
|
46
|
+
throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
|
|
47
|
+
}
|
|
33
48
|
logger(`Thinking time: unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`);
|
|
34
49
|
return;
|
|
35
50
|
}
|
|
@@ -40,9 +55,9 @@ export async function ensureThinkingTime(Runtime, level, logger) {
|
|
|
40
55
|
* Safe by default: if the pill/menu/option isn't present, we continue without throwing.
|
|
41
56
|
* @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
|
|
42
57
|
*/
|
|
43
|
-
export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
|
|
58
|
+
export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) {
|
|
44
59
|
try {
|
|
45
|
-
const result = await evaluateThinkingTimeSelection(Runtime, level);
|
|
60
|
+
const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
|
|
46
61
|
const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
|
|
47
62
|
switch (result?.status) {
|
|
48
63
|
case "already-selected":
|
|
@@ -54,6 +69,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
|
|
|
54
69
|
case "chip-not-found":
|
|
55
70
|
case "menu-not-found":
|
|
56
71
|
case "option-not-found":
|
|
72
|
+
case "model-kind-not-found":
|
|
57
73
|
if (logger.verbose) {
|
|
58
74
|
logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
|
|
59
75
|
}
|
|
@@ -74,19 +90,20 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
|
|
|
74
90
|
return false;
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
|
-
async function evaluateThinkingTimeSelection(Runtime, level) {
|
|
93
|
+
async function evaluateThinkingTimeSelection(Runtime, level, desiredModel) {
|
|
78
94
|
const outcome = await Runtime.evaluate({
|
|
79
|
-
expression: buildThinkingTimeExpression(level),
|
|
95
|
+
expression: buildThinkingTimeExpression(level, desiredModel),
|
|
80
96
|
awaitPromise: true,
|
|
81
97
|
returnByValue: true,
|
|
82
98
|
});
|
|
83
99
|
return outcome.result?.value;
|
|
84
100
|
}
|
|
85
|
-
function buildThinkingTimeExpression(level) {
|
|
101
|
+
function buildThinkingTimeExpression(level, desiredModel) {
|
|
86
102
|
const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
|
|
87
103
|
const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
|
|
88
104
|
const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
|
|
89
105
|
const targetLevelLiteral = JSON.stringify(level.toLowerCase());
|
|
106
|
+
const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
|
|
90
107
|
return `(async () => {
|
|
91
108
|
${buildClickDispatcher()}
|
|
92
109
|
|
|
@@ -94,6 +111,7 @@ function buildThinkingTimeExpression(level) {
|
|
|
94
111
|
const MENU_ITEM_SELECTOR = ${menuItemLiteral};
|
|
95
112
|
const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
|
|
96
113
|
const TARGET_LEVEL = ${targetLevelLiteral};
|
|
114
|
+
const TARGET_MODEL_KIND = ${targetModelKindLiteral};
|
|
97
115
|
|
|
98
116
|
// Bilingual matchers: English level token + observed Chinese variants.
|
|
99
117
|
const LEVEL_TOKENS = {
|
|
@@ -119,6 +137,7 @@ function buildThinkingTimeExpression(level) {
|
|
|
119
137
|
const t = normalize(text);
|
|
120
138
|
return targetTokens.some((tok) => t.includes(String(tok).toLowerCase()));
|
|
121
139
|
};
|
|
140
|
+
const hasToken = (text, token) => normalize(text).split(' ').includes(token);
|
|
122
141
|
const optionIsSelected = (node) => {
|
|
123
142
|
if (!(node instanceof HTMLElement)) return false;
|
|
124
143
|
const ariaChecked = node.getAttribute('aria-checked');
|
|
@@ -207,28 +226,99 @@ function buildThinkingTimeExpression(level) {
|
|
|
207
226
|
|
|
208
227
|
const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
|
|
209
228
|
const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
|
|
229
|
+
const KIND_NOT_FOUND = { kindNotFound: true };
|
|
230
|
+
const findEffortRow = (node) => {
|
|
231
|
+
let current = node instanceof HTMLElement ? node.parentElement : null;
|
|
232
|
+
while (current && current !== document.body) {
|
|
233
|
+
if (current.getAttribute?.('data-model-picker-thinking-effort-row') === 'true') {
|
|
234
|
+
return current;
|
|
235
|
+
}
|
|
236
|
+
current = current.parentElement;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
};
|
|
240
|
+
const rowIsSelected = (row) => {
|
|
241
|
+
if (!(row instanceof HTMLElement)) return false;
|
|
242
|
+
const modelItem = row.querySelector('[data-model-picker-thinking-effort-menu-item="true"], [role="menuitemradio"]');
|
|
243
|
+
if (optionIsSelected(modelItem)) return true;
|
|
244
|
+
return Boolean(
|
|
245
|
+
row.querySelector(
|
|
246
|
+
'[aria-checked="true"], [aria-selected="true"], [aria-current="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
const rowForTrailing = (trailing) =>
|
|
251
|
+
trailing.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
|
|
252
|
+
const rowTextForTrailing = (trailing) => {
|
|
253
|
+
const row = rowForTrailing(trailing) || findEffortRow(trailing);
|
|
254
|
+
return normalize(
|
|
255
|
+
(row?.getAttribute?.('aria-label') ?? '') + ' ' +
|
|
256
|
+
(row?.getAttribute?.('data-testid') ?? '') + ' ' +
|
|
257
|
+
(row?.textContent ?? '') + ' ' +
|
|
258
|
+
(trailing.getAttribute?.('aria-label') ?? '') + ' ' +
|
|
259
|
+
(trailing.getAttribute?.('data-testid') ?? '')
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
const testIdTextForTrailing = (trailing) => {
|
|
263
|
+
const row = rowForTrailing(trailing) || findEffortRow(trailing);
|
|
264
|
+
return normalize(
|
|
265
|
+
(row?.getAttribute?.('data-testid') ?? '') + ' ' +
|
|
266
|
+
(trailing.getAttribute?.('data-testid') ?? '')
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
const modelKindFromTrailing = (trailing) => {
|
|
270
|
+
const idText = testIdTextForTrailing(trailing);
|
|
271
|
+
if (!idText.includes('model switcher')) return null;
|
|
272
|
+
const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
|
|
273
|
+
if (hasToken(modelPart, 'pro')) return 'pro';
|
|
274
|
+
if (hasToken(modelPart, 'thinking')) return 'thinking';
|
|
275
|
+
if (hasToken(modelPart, 'instant')) return 'instant';
|
|
276
|
+
return null;
|
|
277
|
+
};
|
|
278
|
+
const trailingMatchesTargetModelKind = (trailing) => {
|
|
279
|
+
if (!TARGET_MODEL_KIND) return false;
|
|
280
|
+
const idKind = modelKindFromTrailing(trailing);
|
|
281
|
+
if (idKind) return idKind === TARGET_MODEL_KIND;
|
|
282
|
+
const text = rowTextForTrailing(trailing);
|
|
283
|
+
if (TARGET_MODEL_KIND === 'pro') {
|
|
284
|
+
return hasToken(text, 'pro') && !hasToken(text, 'thinking');
|
|
285
|
+
}
|
|
286
|
+
if (TARGET_MODEL_KIND === 'thinking') {
|
|
287
|
+
return hasToken(text, 'thinking') && !hasToken(text, 'pro');
|
|
288
|
+
}
|
|
289
|
+
if (TARGET_MODEL_KIND === 'instant') {
|
|
290
|
+
return hasToken(text, 'instant') && !hasToken(text, 'thinking') && !hasToken(text, 'pro');
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
};
|
|
294
|
+
const hasStableBox = (node) => {
|
|
295
|
+
const r = node.getBoundingClientRect?.();
|
|
296
|
+
return Boolean(r && r.width > 0 && r.height > 0 && node.getAttribute?.('aria-hidden') !== 'true');
|
|
297
|
+
};
|
|
298
|
+
const pickSingleStableTrailing = (trailings) => {
|
|
299
|
+
const visible = trailings.filter((t) => hasStableBox(t));
|
|
300
|
+
return visible.length === 1 ? visible[0] : null;
|
|
301
|
+
};
|
|
210
302
|
const pickTrailingForCurrentModel = () => {
|
|
211
303
|
const trailings = findTrailingButtons();
|
|
212
304
|
if (trailings.length === 0) return null;
|
|
213
305
|
if (trailings.length === 1) return trailings[0];
|
|
214
306
|
// Prefer the trailing button whose model row is currently selected.
|
|
215
307
|
for (const t of trailings) {
|
|
216
|
-
const row = t
|
|
217
|
-
if (
|
|
308
|
+
const row = findEffortRow(t);
|
|
309
|
+
if (rowIsSelected(row)) return t;
|
|
218
310
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (r && r.width > 0 && r.height > 0) return t;
|
|
311
|
+
if (TARGET_MODEL_KIND) {
|
|
312
|
+
const targetTrailings = trailings.filter((t) => trailingMatchesTargetModelKind(t));
|
|
313
|
+
return pickSingleStableTrailing(targetTrailings) || KIND_NOT_FOUND;
|
|
223
314
|
}
|
|
224
|
-
return
|
|
315
|
+
return null;
|
|
225
316
|
};
|
|
226
317
|
|
|
227
318
|
const modelBtn = findModelButton();
|
|
228
319
|
if (!modelBtn) {
|
|
229
320
|
return { status: 'chip-not-found' };
|
|
230
321
|
}
|
|
231
|
-
|
|
232
322
|
// Open model menu (idempotent — leaves it open if already open).
|
|
233
323
|
if (modelBtn.getAttribute('aria-expanded') !== 'true') {
|
|
234
324
|
dispatchClickSequence(modelBtn);
|
|
@@ -246,6 +336,10 @@ function buildThinkingTimeExpression(level) {
|
|
|
246
336
|
closeOpenMenus();
|
|
247
337
|
return { status: 'chip-not-found' };
|
|
248
338
|
}
|
|
339
|
+
if (trailing.kindNotFound) {
|
|
340
|
+
closeOpenMenus();
|
|
341
|
+
return { status: 'model-kind-not-found', modelKind: TARGET_MODEL_KIND };
|
|
342
|
+
}
|
|
249
343
|
|
|
250
344
|
dispatchClickSequence(trailing);
|
|
251
345
|
await sleep(STEP_WAIT_MS);
|
|
@@ -298,6 +392,26 @@ function buildThinkingTimeExpression(level) {
|
|
|
298
392
|
return { status: already ? 'already-selected' : 'switched', label };
|
|
299
393
|
})()`;
|
|
300
394
|
}
|
|
301
|
-
export function buildThinkingTimeExpressionForTest(level = "extended") {
|
|
302
|
-
return buildThinkingTimeExpression(level);
|
|
395
|
+
export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) {
|
|
396
|
+
return buildThinkingTimeExpression(level, desiredModel);
|
|
397
|
+
}
|
|
398
|
+
function inferThinkingTargetModelKind(desiredModel) {
|
|
399
|
+
const normalized = (desiredModel ?? "")
|
|
400
|
+
.toLowerCase()
|
|
401
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
402
|
+
.replace(/\s+/g, " ")
|
|
403
|
+
.trim();
|
|
404
|
+
if (!normalized)
|
|
405
|
+
return null;
|
|
406
|
+
const tokens = normalized.split(" ");
|
|
407
|
+
if (tokens.includes("pro"))
|
|
408
|
+
return "pro";
|
|
409
|
+
if (tokens.includes("thinking"))
|
|
410
|
+
return "thinking";
|
|
411
|
+
if (tokens.includes("instant"))
|
|
412
|
+
return "instant";
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
export function inferThinkingTargetModelKindForTest(desiredModel) {
|
|
416
|
+
return inferThinkingTargetModelKind(desiredModel);
|
|
303
417
|
}
|