@steipete/oracle 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -15,7 +15,7 @@ import { resolveDashPrompt } from "../src/cli/stdin.js";
15
15
  import chalk from "chalk";
16
16
  import { sessionStore, pruneOldSessions } from "../src/sessionStore.js";
17
17
  import { DEFAULT_MODEL, MODEL_CONFIGS } from "../src/oracle/config.js";
18
- import { isKnownModel } from "../src/oracle/modelResolver.js";
18
+ import { isKnownModel, resolveOverriddenApiModel } from "../src/oracle/modelResolver.js";
19
19
  import { CHATGPT_URL } from "../src/browser/constants.js";
20
20
  import { applyHelpStyling } from "../src/cli/help.js";
21
21
  import { collectPaths, collectModelList, collectTextValues, parseFloatOption, parseIntOption, parseSearchOption, parseThinkingTimeOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from "../src/cli/options.js";
@@ -662,6 +662,7 @@ function buildRunOptions(options, overrides = {}) {
662
662
  previousResponseId: overrides.previousResponseId ?? options.previousResponseId,
663
663
  browserResumeConversationUrl: overrides.browserResumeConversationUrl ?? options.browserResumeConversationUrl,
664
664
  effectiveModelId: overrides.effectiveModelId ?? options.effectiveModelId ?? options.model,
665
+ modelOverrides: overrides.modelOverrides ?? options.modelOverrides,
665
666
  file: overrides.file ?? options.file ?? [],
666
667
  maxFileSizeBytes: overrides.maxFileSizeBytes ?? options.maxFileSizeBytes,
667
668
  slug: overrides.slug ?? options.slug,
@@ -900,6 +901,7 @@ function buildRunOptionsFromMetadata(metadata) {
900
901
  previousResponseId: stored.previousResponseId,
901
902
  browserResumeConversationUrl: stored.browserResumeConversationUrl,
902
903
  effectiveModelId: stored.effectiveModelId ?? stored.model,
904
+ modelOverrides: stored.modelOverrides,
903
905
  file: stored.file ?? [],
904
906
  maxFileSizeBytes: stored.maxFileSizeBytes,
905
907
  slug: stored.slug,
@@ -1172,11 +1174,16 @@ async function runRootCommand(options) {
1172
1174
  throw new Error("--remote-host does not support --models yet. Use API engine locally instead.");
1173
1175
  }
1174
1176
  const resolvedModel = normalizedMultiModels[0] ?? (isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate);
1175
- const effectiveModelId = resolvedModel.startsWith("gemini")
1176
- ? resolveGeminiModelId(resolvedModel)
1177
- : isKnownModel(resolvedModel)
1178
- ? (MODEL_CONFIGS[resolvedModel].apiModel ?? resolvedModel)
1179
- : resolvedModel;
1177
+ // A user-config apiModel override (known models only) wins over Gemini alias
1178
+ // remapping and the bundled apiModel, so it becomes the on-wire request id.
1179
+ const apiModelOverrides = engine === "api" ? userConfig.modelOverrides : undefined;
1180
+ const overriddenApiModel = resolveOverriddenApiModel(resolvedModel, apiModelOverrides);
1181
+ const effectiveModelId = overriddenApiModel ??
1182
+ (resolvedModel.startsWith("gemini")
1183
+ ? resolveGeminiModelId(resolvedModel)
1184
+ : isKnownModel(resolvedModel)
1185
+ ? (MODEL_CONFIGS[resolvedModel].apiModel ?? resolvedModel)
1186
+ : resolvedModel);
1180
1187
  const resolvedBaseUrl = normalizeBaseUrl(options.baseUrl ?? (isClaude ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL));
1181
1188
  const { models: _rawModels, ...optionsWithoutModels } = options;
1182
1189
  const resolvedOptions = { ...optionsWithoutModels, model: resolvedModel };
@@ -1187,6 +1194,7 @@ async function runRootCommand(options) {
1187
1194
  }
1188
1195
  resolvedOptions.baseUrl = resolvedBaseUrl;
1189
1196
  resolvedOptions.effectiveModelId = effectiveModelId;
1197
+ resolvedOptions.modelOverrides = apiModelOverrides;
1190
1198
  resolvedOptions.provider = providerMode;
1191
1199
  resolvedOptions.writeOutputPath = resolveOutputPath(options.writeOutput, process.cwd());
1192
1200
  if (options.status) {
@@ -251,6 +251,22 @@ body:not(.home) .doc>h1:first-child{display:none}
251
251
  <li><strong>Host (Windows)</strong>: runs <code>oracle bridge host</code> and holds the signed-in ChatGPT session.</li>
252
252
  <li><strong>Client (Linux)</strong>: stores the host connection once and routes browser runs (and MCP browser runs) through the host.</li>
253
253
  </ul>
254
+ <h2 id="generated-artifact-transfer"><a class="anchor" href="#generated-artifact-transfer" aria-label="Anchor link">#</a>Generated artifact transfer</h2>
255
+ <p>Bridge runs now keep the Windows browser host and Linux client separated while still returning ChatGPT-generated files, such as ZIP, CSV, PDF, wheels, and source distributions, to a cloud-readable path. The host advertises artifact-transfer support from the token-protected <code>GET /health</code> response. The Linux client uses that capability signal in <code>oracle bridge client --test</code> and <code>oracle bridge doctor</code>; older hosts remain usable for text responses, but generated files require manual copy from the Windows browser until both sides are upgraded.</p>
256
+ <p>The transfer protocol is pull-based and keeps secrets local to the host:</p>
257
+ <ol>
258
+ <li>The browser host saves the ChatGPT file to its local session artifacts directory as before.</li>
259
+ <li>The host emits only a redacted artifact descriptor over the existing NDJSON run stream: artifact id, safe filename, MIME type, byte size, SHA-256, validation status, and coarse source kind. It does not expose cookies, bearer tokens, signed ChatGPT download URLs, or Windows filesystem paths.</li>
260
+ <li>The Linux client fetches <code>GET /runs/&lt;runId&gt;/artifacts/&lt;artifactId&gt;</code> with the same bridge bearer token, writes to <code>~/.oracle/sessions/&lt;sessionId&gt;/artifacts/</code>, verifies size and SHA-256, validates ZIP structure when applicable, and only then publishes the final path in session metadata.</li>
261
+ <li>If transfer fails, Oracle keeps the text response and records a warning with manual fallback instructions. Open the ChatGPT browser on the Windows host, use the visible download button/link in the current assistant response, and copy the file to a cloud-readable path yourself.</li>
262
+ </ol>
263
+ <p>Operational notes:</p>
264
+ <ul>
265
+ <li>Run the same patched Oracle version on both Windows host and Linux client before relying on automatic file transfer. Mixed versions remain backward compatible for text-only runs.</li>
266
+ <li><code>oracle bridge doctor</code> reports <code>Artifact transfer: bridge v1</code> when the host supports the protocol, including the advertised maximum artifact size.</li>
267
+ <li>The default bridge transfer size limit is 512 MiB. Larger files stay on the browser host and require manual copy.</li>
268
+ <li>Session inspection prints artifact path, size, SHA-256 prefix, validation status, and transfer status so agents can verify whether the returned path is local to the Linux client.</li>
269
+ </ul>
254
270
  <h2 id="1-windows-start-the-host-service-recommended"><a class="anchor" href="#1-windows-start-the-host-service-recommended" aria-label="Anchor link">#</a>1) Windows: start the host service (recommended)</h2>
255
271
  <p>Run this on the Windows machine that’s signed into ChatGPT:</p>
256
272
  <pre class="shiki github-dark-dimmed" style="background-color:var(--code-bg);color:var(--code-fg)" tabindex="0"><code class="language-powershell"><span class="line"><span style="color:#ADBAC7">oracle bridge host </span><span style="color:#F47067">--</span><span style="color:#ADBAC7">token auto </span><span style="color:#F47067">--</span><span style="color:#ADBAC7">ssh user@your</span><span style="color:#F47067">-</span><span style="color:#ADBAC7">linux</span><span style="color:#F47067">-</span><span style="color:#ADBAC7">host</span></span></code></pre>
@@ -334,7 +350,7 @@ body:not(.home) .doc>h1:first-child{display:none}
334
350
  <li>The connection artifact and config file contain secrets; keep them private (Oracle writes them with restrictive permissions on Unix).</li>
335
351
  <li>Bridge does <strong>not</strong> extract/decrypt cookies from arbitrary profiles; the Windows machine keeps the authenticated session locally.</li>
336
352
  </ul><nav class="page-nav" aria-label="Pager"><a class="page-nav-prev" href="windows-work.html"><small>Previous</small><span>Windows Work</span></a><a class="page-nav-next" href="agents.html"><small>Next</small><span>Coding Agents</span></a></nav></article>
337
- <nav class="toc" aria-label="On this page"><h2>On this page</h2><a class="toc-l2" href="#concepts">Concepts</a><a class="toc-l2" href="#1-windows-start-the-host-service-recommended">1) Windows: start the host service (recommended)</a><a class="toc-l2" href="#2-linux-configure-the-client-once">2) Linux: configure the client once</a><a class="toc-l2" href="#2b-linux-desktop-local-manual-login-no-bridge">2b) Linux desktop: local manual-login (no bridge)</a><a class="toc-l2" href="#3-codex-cli-mcp-integration">3) Codex CLI (MCP) integration</a><a class="toc-l2" href="#3b-claude-code-mcp-integration">3b) Claude Code (MCP) integration</a><a class="toc-l3" href="#macos-local-browser-let-them-fight">macOS local browser: Let Them Fight</a><a class="toc-l2" href="#4-troubleshooting">4) Troubleshooting</a><a class="toc-l2" href="#security-notes">Security notes</a></nav>
353
+ <nav class="toc" aria-label="On this page"><h2>On this page</h2><a class="toc-l2" href="#concepts">Concepts</a><a class="toc-l2" href="#generated-artifact-transfer">Generated artifact transfer</a><a class="toc-l2" href="#1-windows-start-the-host-service-recommended">1) Windows: start the host service (recommended)</a><a class="toc-l2" href="#2-linux-configure-the-client-once">2) Linux: configure the client once</a><a class="toc-l2" href="#2b-linux-desktop-local-manual-login-no-bridge">2b) Linux desktop: local manual-login (no bridge)</a><a class="toc-l2" href="#3-codex-cli-mcp-integration">3) Codex CLI (MCP) integration</a><a class="toc-l2" href="#3b-claude-code-mcp-integration">3b) Claude Code (MCP) integration</a><a class="toc-l3" href="#macos-local-browser-let-them-fight">macOS local browser: Let Them Fight</a><a class="toc-l2" href="#4-troubleshooting">4) Troubleshooting</a><a class="toc-l2" href="#security-notes">Security notes</a></nav>
338
354
  </div>
