@steipete/oracle 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/bin/oracle-cli.js +2 -0
  2. package/dist/bin/oracle.js +569 -0
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +400 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +452 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +387 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/deepResearch.js +132 -61
  39. package/dist/src/browser/actions/modelSelection.js +45 -2
  40. package/dist/src/browser/actions/thinkingTime.js +65 -20
  41. package/dist/src/browser/artifacts.js +2 -8
  42. package/dist/src/browser/chatgptFiles.js +198 -49
  43. package/dist/src/browser/chromeCookies.js +312 -0
  44. package/dist/src/browser/chromeLifecycle.js +35 -4
  45. package/dist/src/browser/deepResearchResult.js +23 -0
  46. package/dist/src/browser/index.js +82 -11
  47. package/dist/src/browser/keytarShim.js +56 -0
  48. package/dist/src/browser/profileCopy.js +93 -0
  49. package/dist/src/browser/windowsCookies.js +219 -0
  50. package/dist/src/cli/browserConfig.js +17 -1
  51. package/dist/src/cli/sessionRunner.js +13 -7
  52. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  53. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  54. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  55. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  56. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  57. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  58. package/package.json +33 -31
  59. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  60. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  61. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  62. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  64. package/vendor/oracle-notifier/README.md +26 -0
  65. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -1,5 +1,6 @@
1
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";
2
2
  import { delay } from "../utils.js";
3
+ import { isDeepResearchIncompleteText } from "../deepResearchResult.js";
3
4
  import { buildClickDispatcher } from "./domEvents.js";
4
5
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./assistantResponse.js";
5
6
  import { BrowserAutomationError } from "../../oracle/errors.js";
@@ -116,7 +117,10 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
116
117
  : -1;
117
118
  const scopedToNewTurns = minTurnLiteral >= 0;
118
119
  const ignoredTargetKeys = new Set(options?.ignoredTargetKeys ?? []);
119
- const requireScopedTargetOwner = options?.requireScopedTargetOwner === true;
120
+ const requireScopedTargetOwner = options?.requireScopedTargetOwner === true ||
121
+ (scopedToNewTurns && options?.targetBaselineCaptured !== true);
122
+ let observedResearchEvidence = false;
123
+ let loggedIncompleteResult = false;
120
124
  logger(`Monitoring Deep Research (timeout: ${Math.round(timeoutMs / 60_000)}min)...`);
