@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.
- package/dist/bin/oracle-cli.js +14 -6
- package/dist/docs-site/bridge.html +17 -1
- package/dist/docs-site/browser-mode.html +2 -2
- package/dist/docs-site/configuration.html +12 -2
- package/dist/docs-site/openai-endpoints.html +12 -0
- package/dist/scripts/test-browser.js +13 -2
- package/dist/src/browser/actions/assistantResponse.js +81 -50
- package/dist/src/browser/actions/attachments.js +31 -5
- package/dist/src/browser/actions/deepResearch.js +218 -73
- package/dist/src/browser/actions/modelSelection.js +30 -7
- package/dist/src/browser/actions/promptComposer.js +75 -19
- package/dist/src/browser/actions/thinkingStatus.js +19 -1
- package/dist/src/browser/artifacts.js +191 -6
- package/dist/src/browser/chatgptFiles.js +529 -98
- package/dist/src/browser/chatgptImages.js +3 -4
- package/dist/src/browser/chromeLifecycle.js +1 -0
- package/dist/src/browser/constants.js +6 -0
- package/dist/src/browser/conversationTurns.js +16 -0
- package/dist/src/browser/conversationUrlMonitor.js +64 -0
- package/dist/src/browser/cookies.js +72 -0
- package/dist/src/browser/index.js +103 -94
- package/dist/src/browser/projectSourcesRunner.js +3 -2
- package/dist/src/browser/reattach.js +27 -11
- package/dist/src/browser/reattachHelpers.js +14 -5
- package/dist/src/browser/sessionRunner.js +9 -3
- package/dist/src/cli/bridge/client.js +4 -1
- package/dist/src/cli/bridge/doctor.js +19 -0
- package/dist/src/cli/runOptions.js +11 -2
- package/dist/src/cli/sessionDisplay.js +6 -1
- package/dist/src/cli/sessionRunner.js +28 -10
- package/dist/src/config.js +3 -0
- package/dist/src/oracle/client.js +2 -0
- package/dist/src/oracle/modelResolver.js +85 -0
- package/dist/src/oracle/multiModelRunner.js +4 -1
- package/dist/src/oracle/oscProgress.js +3 -2
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/remote/client.js +253 -22
- package/dist/src/remote/health.js +27 -0
- package/dist/src/remote/server.js +239 -4
- package/dist/src/remote/types.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +20 -20
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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/<runId>/artifacts/<artifactId></code> with the same bridge bearer token, writes to <code>~/.oracle/sessions/<sessionId>/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
|
|
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 <id> --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/<id>/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 <path></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'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(),
|
|
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,
|
|
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
|
-
|
|
48
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
364
|
-
const mediumAnswer = currentLength >=
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 <
|
|
712
|
-
const mediumAnswer = initialLength >=
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 {
|
|
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) ||
|
|
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 =
|
|
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;
|