339
355
  </main>
340
356
  </div>
@@ -362,11 +362,11 @@ body:not(.home) .doc>h1:first-child{display:none}
362
362
  <span class="line"><span style="color:#6CB6FF"> --browser-manual-login</span><span style="color:#F47067"> \</span></span>
363
363
  <span class="line"><span style="color:#6CB6FF"> --browser-research</span><span style="color:#96D0FF"> deep</span><span style="color:#F47067"> \</span></span>
364
364
  <span class="line"><span style="color:#6CB6FF"> -p</span><span style="color:#96D0FF"> "Research the current browser support for WebGPU in enterprise-managed Chrome and cite sources."</span></span></code></pre>
365
- <p>Oracle activates ChatGPT Deep Research through the composer <code>/Deepresearch</code> command and falls back to the composer tools menu when the slash flow is not available. It waits for the research plan to auto-confirm, logs high-level progress, then captures the final report from the Deep Research report surface instead of trusting the assistant tool-call wrapper.</p>
365
+ <p>Oracle activates ChatGPT Deep Research through the composer tools menu, recognizing both the <code>Deep research</code> label and current <code>Get a detailed report</code> menu variants. It waits for the research plan to auto-confirm, logs high-level progress, then captures the final report from the Deep Research report surface instead of trusting the assistant tool-call wrapper.</p>
366
366
  <p>If ChatGPT initially exposes only <code>Called tool</code> / <code>Used tool</code>, Oracle treats that as an incomplete capture for Deep Research rather than a final answer. Reattach the existing session with <code>oracle session &lt;id&gt; --render</code> so Oracle can recover the lazy-loaded report from the existing Chrome tab; do not rerun the research unless the browser session is unrecoverable.</p>