121
125
  while (Date.now() - start < timeoutMs) {
122
126
  const { result } = await Runtime.evaluate({
@@ -127,7 +131,6 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
127
131
  if (val?.accountBlocked) {
128
132
  throw new BrowserAutomationError("ChatGPT account security block detected during Deep Research. Open chatgpt.com in Chrome, secure the account, then rerun Oracle.", { stage: "chatgpt-account-blocked", code: "chatgpt-account-blocked" });
129
133
  }
130
- const activeScopedResearch = Boolean(val?.hasActiveScopedResearch);
131
134
  // ChatGPT renders the Deep Research report inside an out-of-process,
132
135
  // sandboxed iframe (connector_openai_deep_research.*.oaiusercontent.com),
133
136
  // doubly nested and same-origin. That OOPIF does NOT appear in the main
@@ -136,24 +139,27 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
136
139
  // (readDeepResearchTargetResult) attaches to the iframe's own CDP target and
137
140
  // walks its nested frames, so it CAN read the report. Prefer the target path
138
141
  // and fall back to the in-page frame path for legacy/inline rendering.
139
- const targetResult = client
142
+ const rawTargetResult = client
140
143
  ? ((await readDeepResearchTargetResult(client, ignoredTargetKeys, requireScopedTargetOwner ? minTurnLiteral : -1).catch(() => null))?.read ?? null)
141
144
  : null;
145
+ const targetResult = filterIncompleteDeepResearchRead(rawTargetResult);
142
146
  // A completed target read is authoritative. If the target read is missing or
143
147
  // only in-progress, still try the in-page frame path so an incomplete target
144
148
  // read does not suppress a completed report there (legacy/inline rendering).
145
- const inPageResult = !targetResult?.completed && Page
146
- ? await readDeepResearchFrameResult(Runtime, Page).catch(() => null)
149
+ const inPageScan = !targetResult?.completed && Page
150
+ ? await readDeepResearchFrameResult(Runtime, Page, client, scopedToNewTurns ? minTurnLiteral : -1).catch(() => null)
147
151
  : null;
152
+ const rawInPageResult = inPageScan?.read ?? null;
153
+ const inPageResult = filterIncompleteDeepResearchRead(rawInPageResult);
148
154
  const read = pickPreferredDeepResearchRead(targetResult, inPageResult);
149
- // A target-confirmed completion read the live connector iframe directly, so
150
- // it is authoritative even when the main DOM exposes no assistant turn (the
151
- // report lives entirely in the OOPIF). The main-DOM hasActiveScopedResearch
152
- // heuristic no longer holds in that case, so don't gate on it.
153
- const completedFromTarget = Boolean(targetResult?.completed);
154
- if (read?.completed &&
155
- read.text &&
156
- (completedFromTarget || !scopedToNewTurns || activeScopedResearch)) {
155
+ // Target keys captured before submission are ignored, so a target result is
156
+ // tied to this run. Main-page iframes are not: old reports can remain in the
157
+ // conversation and must never authorize a new normal-response fallback.
158
+ observedResearchEvidence ||= Boolean(rawTargetResult ||
159
+ (scopedToNewTurns && rawInPageResult) ||
160
+ val?.researchActivity ||
161
+ val?.hasActiveScopedResearch);
162
+ if (read?.completed && read.text) {
157
163
  logger(`Deep Research completed (${Math.round((Date.now() - start) / 1000)}s elapsed)`);
158
164
  return {
159
165
  text: read.text,
@@ -163,9 +169,18 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
163
169
  }
164
170
  // Completion detected
165
171
  if (val?.finished) {
172
+ if (!observedResearchEvidence) {
173
+ throw new BrowserAutomationError("ChatGPT returned a completed response without starting Deep Research. The Deep Research selection may have silently fallen back to a normal response.", { stage: "deep-research-not-started", code: "deep-research-not-started" });
174
+ }
166
175
  logger(`Deep Research completed (${Math.round((Date.now() - start) / 1000)}s elapsed)`);
167
176
  return await extractDeepResearchResult(Runtime, logger, minTurnIndex ?? undefined);
168
177
  }
178
+ const incompleteFrameResult = Boolean((rawTargetResult?.completed && !targetResult?.completed) ||
179
+ (rawInPageResult?.completed && !inPageResult?.completed));
180
+ if ((val?.incompleteResult || incompleteFrameResult) && !loggedIncompleteResult) {
181
+ logger("Deep Research interim status detected; waiting for the final report");
182
+ loggedIncompleteResult = true;
183
+ }
169
184
  // Progress logging every 60 seconds
170
185
  const now = Date.now();
171
186
  if (now - lastLogTime >= 60_000) {
@@ -204,24 +219,26 @@ export async function extractDeepResearchResult(Runtime, logger, minTurnIndex) {
204
219
  };
205
220
  // Try the copy-button approach first for clean markdown
206
221
  const markdown = await captureAssistantMarkdown(Runtime, meta, logger);
207
- if (markdown && !isDeepResearchPlaceholderText(markdown)) {
222
+ if (markdown && !isDeepResearchIncompleteText(markdown)) {
208
223
  return { text: markdown, html: snapshot?.html ?? undefined, meta };
209
224
  }
210
225
  // Fall back to snapshot text
211
- if (snapshot?.text && !isDeepResearchPlaceholderText(snapshot.text)) {
226
+ if (snapshot?.text && !isDeepResearchIncompleteText(snapshot.text)) {
212
227
  return { text: snapshot.text, html: snapshot.html ?? undefined, meta };
213
228
  }
214
229
  throw new BrowserAutomationError("Deep Research completed but failed to extract the response text.", { stage: "deep-research-extract", code: "extraction-failed" });
215
230
  }
216
- function isDeepResearchPlaceholderText(text) {
217
- const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
218
- return (normalized === "called tool" ||
219
- normalized === "used tool" ||
220
- normalized === "użyto narzędzia" ||
221
- normalized === "narzędzie wywołane");
222
- }
223
231
  export function isDeepResearchPlaceholderTextForTest(text) {
224
- return isDeepResearchPlaceholderText(text);
232
+ return isDeepResearchIncompleteText(text);
233
+ }
234
+ function filterIncompleteDeepResearchRead(result) {
235
+ if (!result?.completed || !result.text || !isDeepResearchIncompleteText(result.text)) {
236
+ return result;
237
+ }
238
+ return { ...result, completed: false, inProgress: true };
239
+ }
240
+ export function filterIncompleteDeepResearchReadForTest(result) {
241
+ return filterIncompleteDeepResearchRead(result);
225
242
  }
226
243
  /**
227
244
  * Choose the authoritative Deep Research read between the target-attach result
@@ -243,37 +260,64 @@ function pickPreferredDeepResearchRead(targetResult, inPageResult) {
243
260
  export function pickPreferredDeepResearchReadForTest(targetResult, inPageResult) {
244
261
  return pickPreferredDeepResearchRead(targetResult, inPageResult);
245
262
  }
246
- async function readDeepResearchFrameResult(Runtime, Page) {
263
+ async function readDeepResearchFrameResult(Runtime, Page, client, minTurnIndex = -1) {
247
264
  const pageWithFrames = Page;
248
265
  if (typeof pageWithFrames.getFrameTree !== "function" ||
249
266
  typeof pageWithFrames.createIsolatedWorld !== "function") {
250
267
  return null;
251
268
  }
252
269
  const frameTree = (await pageWithFrames.getFrameTree())?.frameTree;
253
- const frameId = findDeepResearchFrameId(frameTree);
254
- if (!frameId) {
270
+ const frameIds = collectPageDeepResearchFrameIds(frameTree);
271
+ if (frameIds.length === 0) {
255
272
  return null;
256
273
  }
257
- const world = await pageWithFrames.createIsolatedWorld({
258
- frameId,
259
- worldName: "oracle-deep-research",
260
- grantUniveralAccess: true,
261
- });
262
- if (typeof world.executionContextId !== "number") {
263
- return null;
274
+ const rawClient = client;
275
+ if (minTurnIndex >= 0) {
276
+ if (typeof rawClient?.send !== "function") {
277
+ return null;
278
+ }
264
279
  }
265
- const { result } = await Runtime.evaluate({
266
- expression: buildDeepResearchFrameStatusExpression(),
267
- contextId: world.executionContextId,
268
- returnByValue: true,
269
- });
270
- return result?.value ?? null;
280
+ let best = null;
281
+ for (const frameId of frameIds) {
282
+ let ownerTurnIndex = null;
283
+ if (minTurnIndex >= 0 && rawClient?.send) {
284
+ ownerTurnIndex = await readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, rawClient.oraclePageSessionId);
285
+ if (ownerTurnIndex === null || ownerTurnIndex < minTurnIndex) {
286
+ continue;
287
+ }
288
+ }
289
+ const world = await pageWithFrames.createIsolatedWorld({
290
+ frameId,
291
+ worldName: "oracle-deep-research",
292
+ grantUniveralAccess: true,
293
+ });
294
+ if (typeof world.executionContextId !== "number") {
295
+ continue;
296
+ }
297
+ const { result } = await Runtime.evaluate({
298
+ expression: buildDeepResearchFrameStatusExpression(),
299
+ contextId: world.executionContextId,
300
+ returnByValue: true,
301
+ });
302
+ const read = result?.value ?? null;
303
+ if (!read) {
304
+ continue;
305
+ }
306
+ best = { read, ownerTurnIndex };
307
+ if (read.completed) {
308
+ return best;
309
+ }
310
+ }
311
+ return best;
271
312
  }
272
313
  async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(), minTurnIndex = -1) {
273
314
  const rawClient = client;
274
315
  if (typeof rawClient.send !== "function") {
275
316
  return null;
276
317
  }
318
+ if (typeof client.on !== "function") {
319
+ return null;
320
+ }
277
321
  // On the browser-WSEndpoint path, `client` is a session-bound wrapper whose
278
322
  // domain methods target the page session but whose raw `send` is the
279
323
  // browser-level send. We must therefore pass the page session id explicitly so
@@ -299,7 +343,7 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
299
343
  ownedSessionIds.add(eventSessionId);
300
344
  }
301
345
  };
302
- client.on?.("Target.attachedToTarget", onAttached);
346
+ client.on("Target.attachedToTarget", onAttached);
303
347
  try {
304
348
  // Scope discovery to the current Oracle-controlled page. `client` is
305
349
  // connected to the conversation page target, so enabling auto-attach on this
@@ -311,13 +355,16 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
311
355
  // would surface another tab's completed Deep Research report and let it be
312
356
  // saved into the current session (cross-tab leak). Only auto-attached,
313
357
  // page-scoped sessions are treated as belonging to this run.
314
- await rawClient
358
+ const autoAttachEnabled = await rawClient
315
359
  .send("Target.setAutoAttach", {
316
360
  autoAttach: true,
317
361
  waitForDebuggerOnStart: false,
318
362
  flatten: true,
319
363
  }, pageSessionId)
320
- .catch(() => undefined);
364
+ .then(() => true, () => false);
365
+ if (!autoAttachEnabled) {
366
+ return null;
367
+ }
321
368
  await delay(100);
322
369
  if (minTurnIndex >= 0) {
323
370
  await rawClient.send("DOM.enable", {}, pageSessionId).catch(() => undefined);
@@ -374,7 +421,11 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
374
421
  }
375
422
  }
376
423
  export async function captureDeepResearchTargetKeys(client) {
377
- return (await readDeepResearchTargetResult(client))?.targetKeys ?? [];
424
+ const scan = await readDeepResearchTargetResult(client);
425
+ if (!scan) {
426
+ throw new Error("Deep Research target baseline capture unavailable");
427
+ }
428
+ return scan.targetKeys;
378
429
  }
379
430
  async function readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, pageSessionId) {
380
431
  const owner = (await rawClient
@@ -419,7 +470,7 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
419
470
  const frameTree = (await rawClient
420
471
  .send("Page.getFrameTree", {}, sessionId)
421
472
  .catch(() => null));
422
- const frameId = frameTree?.frameTree?.frame?.id;
473
+ const ownerFrameId = frameTree?.frameTree?.frame?.id;
423
474
  if (!isConfirmedDeepResearchTarget(targetUrl, frameTree?.frameTree)) {
424
475
  return { confirmed: false, read: null };
425
476
  }
@@ -438,7 +489,7 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
438
489
  }
439
490
  const value = await evaluateDeepResearchFrameStatus(rawClient, sessionId, world.executionContextId);
440
491
  if (value?.completed) {
441
- return { confirmed: true, read: value, frameId };
492
+ return { confirmed: true, read: value, frameId: ownerFrameId };
442
493
  }
443
494
  if ((value?.textLength ?? 0) > (best?.textLength ?? 0) || value?.inProgress) {
444
495
  best = value;
@@ -446,12 +497,12 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
446
497
  }
447
498
  const topFrameValue = await evaluateDeepResearchFrameStatus(rawClient, sessionId);
448
499
  if (topFrameValue?.completed) {
449
- return { confirmed: true, read: topFrameValue, frameId };
500
+ return { confirmed: true, read: topFrameValue, frameId: ownerFrameId };
450
501
  }
451
502
  if ((topFrameValue?.textLength ?? 0) > (best?.textLength ?? 0) || topFrameValue?.inProgress) {
452
503
  best = topFrameValue;
453
504
  }
454
- return { confirmed: true, read: best, frameId };
505
+ return { confirmed: true, read: best, frameId: ownerFrameId };
455
506
  }
456
507
  async function evaluateDeepResearchFrameStatus(rawClient, sessionId, contextId) {
457
508
  const response = (await rawClient
@@ -474,21 +525,20 @@ function isDeepResearchFrameDescriptor(url, name = "") {
474
525
  return (descriptor.includes("connector_openai_deep_research") || descriptor.includes("deep-research"));
475
526
  }
476
527
  function findDeepResearchFrameId(tree) {
528
+ return collectPageDeepResearchFrameIds(tree)[0] ?? null;
529
+ }
530
+ function collectPageDeepResearchFrameIds(tree) {
477
531
  if (!tree?.frame) {
478
- return null;
532
+ return [];
479
533
  }
480
- const url = tree.frame.url ?? "";
481
- const name = tree.frame.name ?? "";
482
- if (isDeepResearchFrameDescriptor(url, name)) {
483
- return tree.frame.id ?? null;
534
+ const ids = [];
535
+ if (tree.frame.id && isDeepResearchFrameDescriptor(tree.frame.url ?? "", tree.frame.name ?? "")) {
536
+ ids.push(tree.frame.id);
484
537
  }
485
538
  for (const child of tree.childFrames ?? []) {
486
- const match = findDeepResearchFrameId(child);
487
- if (match) {
488
- return match;
489
- }
539
+ ids.push(...collectPageDeepResearchFrameIds(child));
490
540
  }
491
- return null;
541
+ return ids;
492
542
  }
493
543
  function collectDeepResearchFrameIds(tree) {
494
544
  if (!tree?.frame) {
@@ -644,20 +694,41 @@ function buildDeepResearchCompletionPollExpression(minTurnIndex) {
644
694
  const text = (lastTurn?.textContent || '').trim();
645
695
  const normalized = text.toLowerCase().replace(/\\s+/g, ' ').trim();
646
696
  const textLength = text.length;
697
+ const lines = text.split(/\\n+/).map(line => line.trim()).filter(Boolean);
698
+ const tailIsPlanningPanel = text.length <= 1500 &&
699
+ lines.length >= 4 &&
700
+ lines.length <= 20 &&
701
+ /^update$/i.test(lines[1] || '') &&
702
+ /^stop research$/i.test(lines[lines.length - 1] || '') &&
703
+ /^determining steps for creating a report(?:\\.\\.\\.)?$/i.test(lines[lines.length - 2] || '');
647
704
  const isToolStub = normalized === 'called tool' ||
648
705
  normalized === 'used tool' ||
649
706
  normalized === 'użyto narzędzia' ||
650
707
  normalized === 'narzędzie wywołane';
708
+ const incompleteResult = isToolStub ||
709
+ normalized === 'planning' ||
710
+ normalized === 'researching' ||
711
+ normalized === 'searching the web' ||
712
+ (text.trimStart().startsWith('<system-reminder>') &&
713
+ /<system-reminder>[\\s\\S]*#\\s*plan mode\\b/i.test(text)) ||
714
+ tailIsPlanningPanel;
651
715
  const finished = Boolean(lastTurn?.querySelector(${finishedSelector})) &&
652
716
  textLength >= 40 &&
653
- !isToolStub;
717
+ !incompleteResult;
654
718
  const hasIframe = Array.from(document.querySelectorAll('iframe')).some(f => {
655
719
  const rect = f.getBoundingClientRect();
656
720
  return rect.width > 200 && rect.height > 200;
657
721
  });
658
- const hasActiveScopedResearch = scopedToNewTurns && Boolean(lastTurn) && hasIframe &&
659
- (textLength < 40 || isToolStub || /chatgpt\\s+said:?$/i.test(text));
660
- return { finished, stopVisible, textLength, hasIframe, isToolStub, hasActiveScopedResearch, accountBlocked };
722
+ const hasScopedDeepResearchIframe = Array.from(lastTurn?.querySelectorAll?.('iframe') || []).some(f => {
723
+ const rect = f.getBoundingClientRect();
724
+ const descriptor = String(f.getAttribute('src') || '') + ' ' + String(f.getAttribute('name') || '');
725
+ return rect.width > 200 && rect.height > 200 &&
726
+ /connector_openai_deep_research|deep-research/i.test(descriptor);
727
+ });
728
+ const hasActiveScopedResearch = scopedToNewTurns && Boolean(lastTurn) &&
729
+ hasScopedDeepResearchIframe &&
730
+ (textLength < 40 || isToolStub || tailIsPlanningPanel || /chatgpt\\s+said:?$/i.test(text));
731
+ return { finished, stopVisible, textLength, hasIframe, isToolStub, incompleteResult, researchActivity: tailIsPlanningPanel || (isToolStub && hasScopedDeepResearchIframe), hasActiveScopedResearch, accountBlocked };
661
732
  })()`;
662
733
  }
663
734
  export function buildDeepResearchStatusExpressionForTest() {
@@ -659,10 +659,27 @@ function buildModelSelectionExpression(targetModel, strategy) {
659
659
  normalizedTestId.includes('pro');
660
660
  const candidateHasInstant =
661
661
  normalizedText.includes('instant') || normalizedTestId.includes('instant');
662
+ const candidateSelectsConfiguredVersion =
663
+ Boolean(getConfigurationDialog()) &&
664
+ candidateSelectsDesiredVersion &&
665
+ (node?.getAttribute?.('role') === 'option' ||
666
+ node?.getAttribute?.('role') === 'menuitemradio');
667
+ const candidateOpensInstantSubmenu =
668
+ wantsInstant &&
669
+ candidateSelectsDesiredVersion &&
670
+ !candidateHasInstant &&
671
+ (normalizedTestId.includes('submenu') ||
672
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
673
+ node?.getAttribute?.('data-has-submenu') !== null);
662
674
  if (wantsPro && candidateHasThinking) return 0;
663
675
  if (wantsPro && candidateHasLegacyProVersion && !candidateSelectsDesiredVersion) return 0;
664
676
  if (wantsPro && !candidateHasPro && !candidateSelectsDesiredVersion) return 0;
665
- if (wantsInstant && !candidateHasInstant && !candidateSelectsDesiredVersion) return 0;
677
+ if (
678
+ wantsInstant &&
679
+ !candidateHasInstant &&
680
+ !candidateOpensInstantSubmenu &&
681
+ !candidateSelectsConfiguredVersion
682
+ ) return 0;
666
683
  if (wantsThinking && candidateHasPro) return 0;
667
684
  if (wantsThinking && !candidateHasThinking && !candidateSelectsDesiredVersion) return 0;
668
685
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
@@ -701,10 +718,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
701
718
  if (
702
719
  desiredVersion &&
703
720
  candidateTextVersion === desiredVersion &&
721
+ !candidateOpensInstantSubmenu &&
704
722
  !(wantsThinking && desiredVersion === '5-5' && normalizedText === 'gpt 5 5')
705
723
  ) {
706
724
  score += 1200;
707
725
  }
726
+ if (candidateOpensInstantSubmenu) {
727
+ score += 300;
728
+ }
708
729
  if (candidateOpensVersionSubmenu) {
709
730
  score += 500;
710
731
  }
@@ -809,6 +830,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
809
830
  const currentButtonLabel = normalizeText(getButtonLabel());
810
831
  return !labelHasProWord(currentButtonLabel) && !hasProComposerPill();
811
832
  };
833
+ const openedSubmenuKeys = new Set();
834
+ const submenuKey = (normalizedText, testid) =>
835
+ normalizeText(testid ?? '') + '|' + normalizedText;
812
836
 
813
837
  const findBestOption = () => {
814
838
  // Walk through every menu item and keep whichever earns the highest score.
@@ -823,6 +847,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
823
847
  const text = option.textContent ?? '';
824
848
  const normalizedText = normalizeText(text);
825
849
  const testid = option.getAttribute('data-testid') ?? '';
850
+ const optionSubmenuKey = submenuKey(normalizedText, testid);
851
+ if (isSubmenuOption(option, testid) && openedSubmenuKeys.has(optionSubmenuKey)) {
852
+ continue;
853
+ }
826
854
  let score = scoreOption(normalizedText, testid, option);
827
855
  if (score <= 0) {
828
856
  continue;
@@ -832,7 +860,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
832
860
  }
833
861
  const label = getOptionLabel(option);
834
862
  if (!bestMatch || score > bestMatch.score) {
835
- bestMatch = { node: option, label, score, testid, normalizedText };
863
+ bestMatch = {
864
+ node: option,
865
+ label,
866
+ score,
867
+ testid,
868
+ normalizedText,
869
+ submenuKey: optionSubmenuKey,
870
+ };
836
871
  }
837
872
  }
838
873
  }
@@ -924,6 +959,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
924
959
  const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
925
960
  let initialized = false;
926
961
  const attempt = async () => {
962
+ if (performance.now() - start > MAX_WAIT_MS) {
963
+ resolve({
964
+ status: 'option-not-found',
965
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
966
+ });
967
+ return;
968
+ }
927
969
  if (!initialized) {
928
970
  initialized = true;
929
971
  await openDelay();
@@ -946,6 +988,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
946
988
  // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
947
989
  const isSubmenu = isSubmenuOption(match.node, match.testid);
948
990
  if (isSubmenu) {
991
+ openedSubmenuKeys.add(match.submenuKey);
949
992
  openSubmenuOption(match.node);
950
993
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
951
994
  return;
@@ -578,9 +578,62 @@ function buildThinkingTimeExpression(level, desiredModel) {
578
578
  }
579
579
  return null;
580
580
  };
581
-
582
- const composerEffortPill = findComposerEffortPill();
581
+ let composerEffortPill = findComposerEffortPill();
582
+ let modelBtn = findModelButton();
583
+ const modelKindFromLegacyTrailing = (trailing) => {
584
+ const row = trailing.closest?.(
585
+ '[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]',
586
+ );
587
+ const idText = normalize(
588
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
589
+ (trailing.getAttribute?.('data-testid') ?? '')
590
+ );
591
+ if (!idText.includes('model switcher')) return null;
592
+ const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
593
+ if (hasToken(modelPart, 'pro')) return 'pro';
594
+ if (hasToken(modelPart, 'thinking')) return 'thinking';
595
+ if (hasToken(modelPart, 'instant')) return 'instant';
596
+ return null;
597
+ };
598
+ const legacyEffortOwnerIsReady = () => {
599
+ if (
600
+ TARGET_MODEL_KIND === 'pro' &&
601
+ TARGET_LEVEL === 'extended' &&
602
+ isVisible(document.querySelector(INTELLIGENCE_MENU_SELECTOR))
603
+ ) {
604
+ return true;
605
+ }
606
+ const expectedKind = TARGET_MODEL_KIND || modelKindFromNode(modelBtn);
607
+ return Boolean(
608
+ expectedKind &&
609
+ findTrailingButtons().some(
610
+ (button) => isVisible(button) && modelKindFromLegacyTrailing(button) === expectedKind,
611
+ ),
612
+ );
613
+ };
614
+ let attemptedModelButton =
615
+ modelBtn?.getAttribute?.('aria-expanded') === 'true' ? modelBtn : null;
616
+ const effortOwnerDeadline = performance.now() + MAX_WAIT_MS;
617
+ while (!composerEffortPill && performance.now() < effortOwnerDeadline) {
618
+ if (
619
+ modelBtn &&
620
+ attemptedModelButton !== modelBtn &&
621
+ modelBtn.getAttribute?.('aria-expanded') !== 'true'
622
+ ) {
623
+ dispatchClickSequence(modelBtn);
624
+ attemptedModelButton = modelBtn;
625
+ await sleep(INITIAL_WAIT_MS);
626
+ }
627
+ if (modelBtn && legacyEffortOwnerIsReady()) break;
628
+ await sleep(100);
629
+ composerEffortPill = findComposerEffortPill();
630
+ modelBtn = findModelButton();
631
+ if (modelBtn?.getAttribute?.('aria-expanded') === 'true') {
632
+ attemptedModelButton = modelBtn;
633
+ }
634
+ }
583
635
  if (composerEffortPill) {
636
+ if (attemptedModelButton && attemptedModelButton !== composerEffortPill) closeOpenMenus();
584
637
  const composerModelKind = TARGET_MODEL_KIND || modelKindFromNode(composerEffortPill);
585
638
  if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
586
639
  dispatchClickSequence(composerEffortPill);
@@ -646,22 +699,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
646
699
  (trailing.getAttribute?.('data-testid') ?? '')
647
700
  );
648
701
  };
649
- const testIdTextForTrailing = (trailing) => {
650
- const row = rowForTrailing(trailing) || findEffortRow(trailing);
651
- return normalize(
652
- (row?.getAttribute?.('data-testid') ?? '') + ' ' +
653
- (trailing.getAttribute?.('data-testid') ?? '')
654
- );
655
- };
656
- const modelKindFromTrailing = (trailing) => {
657
- const idText = testIdTextForTrailing(trailing);
658
- if (!idText.includes('model switcher')) return null;
659
- const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
660
- if (hasToken(modelPart, 'pro')) return 'pro';
661
- if (hasToken(modelPart, 'thinking')) return 'thinking';
662
- if (hasToken(modelPart, 'instant')) return 'instant';
663
- return null;
664
- };
702
+ const modelKindFromTrailing = modelKindFromLegacyTrailing;
665
703
  const trailingMatchesTargetModelKind = (trailing) => {
666
704
  if (!TARGET_MODEL_KIND) return false;
667
705
  const idKind = modelKindFromTrailing(trailing);
@@ -698,12 +736,19 @@ function buildThinkingTimeExpression(level, desiredModel) {
698
736
  return null;
699
737
  };
700
738
 
701
- const modelBtn = findModelButton();
739
+ const modelButtonDeadline = performance.now() + MAX_WAIT_MS;
740
+ while (!modelBtn && performance.now() < modelButtonDeadline) {
741
+ await sleep(100);
742
+ modelBtn = findModelButton();
743
+ }
702
744
  if (!modelBtn) {
703
745
  return failure('chip-not-found');
704
746
  }
705
747
  // Open model menu (idempotent — leaves it open if already open).
706
- if (modelBtn.getAttribute('aria-expanded') !== 'true') {
748
+ if (
749
+ modelBtn.getAttribute('aria-expanded') !== 'true' &&
750
+ !legacyEffortOwnerIsReady()
751
+ ) {
707
752
  dispatchClickSequence(modelBtn);
708
753
  await sleep(INITIAL_WAIT_MS);
709
754
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getOracleHomeDir } from "../oracleHome.js";
4
+ import { isDeepResearchIncompleteText } from "./deepResearchResult.js";
4
5
  const ARTIFACTS_DIRNAME = "artifacts";
5
6
  function sanitizePathSegment(value, fallback) {
6
7
  const sanitized = value
@@ -85,16 +86,9 @@ export async function writeBinaryBrowserArtifact(params) {
85
86
  sourceUrl: params.sourceUrl,
86
87
  };
87
88
  }
88
- function isToolOnlyPlaceholder(text) {
89
- const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
90
- return (normalized === "called tool" ||
91
- normalized === "used tool" ||
92
- normalized === "użyto narzędzia" ||
93
- normalized === "narzędzie wywołane");
94
- }
95
89
  export async function saveDeepResearchReportArtifact(params) {
96
90
  const report = params.reportMarkdown.trim();
97
- if (report.length < 40 || isToolOnlyPlaceholder(report)) {
91
+ if (report.length < 40 || isDeepResearchIncompleteText(report)) {
98
92
  return null;
99
93
  }
100
94
  return writeTextBrowserArtifact({