367
367
  <p>Deep Research is browser-only. It does not use connected apps in v1; give it public-web scope, uploaded files, and any domain/source guidance in the prompt. For deep thinking over code or architecture without web search, prefer a normal browser run with a Pro/Thinking model and <code>--browser-thinking-time heavy</code>.</p>
368
368
  <p>Completed browser sessions also save durable artifacts under <code>~/.oracle/sessions/&lt;id&gt;/artifacts/</code>. Deep Research writes the extracted report to <code>deep-research-report.md</code>, and every browser run writes <code>transcript.md</code> with the prompt, final answer, conversation URL, and saved artifact references. Use <code>--write-output &lt;path&gt;</code> when you also need a copy of just the final answer at a specific path.</p>
369
- <p>When ChatGPT generates downloadable files in the assistant response (for example a ZIP, wheel, source distribution, CSV, or PDF), Oracle saves those files beside the transcript before any archive attempt. The downloader is intentionally narrow: it only follows ChatGPT-owned file/download URLs from the assistant response and uses <code>sandbox:/mnt/data/...</code> links as source metadata and filename hints, not as arbitrary fetch targets. External links in the response are left in the transcript but are not downloaded.</p>
369
+ <p>When ChatGPT generates downloadable files in the assistant response (for example a ZIP, wheel, source distribution, CSV, or PDF), Oracle saves those files beside the transcript before any archive attempt. The downloader is intentionally narrow: it only follows ChatGPT-owned file/download URLs from the assistant response and uses <code>sandbox:/mnt/data/...</code> links as source metadata and filename hints, not as arbitrary fetch targets. External links in the response are left in the transcript but are not downloaded. In bridge mode, a patched Windows host advertises artifact-transfer capability through <code>/health</code>; the Linux client then pulls each saved file over the authenticated bridge endpoint, stores it under the Linux session <code>artifacts/</code> directory, and verifies safe filename, byte size, SHA-256, and ZIP structure where applicable. If either side is older or transfer validation fails, the text response still completes and Oracle prints a manual-copy fallback instead of leaking host paths or signed download URLs.</p>
370
370
  <h3 id="conversation-archiving"><a class="anchor" href="#conversation-archiving" aria-label="Anchor link">#</a>Conversation archiving</h3>
371
371
  <p>Browser mode keeps the local session as the source of truth, so Oracle can optionally archive the ChatGPT conversation after a successful run. The default <code>--browser-archive auto</code> archives only successful non-project, non-Deep-Research, non-multi-turn one-shot chats after <code>transcript.md</code>, generated artifacts, the final answer, and the conversation URL are saved locally.</p>
372
372
  <p>Oracle does not auto-archive failed, incomplete, running, project, Deep Research, or multi-turn sessions. Use <code>--browser-archive never</code> to disable archiving, or <code>--browser-archive always</code> when you explicitly want a successful browser conversation archived even outside the default one-shot policy. Archived chats are still visible and manageable from ChatGPT&#39;s own archive UI.</p>
@@ -307,6 +307,15 @@ body:not(.home) .doc>h1:first-child{display:none}
307
307
  <span class="line"><span style="color:#96D0FF"> sessionRetentionHours</span><span style="color:#ADBAC7">: </span><span style="color:#6CB6FF">72</span><span style="color:#ADBAC7">, </span><span style="color:#768390">// prune cached sessions older than 72h before each run (0 disables)</span></span>
308
308
  <span class="line"><span style="color:#96D0FF"> promptSuffix</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"// signed-off by me"</span><span style="color:#ADBAC7">, </span><span style="color:#768390">// appended to every prompt</span></span>
309
309
  <span class="line"><span style="color:#96D0FF"> apiBaseUrl</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"https://api.openai.com/v1"</span><span style="color:#ADBAC7">, </span><span style="color:#768390">// override for LiteLLM / custom gateways</span></span>
310
+ <span class="line"><span style="color:#768390"> // API-only, user-config-only overrides for known model keys.</span></span>
311
+ <span class="line"><span style="color:#96D0FF"> modelOverrides</span><span style="color:#ADBAC7">: {</span></span>
312
+ <span class="line"><span style="color:#96D0FF"> "gpt-5.5"</span><span style="color:#ADBAC7">: {</span></span>
313
+ <span class="line"><span style="color:#96D0FF"> apiModel</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"gateway-model"</span><span style="color:#ADBAC7">, </span><span style="color:#768390">// on-wire id exposed by the gateway</span></span>
314
+ <span class="line"><span style="color:#96D0FF"> reasoning</span><span style="color:#ADBAC7">: { </span><span style="color:#96D0FF">effort</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"xhigh"</span><span style="color:#ADBAC7"> }, </span><span style="color:#768390">// or null to clear the bundled effort</span></span>
315
+ <span class="line"><span style="color:#96D0FF"> inputLimit</span><span style="color:#ADBAC7">: </span><span style="color:#6CB6FF">1050000</span><span style="color:#ADBAC7">,</span></span>
316
+ <span class="line"><span style="color:#96D0FF"> pricing</span><span style="color:#ADBAC7">: { </span><span style="color:#96D0FF">inputPerToken</span><span style="color:#ADBAC7">: </span><span style="color:#6CB6FF">0.000005</span><span style="color:#ADBAC7">, </span><span style="color:#96D0FF">outputPerToken</span><span style="color:#ADBAC7">: </span><span style="color:#6CB6FF">0.00003</span><span style="color:#ADBAC7"> },</span></span>
317
+ <span class="line"><span style="color:#ADBAC7"> },</span></span>
318
+ <span class="line"><span style="color:#ADBAC7"> },</span></span>
310
319
  <span class="line"><span style="color:#ADBAC7">}</span></span></code></pre>
311
320
  <h2 id="project-configs"><a class="anchor" href="#project-configs" aria-label="Anchor link">#</a>Project configs</h2>
312
321
  <p>Put a project-level config at <code>.oracle/config.json</code> inside any project folder:</p>
@@ -320,17 +329,18 @@ body:not(.home) .doc>h1:first-child{display:none}
320
329
  <span class="line"><span style="color:#ADBAC7"> },</span></span>
321
330
  <span class="line"><span style="color:#ADBAC7">}</span></span></code></pre>
322
331
  <p>Oracle discovers every <code>.oracle/config.json</code> from the current directory upward until your home directory, then applies them from parent to child after the user config. Nested objects are merged, while scalars and arrays replace earlier values. This lets a parent folder set broad defaults and override a specific ChatGPT Project URL in a package subdirectory.</p>
323
- <p>Project configs intentionally support only workflow defaults. They cannot set provider routing or secret/executable fields such as <code>apiBaseUrl</code>, <code>azure</code>, <code>browser.remoteHost</code>, <code>browser.remoteToken</code>, <code>browser.chromePath</code>, or <code>browser.chromeCookiePath</code>. Keep tokens and machine-local executable/profile paths in <code>~/.oracle/config.json</code>, environment variables, or explicit CLI flags.</p>
332
+ <p>Project configs intentionally support only workflow defaults. They cannot set provider routing or secret/executable fields such as <code>apiBaseUrl</code>, <code>modelOverrides</code>, <code>azure</code>, <code>browser.remoteHost</code>, <code>browser.remoteToken</code>, <code>browser.chromePath</code>, or <code>browser.chromeCookiePath</code>. Keep tokens and machine-local executable/profile paths in <code>~/.oracle/config.json</code>, environment variables, or explicit CLI flags.</p>
324
333
  <h2 id="precedence"><a class="anchor" href="#precedence" aria-label="Anchor link">#</a>Precedence</h2>
325
334
  <p>CLI flags and explicit override environment variables → effective config (project <code>.oracle/config.json</code> files over <code>~/.oracle/config.json</code>) → auto-detected environment → built-in defaults.</p>
326
335
  <ul>
327
336
  <li>The effective config starts with <code>~/.oracle/config.json</code>, then layers project <code>.oracle/config.json</code> files from parent to child. <code>engine</code>, <code>model</code>, <code>search</code>, <code>filesReport</code>, <code>heartbeatSeconds</code>, <code>maxFileSizeBytes</code>, and <code>apiBaseUrl</code> in the effective config override auto-detected values unless explicitly set on the CLI or through a supported override environment variable.</li>
328
337
  <li>Project <code>.oracle/config.json</code> files can override safe workflow defaults such as <code>engine</code>, <code>model</code>, <code>search</code>, <code>filesReport</code>, <code>heartbeatSeconds</code>, <code>maxFileSizeBytes</code>, <code>promptSuffix</code>, and allowed <code>browser.*</code> workflow settings.</li>
329
- <li>Provider routing and machine-local fields (<code>apiBaseUrl</code>, <code>azure</code>, remote browser host/token defaults, Chrome binary/profile paths, cookie DB paths, and session retention cleanup) are ignored in project configs and are read only from the user config, environment variables, or explicit CLI flags.</li>
338
+ <li>Provider routing and machine-local fields (<code>apiBaseUrl</code>, <code>modelOverrides</code>, <code>azure</code>, remote browser host/token defaults, Chrome binary/profile paths, cookie DB paths, and session retention cleanup) are ignored in project configs and are read only from the user config, environment variables, or explicit CLI flags.</li>
330
339
  <li><code>ORACLE_ENGINE=api|browser</code> is a global override for engine selection (useful for MCP/Codex setups); it wins over <code>config.json</code>.</li>
331
340
  <li>If <code>azure.endpoint</code> (or <code>--azure-endpoint</code>) is set, Oracle reads <code>AZURE_OPENAI_API_KEY</code> first and falls back to <code>OPENAI_API_KEY</code> for GPT models.</li>
332
341
  <li>Remote browser defaults follow the same order: <code>--remote-host/--remote-token</code> win, then <code>browser.remoteHost</code> / <code>browser.remoteToken</code> in the config, then <code>ORACLE_REMOTE_HOST</code> / <code>ORACLE_REMOTE_TOKEN</code> if still unset.</li>
333
342
  <li><code>OPENAI_API_KEY</code> only influences engine selection when neither the CLI nor <code>config.json</code> specify an engine (API when present, otherwise browser).</li>
343
+ <li><code>modelOverrides</code> applies only to API runs and existing built-in model keys. It can replace the on-wire <code>apiModel</code>, reasoning effort, input limit, and per-token pricing; unspecified fields and the bundled tokenizer remain unchanged. Invalid override values are ignored. Project configs cannot set this field.</li>
334
344
  <li><code>ORACLE_NOTIFY*</code> env vars still layer on top of the config’s <code>notify</code> block.</li>
335
345
  <li><code>sessionRetentionHours</code> controls the default value for <code>--retain-hours</code>. When unset, <code>ORACLE_RETAIN_HOURS</code> (if present) becomes the fallback, and the CLI flag still wins over both.</li>
336
346
  <li><code>ORACLE_MAX_FILE_SIZE_BYTES</code> overrides <code>maxFileSizeBytes</code> when set. Oracle validates it as a positive integer number of bytes before reading any <code>--file</code> inputs.</li>
@@ -292,6 +292,18 @@ body:not(.home) .doc>h1:first-child{display:none}
292
292
  <pre class="shiki github-dark-dimmed" style="background-color:var(--code-bg);color:var(--code-fg)" tabindex="0"><code class="language-json"><span class="line"><span style="color:#ADBAC7">{</span></span>
293
293
  <span class="line"><span style="color:#8DDB8C"> "apiBaseUrl"</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"http://localhost:4000"</span></span>
294
294
  <span class="line"><span style="color:#ADBAC7">}</span></span></code></pre>
295
+ <p>If a gateway exposes a different on-wire model id, add an API-only override to the user config at <code>~/.oracle/config.json</code>:</p>
296
+ <pre class="shiki github-dark-dimmed" style="background-color:var(--code-bg);color:var(--code-fg)" tabindex="0"><code class="language-json5"><span class="line"><span style="color:#ADBAC7">{</span></span>
297
+ <span class="line"><span style="color:#96D0FF"> apiBaseUrl</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"http://localhost:4000"</span><span style="color:#ADBAC7">,</span></span>
298
+ <span class="line"><span style="color:#96D0FF"> model</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"gpt-5.5"</span><span style="color:#ADBAC7">,</span></span>
299
+ <span class="line"><span style="color:#96D0FF"> modelOverrides</span><span style="color:#ADBAC7">: {</span></span>
300
+ <span class="line"><span style="color:#96D0FF"> "gpt-5.5"</span><span style="color:#ADBAC7">: {</span></span>
301
+ <span class="line"><span style="color:#96D0FF"> apiModel</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"gateway-model"</span><span style="color:#ADBAC7">,</span></span>
302
+ <span class="line"><span style="color:#96D0FF"> reasoning</span><span style="color:#ADBAC7">: { </span><span style="color:#96D0FF">effort</span><span style="color:#ADBAC7">: </span><span style="color:#96D0FF">"high"</span><span style="color:#ADBAC7"> },</span></span>
303
+ <span class="line"><span style="color:#ADBAC7"> },</span></span>
304
+ <span class="line"><span style="color:#ADBAC7"> },</span></span>
305
+ <span class="line"><span style="color:#ADBAC7">}</span></span></code></pre>
306
+ <p>Overrides accept existing built-in model keys only and are ignored in project <code>.oracle/config.json</code> files. See <a href="configuration.html">Local configuration</a> for the full field list and precedence.</p>
295
307
  <h2 id="model-aliases"><a class="anchor" href="#model-aliases" aria-label="Anchor link">#</a>Model aliases</h2>
296
308
  <p>Oracle keeps a stable CLI-facing model set, but some names are aliases for the concrete API model ids it sends:</p>
297
309
  <ul>
@@ -58,9 +58,9 @@ function firewallHint(host, devtoolsPort) {
58
58
  "Re-run ./runner pnpm test:browser after adding the rule.",
59
59
  ].join("\n");
60
60
  }
61
- async function fetchVersion(host, devtoolsPort) {
61
+ async function fetchVersion(host, devtoolsPort, timeoutMs = 5000) {
62
62
  const controller = new AbortController();
63
- const timer = setTimeout(() => controller.abort(), 5000);
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
64
64
  try {
65
65
  const res = await fetch(`http://${host}:${devtoolsPort}/json/version`, {
66
66
  signal: controller.signal,
@@ -77,6 +77,16 @@ async function fetchVersion(host, devtoolsPort) {
77
77
  clearTimeout(timer);
78
78
  }
79
79
  }
80
+ async function waitForDevToolsShutdown(host, devtoolsPort) {
81
+ const deadline = Date.now() + 5000;
82
+ while (Date.now() < deadline) {
83
+ if (!(await fetchVersion(host, devtoolsPort, 250))) {
84
+ return;
85
+ }
86
+ await sleep(100);
87
+ }
88
+ throw new Error(`DevTools did not stop listening at ${host}:${devtoolsPort}`);
89
+ }
80
90
  async function main() {
81
91
  console.log(`[browser-test] launching Chrome on ${targetHost}:${port} (headful)…`);
82
92
  const chrome = await launch({
@@ -89,6 +99,7 @@ async function main() {
89
99
  ok = await fetchVersion(targetHost, chrome.port);
90
100
  }
91
101
  await chrome.kill();
102
+ await waitForDevToolsShutdown(targetHost, chrome.port);
92
103
  if (ok) {
93
104
  console.log(`[browser-test] PASS: DevTools responding on ${targetHost}:${chrome.port}`);
94
105
  process.exit(0);
@@ -1,8 +1,20 @@
1
- import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from "../constants.js";
1
+ import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
2
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression, } from "../domDebug.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
5
6
  const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
7
+ const STOP_CONTROL_SELECTOR = STOP_BUTTON_SELECTORS.join(", ");
8
+ const MIN_CONFIDENT_ANSWER_LENGTH = 16;
9
+ function isImplausiblyShortAnswer(candidateLength) {
10
+ return candidateLength > 0 && candidateLength < MIN_CONFIDENT_ANSWER_LENGTH;
11
+ }
12
+ export function shouldConfirmAssistantCompletion(args) {
13
+ if (args.stopVisible || args.completionVisible) {
14
+ return true;
15
+ }
16
+ return isImplausiblyShortAnswer(args.candidateLength);
17
+ }
6
18
  const THINKING_STATUS_LABELS = [
7
19
  "thinking",
8
20
  "pro thinking",
@@ -44,8 +56,8 @@ function isAnswerNowPlaceholderText(normalized) {
44
56
  }
45
57
  function buildActiveThinkingStatusPredicateJs(fnName) {
46
58
  const labelsLiteral = JSON.stringify(THINKING_STATUS_LABELS);
47
- const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
48
- return `const ${fnName} = (snapshot) => {
59
+ return `${buildStopButtonVisibilityPredicateJs("isStopControlVisible")}
60
+ const ${fnName} = (snapshot) => {
49
61
  const normalized = String(snapshot?.text ?? '').toLowerCase().replace(/\\s+/g, ' ').trim();
50
62
  if (!normalized) return false;
51
63
  const labels = ${labelsLiteral};
@@ -53,7 +65,7 @@ function buildActiveThinkingStatusPredicateJs(fnName) {
53
65
  labels.includes(normalized) ||
54
66
  (normalized.startsWith('thought for ') && normalized.length <= 40) ||
55
67
  (normalized.startsWith('pro thinking') && normalized.length <= 40);
56
- return matches && Boolean(document.querySelector(${stopSelectorLiteral}));
68
+ return matches && isStopControlVisible();
57
69
  };`;
58
70
  }
59
71
  export function matchesThinkingStatusLabelForTest(text) {
@@ -108,7 +120,9 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
108
120
  if (source === "poll" &&
109
121
  error instanceof Error &&
110
122
  error.message === ASSISTANT_POLL_TIMEOUT_ERROR) {
111
- evaluation = await evaluationPromise;
123
+ evaluationPromise.catch(() => undefined);
124
+ await terminateRuntimeExecution(Runtime);
125
+ throw error;
112
126
  }
113
127
  else if (source === "poll") {
114
128
  throw error;
@@ -161,21 +175,34 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
161
175
  // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
162
176
  const elapsedMs = Date.now() - start;
163
177
  const remainingMs = Math.max(0, timeoutMs - elapsedMs);
178
+ const candidateText = String(candidate?.text ?? "").trim();
179
+ const suspiciouslyShort = isImplausiblyShortAnswer(candidateText.length);
164
180
  if (remainingMs > 0) {
165
181
  const [stopVisible, completionVisible] = await Promise.all([
166
182
  isStopButtonVisible(Runtime),
167
183
  isCompletionVisible(Runtime),
168
184
  ]);
169
- if (stopVisible) {
170
- logger("Assistant still generating; waiting for completion");
185
+ // Completion controls can appear briefly while Pro is still replacing its thinking UI.
186
+ // Confirm every capture from that transition with the stability-based watchdog; a
187
+ // partial first paragraph can be arbitrarily long.
188
+ if (shouldConfirmAssistantCompletion({
189
+ candidateLength: candidateText.length,
190
+ stopVisible,
191
+ completionVisible,
192
+ })) {
193
+ logger(stopVisible
194
+ ? "Assistant still generating; waiting for completion"
195
+ : completionVisible
196
+ ? "Completion controls surfaced; confirming stable assistant response"
197
+ : "Captured an implausibly short response; confirming it is not a mid-stream capture");
171
198
  const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
172
- if (completed) {
199
+ if (completed && String(completed.text ?? "").trim().length >= candidateText.length) {
173
200
  return completed;
174
201
  }
175
202
  }
176
- else if (completionVisible) {
177
- // No-op: completion UI surfaced and stop button is gone.
178
- }
203
+ }
204
+ if (suspiciouslyShort) {
205
+ throw new Error("assistant-response short capture could not be confirmed before timeout; refusing to finalize it");
179
206
  }
180
207
  return candidate;
181
208
  }
@@ -239,11 +266,17 @@ async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex
239
266
  if (recoveryTimeoutMs === 0) {
240
267
  return null;
241
268
  }
269
+ const recoveryStartedAt = Date.now();
242
270
  const recovered = await waitForCondition(async () => {
243
271
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
244
272
  return normalizeAssistantSnapshot(snapshot);
245
273
  }, recoveryTimeoutMs, 400);
246
274
  if (recovered) {
275
+ if (isImplausiblyShortAnswer(recovered.text.length)) {
276
+ logger("Recovered an implausibly short response; waiting for completion proof");
277
+ const remainingMs = Math.max(0, recoveryTimeoutMs - (Date.now() - recoveryStartedAt));
278
+ return pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
279
+ }
247
280
  logger("Recovered assistant response via polling fallback");
248
281
  return recovered;
249
282
  }
@@ -360,8 +393,8 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
360
393
  if (isGeneratedImageAssistantAnswer(normalized)) {
361
394
  return normalized;
362
395
  }
363
- const shortAnswer = currentLength > 0 && currentLength < 16;
364
- const mediumAnswer = currentLength >= 16 && currentLength < 40;
396
+ const shortAnswer = isImplausiblyShortAnswer(currentLength);
397
+ const mediumAnswer = currentLength >= MIN_CONFIDENT_ANSWER_LENGTH && currentLength < 40;
365
398
  const longAnswer = currentLength >= 40 && currentLength < 500;
366
399
  // Learned: short answers need a longer stability window or they truncate.
367
400
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
@@ -374,7 +407,7 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
374
407
  if (!stopVisible) {
375
408
  const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
376
409
  const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
377
- if (completionEnough || stableEnough) {
410
+ if (completionEnough || (!shortAnswer && stableEnough)) {
378
411
  return normalized;
379
412
  }
380
413
  }
@@ -390,7 +423,7 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
390
423
  async function isStopButtonVisible(Runtime) {
391
424
  try {
392
425
  const { result } = await Runtime.evaluate({
393
- expression: `Boolean(document.querySelector('${STOP_BUTTON_SELECTOR}'))`,
426
+ expression: buildStopButtonVisibilityExpression(),
394
427
  returnByValue: true,
395
428
  });
396
429
  return Boolean(result?.value);
@@ -399,6 +432,30 @@ async function isStopButtonVisible(Runtime) {
399
432
  return false;
400
433
  }
401
434
  }
435
+ function buildStopButtonVisibilityExpression() {
436
+ return `(() => {
437
+ ${buildStopButtonVisibilityPredicateJs("isStopControlVisible")}
438
+ return isStopControlVisible();
439
+ })()`;
440
+ }
441
+ function buildStopButtonVisibilityPredicateJs(fnName) {
442
+ const selectorLiteral = JSON.stringify(STOP_CONTROL_SELECTOR);
443
+ return `const ${fnName} = () => {
444
+ const isVisible = (node) => {
445
+ if (!(node instanceof HTMLElement)) return false;
446
+ const rect = node.getBoundingClientRect();
447
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
448
+ const style = window.getComputedStyle(node);
449
+ return !(
450
+ style.display === 'none' ||
451
+ style.visibility === 'hidden' ||
452
+ (style.opacity !== '' && Number(style.opacity) === 0)
453
+ );
454
+ };
455
+ return Array.from(document.querySelectorAll(${selectorLiteral})).some((node) => isVisible(node));
456
+ };`;
457
+ }
458
+ export const buildStopButtonVisibilityExpressionForTest = buildStopButtonVisibilityExpression;
402
459
  async function isCompletionVisible(Runtime) {
403
460
  try {
404
461
  const { result } = await Runtime.evaluate({
@@ -417,7 +474,7 @@ async function isCompletionVisible(Runtime) {
417
474
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
418
475
  };
419
476
 
420
- const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
477
+ const turns = ${buildConversationTurnListExpression()};
421
478
  let lastAssistantTurn = null;
422
479
  for (let i = turns.length - 1; i >= 0; i--) {
423
480
  if (isAssistantTurn(turns[i])) {
@@ -529,7 +586,6 @@ function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId)
529
586
  }
530
587
  function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId) {
531
588
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
532
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
533
589
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
534
590
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
535
591
  ? Math.floor(minTurnIndex)
@@ -540,9 +596,8 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
540
596
  return `(() => {
541
597
  ${buildClickDispatcher()}
542
598
  const SELECTORS = ${selectorsLiteral};
543
- const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
599
+ const STOP_SELECTOR = ${JSON.stringify(STOP_CONTROL_SELECTOR)};
544
600
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
545
- const CONVERSATION_SELECTOR = ${conversationLiteral};
546
601
  const ASSISTANT_SELECTOR = ${assistantLiteral};
547
602
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
548
603
  // Learned: settling avoids capturing mid-stream HTML; keep short.
@@ -598,7 +653,6 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
598
653
  const captureViaObserver = () =>
599
654
  new Promise((resolve, reject) => {
600
655
  const deadline = Date.now() + ${timeoutMs};
601
- let stopInterval = null;
602
656
  let timeoutId = null;
603
657
  let cleanedUp = false;
604
658
  let observer = null;
@@ -607,10 +661,6 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
607
661
  const cleanup = () => {
608
662
  if (cleanedUp) return;
609
663
  cleanedUp = true;
610
- if (stopInterval) {
611
- clearInterval(stopInterval);
612
- stopInterval = null;
613
- }
614
664
  if (timeoutId) {
615
665
  clearTimeout(timeoutId);
616
666
  timeoutId = null;
@@ -662,20 +712,6 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
662
712
  observer = new MutationObserver(observerCallback);
663
713
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
664
714
 
665
- stopInterval = setInterval(() => {
666
- if (cleanedUp) return;
667
- const stop = document.querySelector(STOP_SELECTOR);
668
- if (!stop) {
669
- return;
670
- }
671
- const isStopButton =
672
- stop.getAttribute('data-testid') === 'stop-button' || stop.getAttribute('aria-label')?.toLowerCase()?.includes('stop');
673
- if (isStopButton) {
674
- return;
675
- }
676
- dispatchClickSequence(stop);
677
- }, 500);
678
-
679
715
  timeoutId = setTimeout(() => {
680
716
  cleanup();
681
717
  reject(new Error('Response timeout'));
@@ -684,7 +720,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
684
720
 
685
721
  // Check if the last assistant turn has finished (scoped to avoid detecting old turns).
686
722
  const isLastAssistantTurnFinished = () => {
687
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
723
+ const turns = ${buildConversationTurnListExpression()};
688
724
  let lastAssistantTurn = null;
689
725
  for (let i = turns.length - 1; i >= 0; i--) {
690
726
  if (isAssistantTurn(turns[i])) {
@@ -708,8 +744,8 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
708
744
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
709
745
  // use progressively longer windows to avoid truncation (#71).
710
746
  const initialLength = snapshot?.text?.length ?? 0;
711
- const shortAnswer = initialLength > 0 && initialLength < 16;
712
- const mediumAnswer = initialLength >= 16 && initialLength < 40;
747
+ const shortAnswer = initialLength > 0 && initialLength < ${MIN_CONFIDENT_ANSWER_LENGTH};
748
+ const mediumAnswer = initialLength >= ${MIN_CONFIDENT_ANSWER_LENGTH} && initialLength < 40;
713
749
  const longAnswer = initialLength >= 40 && initialLength < 500;
714
750
  const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
715
751
  const settleIntervalMs = 400;
@@ -783,11 +819,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
783
819
  })()`;
784
820
  }
785
821
  function buildAssistantExtractor(functionName) {
786
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
787
822
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
788
823
  return `const ${functionName} = () => {
789
824
  ${buildClickDispatcher()}
790
- const CONVERSATION_SELECTOR = ${conversationLiteral};
791
825
  const ASSISTANT_SELECTOR = ${assistantLiteral};
792
826
  const isAssistantTurn = (node) => {
793
827
  if (!(node instanceof HTMLElement)) return false;
@@ -823,7 +857,7 @@ function buildAssistantExtractor(functionName) {
823
857
  }
824
858
  };
825
859
 
826
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
860
+ const turns = ${buildConversationTurnListExpression()};
827
861
  for (let index = turns.length - 1; index >= 0; index -= 1) {
828
862
  const turn = turns[index];
829
863
  if (!isAssistantTurn(turn)) {
@@ -907,13 +941,10 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
907
941
  }
908
942
  }
909
943
  if (!root) return null;
910
- const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
911
- const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
944
+ const turnNodes = ${buildConversationTurnListExpression()};
912
945
  const hasTurns = turnNodes.length > 0;
913
946
  const resolveTurnIndex = (node) => {
914
- const turn = node?.closest?.(CONVERSATION_SELECTOR);
915
- if (!turn) return null;
916
- const idx = turnNodes.indexOf(turn);
947
+ const idx = turnNodes.findIndex((turn) => turn === node || turn.contains?.(node));
917
948
  return idx >= 0 ? idx : null;
918
949
  };
919
950
  const isAfterMinTurn = (node) => {
@@ -1041,7 +1072,7 @@ function buildCopyExpression(meta) {
1041
1072
  if (testId.includes('assistant')) return true;
1042
1073
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
1043
1074
  };
1044
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1075
+ const turns = ${buildConversationTurnListExpression()};
1045
1076
  for (let i = turns.length - 1; i >= 0; i -= 1) {
1046
1077
  const turn = turns[i];
1047
1078
  if (!isAssistantTurn(turn)) continue;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS, } from "../constants.js";
2
+ import { INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from "../constants.js";
3
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
3
4
  import { delay } from "../utils.js";
4
5
  import { logDomFailure } from "../domDebug.js";
5
6
  import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
@@ -1173,7 +1174,14 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1173
1174
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
1174
1175
  const deadline = Date.now() + timeoutMs;
1175
1176
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
1177
+ const expectedInputBasenames = expectedNormalized
1178
+ .map((name) => name.split("/").pop()?.split("\\").pop() ?? name)
1179
+ .map((name) => name.toLowerCase().replace(/\s+/g, " ").trim())
1180
+ .filter(Boolean);
1181
+ const expectedInputSignature = expectedInputBasenames.slice().sort().join("\0");
1176
1182
  let inputMatchSince = null;
1183
+ let inputOnlyReadySince = null;
1184
+ let inputOnlySignature = "";
1177
1185
  let sawInputMatch = false;
1178
1186
  let attachmentMatchSince = null;
1179
1187
  let lastVerboseLog = 0;
@@ -1466,6 +1474,26 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1466
1474
  else {
1467
1475
  attachmentMatchSince = null;
1468
1476
  }
1477
+ const inputOnlySignatureNow = inputNames.slice().sort().join("\0");
1478
+ const inputOnlyNamesSatisfied = expectedInputBasenames.length > 0 && inputOnlySignatureNow === expectedInputSignature;
1479
+ const inputOnlyReady = inputOnlyNamesSatisfied &&
1480
+ value.state === "ready" &&
1481
+ value.uploading === false &&
1482
+ !value.filesAttached &&
1483
+ fileCount === 0;
1484
+ if (inputOnlyReady) {
1485
+ if (inputOnlyReadySince === null || inputOnlySignature !== inputOnlySignatureNow) {
1486
+ inputOnlyReadySince = Date.now();
1487
+ inputOnlySignature = inputOnlySignatureNow;
1488
+ }
1489
+ if (Date.now() - inputOnlyReadySince > 1500) {
1490
+ return;
1491
+ }
1492
+ }
1493
+ else {
1494
+ inputOnlyReadySince = null;
1495
+ inputOnlySignature = "";
1496
+ }
1469
1497
  // Fallback: if the file input has the expected names, allow progress once that condition is stable.
1470
1498
  // Some ChatGPT surfaces only render the filename after sending the message.
1471
1499
  const inputMissing = expectedNormalized.filter((expected) => {
@@ -1478,7 +1506,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1478
1506
  // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1479
1507
  const inputStateOk = value.state === "ready" || value.state === "missing";
1480
1508
  const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1481
- const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
1509
+ const inputEvidenceOk = Boolean(value.filesAttached) || fileCountSatisfied;
1482
1510
  const stableThresholdMs = value.uploading ? 3000 : 1500;
1483
1511
  if (inputSeenNow && inputStateOk && inputEvidenceOk) {
1484
1512
  if (inputMatchSince === null) {
@@ -1567,14 +1595,12 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1567
1595
  throw new Error("Attachment was not present on the sent user message.");
1568
1596
  }
1569
1597
  function buildUserTurnAttachmentExpression(options) {
1570
- const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1571
1598
  const minTurnLiteral = options.minTurnIndex === null ? "null" : String(options.minTurnIndex);
1572
1599
  const expectedPromptLiteral = JSON.stringify(options.expectedPromptPrefix);
1573
1600
  const expectedConversationLiteral = options.expectedConversationId
1574
1601
  ? JSON.stringify(options.expectedConversationId)
1575
1602
  : "null";
1576
1603
  return `(() => {
1577
- const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
1578
1604
  const MIN_TURN_INDEX = ${minTurnLiteral};
1579
1605
  const EXPECTED_PROMPT_PREFIX = ${expectedPromptLiteral};
1580
1606
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
@@ -1587,7 +1613,7 @@ function buildUserTurnAttachmentExpression(options) {
1587
1613
  ) {
1588
1614
  return { ok: false, conversationMismatch: true };
1589
1615
  }
1590
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1616
+ const turns = ${buildConversationTurnListExpression()};
1591
1617
  const userTurns = turns.map((node, index) => ({ node, index })).filter(({ node }) => {
1592
1618
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
1593
1619
  if (attr === 'user') return true;