@steipete/oracle 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/oracle-cli.js +1 -1
- package/dist/src/browser/actions/assistantResponse.js +218 -126
- package/dist/src/browser/actions/modelSelection.js +133 -22
- package/dist/src/browser/actions/navigation.js +235 -14
- package/dist/src/browser/actions/thinkingStatus.js +228 -0
- package/dist/src/browser/actions/thinkingTime.js +68 -11
- package/dist/src/browser/chromeLifecycle.js +29 -28
- package/dist/src/browser/config.js +14 -1
- package/dist/src/browser/constants.js +5 -1
- package/dist/src/browser/controlPlan.js +2 -2
- package/dist/src/browser/index.js +56 -16
- package/dist/src/browser/liveTabs.js +113 -31
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/projectSourcesRunner.js +4 -4
- package/dist/src/browser/reattach.js +2 -2
- package/dist/src/browser/recoverConversation.js +90 -29
- package/dist/src/cli/browserConfig.js +5 -1
- package/dist/src/cli/browserTabs.js +54 -33
- package/dist/src/cli/options.js +24 -0
- package/dist/src/cli/runOptions.js +6 -1
- package/dist/src/cli/sessionDisplay.js +38 -14
- package/dist/src/oracle/config.js +25 -0
- package/dist/src/oracle/geminiModels.js +2 -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 +9 -10
- 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.js +0 -569
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +0 -1
- package/dist/docs-site/RELEASING.html +0 -410
- package/dist/docs-site/agents.html +0 -374
- package/dist/docs-site/anthropic.html +0 -368
- package/dist/docs-site/bridge.html +0 -416
- package/dist/docs-site/browser-mode.html +0 -594
- package/dist/docs-site/chromium-forks.html +0 -347
- package/dist/docs-site/cli-reference.html +0 -346
- package/dist/docs-site/configuration.html +0 -462
- package/dist/docs-site/favicon.svg +0 -14
- package/dist/docs-site/followup.html +0 -375
- package/dist/docs-site/gemini.html +0 -383
- package/dist/docs-site/grok.html +0 -325
- package/dist/docs-site/index.html +0 -360
- package/dist/docs-site/install.html +0 -335
- package/dist/docs-site/linux.html +0 -321
- package/dist/docs-site/llms.txt +0 -43
- package/dist/docs-site/manual-tests.html +0 -596
- package/dist/docs-site/mcp.html +0 -391
- package/dist/docs-site/multimodel.html +0 -364
- package/dist/docs-site/mythical-pro-agents.html +0 -360
- package/dist/docs-site/notifier.html +0 -338
- package/dist/docs-site/openai-endpoints.html +0 -399
- package/dist/docs-site/openrouter.html +0 -344
- package/dist/docs-site/quickstart.html +0 -369
- package/dist/docs-site/refactor/ux.html +0 -532
- package/dist/docs-site/sessions.html +0 -388
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +0 -79
- package/dist/docs-site/spec.html +0 -363
- package/dist/docs-site/testing.html +0 -320
- package/dist/docs-site/tui-debug.html +0 -326
- package/dist/docs-site/windows-work.html +0 -323
- package/dist/docs-site/windows.html +0 -320
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/windowsCookies.js +0 -219
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -227,7 +227,7 @@ program
|
|
|
227
227
|
.addOption(new Option("--copy-markdown", "Copy the assembled markdown bundle to the clipboard; pair with --render to print it too.").default(false))
|
|
228
228
|
.addOption(new Option("--copy").hideHelp().default(false))
|
|
229
229
|
.option("-s, --slug <words>", "Custom session slug (3-5 words).")
|
|
230
|
-
.option("-m, --model <model>", 'Model to target (gpt-5.5-pro default). Also gpt-5.5, gpt-5.4-pro, gpt-5.4, gpt-5.1-pro, gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3.1-flash-lite, gemini-3.5-flash, gemini-3.1-pro, legacy gemini-3-pro, claude-4.6-sonnet, claude-4.1-opus, or ChatGPT labels like "5.5 Pro" / "5.2 Thinking" for browser runs).', normalizeModelOption)
|
|
230
|
+
.option("-m, --model <model>", 'Model to target (gpt-5.5-pro default). GPT-5.6 aliases: gpt-5.6 and gpt-5.6-sol (OpenAI API or ChatGPT browser). Also gpt-5.5, gpt-5.4-pro, gpt-5.4, gpt-5.1-pro, gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3.1-flash-lite, gemini-3.5-flash, gemini-3.1-pro, legacy gemini-3-pro, claude-4.6-sonnet, claude-4.1-opus, or ChatGPT labels like "5.5 Pro" / "5.2 Thinking" for browser runs).', normalizeModelOption)
|
|
231
231
|
.addOption(new Option("--models <models>", 'Comma-separated API model list to query in parallel (e.g., "gpt-5.5-pro,gemini-3-pro").')
|
|
232
232
|
.argParser(collectModelList)
|
|
233
233
|
.default([]))
|
|
@@ -1,19 +1,60 @@
|
|
|
1
1
|
import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
|
|
2
2
|
import { buildConversationTurnListExpression } from "../conversationTurns.js";
|
|
3
|
+
import { buildThinkingActivePredicateJs, readThinkingActivity } from "./thinkingStatus.js";
|
|
3
4
|
import { delay } from "../utils.js";
|
|
4
5
|
import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression, } from "../domDebug.js";
|
|
5
6
|
import { buildClickDispatcher } from "./domEvents.js";
|
|
6
7
|
const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
|
|
7
8
|
const STOP_CONTROL_SELECTOR = STOP_BUTTON_SELECTORS.join(", ");
|
|
9
|
+
// Still used by the in-page settle heuristic's length buckets (see buildResponseObserverExpression).
|
|
8
10
|
const MIN_CONFIDENT_ANSWER_LENGTH = 16;
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
+
function readPositiveIntEnv(name, fallback) {
|
|
12
|
+
const raw = Number(process.env[name]);
|
|
13
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const TERMINAL_GATE_CONFIG = {
|
|
16
|
+
barConfirmCycles: readPositiveIntEnv("ORACLE_BAR_CONFIRM_CYCLES", 3),
|
|
17
|
+
minStableMs: readPositiveIntEnv("ORACLE_TERMINAL_MIN_STABLE_MS", 1_200),
|
|
18
|
+
};
|
|
19
|
+
export function createTerminalGateState(now) {
|
|
20
|
+
return {
|
|
21
|
+
lastKey: "",
|
|
22
|
+
lastChangeAt: now,
|
|
23
|
+
barStableCycles: 0,
|
|
24
|
+
seen: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// Pure, unit-testable per-cycle classifier. Feed it one sample every poll; when it returns
|
|
28
|
+
// terminal:true the capture is proven complete and safe to finalize.
|
|
29
|
+
export function classifyTurnTerminal(state, sample, config) {
|
|
30
|
+
const changed = !state.seen || sample.contentKey !== state.lastKey;
|
|
31
|
+
const lastChangeAt = changed ? sample.now : state.lastChangeAt;
|
|
32
|
+
// proofA debounce: weak/stale sidecar evidence may be overridden, but strong live activity
|
|
33
|
+
// must reset the debounce. It also resets on ANY content change so a bar that appears while
|
|
34
|
+
// the answer is still rendering (the transient-bar / first-tokens race) cannot finalize.
|
|
35
|
+
const barStableCycles = sample.barVisible && !sample.stopVisible && !sample.strongThinkingActive && !changed
|
|
36
|
+
? state.barStableCycles + 1
|
|
37
|
+
: 0;
|
|
38
|
+
const next = {
|
|
39
|
+
lastKey: sample.contentKey,
|
|
40
|
+
lastChangeAt,
|
|
41
|
+
barStableCycles,
|
|
42
|
+
seen: true,
|
|
43
|
+
};
|
|
44
|
+
let terminal = false;
|
|
45
|
+
if (!sample.stopVisible && sample.len > 0) {
|
|
46
|
+
const stableMs = sample.now - lastChangeAt;
|
|
47
|
+
// Debounced action bar AND content stable for a minimum time. The time-stability
|
|
48
|
+
// requirement guards the documented race where finished-action controls surface while only
|
|
49
|
+
// the first tokens have rendered. Weak sidecar evidence cannot hang a finished turn, but
|
|
50
|
+
// strong live activity vetoes this proof and restarts its debounce.
|
|
51
|
+
terminal =
|
|
52
|
+
sample.barVisible &&
|
|
53
|
+
!sample.strongThinkingActive &&
|
|
54
|
+
barStableCycles >= config.barConfirmCycles &&
|
|
55
|
+
stableMs >= config.minStableMs;
|
|
15
56
|
}
|
|
16
|
-
return
|
|
57
|
+
return { state: next, terminal };
|
|
17
58
|
}
|
|
18
59
|
const THINKING_STATUS_LABELS = [
|
|
19
60
|
"thinking",
|
|
@@ -36,12 +77,14 @@ function matchesThinkingStatusLabel(trimmed) {
|
|
|
36
77
|
return false;
|
|
37
78
|
if (THINKING_STATUS_LABELS.includes(trimmed))
|
|
38
79
|
return true;
|
|
39
|
-
|
|
80
|
+
// includes, not startsWith: the completed summary can carry a heading prefix
|
|
81
|
+
// ("Reasoning Thought for 12s"); the length cap keeps real answers out.
|
|
82
|
+
if (trimmed.includes("thought for ") && trimmed.length <= 40)
|
|
40
83
|
return true;
|
|
41
84
|
return trimmed.startsWith("pro thinking") && trimmed.length <= 40;
|
|
42
85
|
}
|
|
43
|
-
function isAnswerNowPlaceholderText(
|
|
44
|
-
const text =
|
|
86
|
+
export function isAnswerNowPlaceholderText(value) {
|
|
87
|
+
const text = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
45
88
|
if (!text)
|
|
46
89
|
return false;
|
|
47
90
|
// Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
|
|
@@ -63,7 +106,7 @@ function buildActiveThinkingStatusPredicateJs(fnName) {
|
|
|
63
106
|
const labels = ${labelsLiteral};
|
|
64
107
|
const matches =
|
|
65
108
|
labels.includes(normalized) ||
|
|
66
|
-
(normalized.
|
|
109
|
+
(normalized.includes('thought for ') && normalized.length <= 40) ||
|
|
67
110
|
(normalized.startsWith('pro thinking') && normalized.length <= 40);
|
|
68
111
|
return matches && isStopControlVisible();
|
|
69
112
|
};`;
|
|
@@ -172,39 +215,32 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
|
|
|
172
215
|
logger("Captured assistant generated image response");
|
|
173
216
|
return candidate;
|
|
174
217
|
}
|
|
175
|
-
// The
|
|
218
|
+
// The observer/refresh path can race ahead of true completion: a settled GPT-5.5 Pro
|
|
219
|
+
// preamble (or any mid-stream capture) looks done for a moment before the reasoning/tool
|
|
220
|
+
// phase begins. Re-confirm EVERY captured text through the terminal-only poller, which
|
|
221
|
+
// finalizes only on positive proof (a debounced action bar, or a quiet window with no
|
|
222
|
+
// active thinking). We deliberately drop the old ">= candidate length" acceptance: the
|
|
223
|
+
// poller is turn-scoped (minTurnIndex), so whatever it proves terminal is the right turn,
|
|
224
|
+
// even when the real answer is shorter than a verbose preamble.
|
|
176
225
|
const elapsedMs = Date.now() - start;
|
|
177
226
|
const remainingMs = Math.max(0, timeoutMs - elapsedMs);
|
|
178
|
-
const candidateText = String(candidate?.text ?? "").trim();
|
|
179
|
-
const suspiciouslyShort = isImplausiblyShortAnswer(candidateText.length);
|
|
180
227
|
if (remainingMs > 0) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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");
|
|
198
|
-
const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
|
|
199
|
-
if (completed && String(completed.text ?? "").trim().length >= candidateText.length) {
|
|
200
|
-
return completed;
|
|
201
|
-
}
|
|
228
|
+
logger("Confirming the capture is terminal (not a mid-stream/preamble capture)");
|
|
229
|
+
const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
|
|
230
|
+
if (completed) {
|
|
231
|
+
return completed;
|
|
202
232
|
}
|
|
233
|
+
// Could not prove completion within the budget: refuse rather than finalize a possibly
|
|
234
|
+
// incomplete capture. A clean, fast failure is recoverable (retry/salvage) and never
|
|
235
|
+
// ships a preamble as if it were the answer.
|
|
236
|
+
await logDomFailure(Runtime, logger, "assistant-response-unconfirmed");
|
|
237
|
+
throw new Error("assistant-response could not be confirmed complete before timeout; refusing to finalize a possibly-incomplete capture");
|
|
203
238
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
// Budget already exhausted before we could confirm: refuse rather than fall through and ship
|
|
240
|
+
// an unconfirmed capture. A settled preamble that arrived near the deadline must not be
|
|
241
|
+
// finalized just because there was no time left to prove it terminal.
|
|
242
|
+
await logDomFailure(Runtime, logger, "assistant-response-unconfirmed");
|
|
243
|
+
throw new Error("assistant-response could not be confirmed complete before the deadline; refusing to finalize a possibly-incomplete capture");
|
|
208
244
|
}
|
|
209
245
|
export async function readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId) {
|
|
210
246
|
const { result } = await Runtime.evaluate({
|
|
@@ -272,13 +308,25 @@ async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex
|
|
|
272
308
|
return normalizeAssistantSnapshot(snapshot);
|
|
273
309
|
}, recoveryTimeoutMs, 400);
|
|
274
310
|
if (recovered) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
311
|
+
// Route EVERY recovered snapshot through the terminal-only poller (not just short ones):
|
|
312
|
+
// a recovered long preamble is exactly the raw-return bug this gate exists to prevent.
|
|
313
|
+
logger("Recovered a candidate response; confirming it is terminal before finalizing");
|
|
314
|
+
const remainingMs = Math.max(0, recoveryTimeoutMs - (Date.now() - recoveryStartedAt));
|
|
315
|
+
if (remainingMs > 0) {
|
|
316
|
+
const confirmed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
|
|
317
|
+
if (confirmed) {
|
|
318
|
+
logger("Recovered and confirmed assistant response via polling fallback");
|
|
319
|
+
return confirmed;
|
|
320
|
+
}
|
|
321
|
+
// Unconfirmable within budget: refuse (return null) so the caller fails fast instead
|
|
322
|
+
// of finalizing a possibly-incomplete recovered capture.
|
|
323
|
+
await logConversationSnapshot(Runtime, logger).catch(() => undefined);
|
|
324
|
+
return null;
|
|
279
325
|
}
|
|
280
|
-
|
|
281
|
-
|
|
326
|
+
// No confirmation time left: refuse rather than return the unconfirmed recovered snapshot
|
|
327
|
+
// (returning it raw would reopen the recovered-long-preamble leak this gate closes).
|
|
328
|
+
await logConversationSnapshot(Runtime, logger).catch(() => undefined);
|
|
329
|
+
return null;
|
|
282
330
|
}
|
|
283
331
|
await logConversationSnapshot(Runtime, logger).catch(() => undefined);
|
|
284
332
|
return null;
|
|
@@ -366,9 +414,7 @@ async function terminateRuntimeExecution(Runtime) {
|
|
|
366
414
|
}
|
|
367
415
|
async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, abortSignal) {
|
|
368
416
|
const watchdogDeadline = Date.now() + timeoutMs;
|
|
369
|
-
let
|
|
370
|
-
let stableCycles = 0;
|
|
371
|
-
let lastChangeAt = Date.now();
|
|
417
|
+
let gate = createTerminalGateState(Date.now());
|
|
372
418
|
while (Date.now() < watchdogDeadline) {
|
|
373
419
|
// Check abort signal to stop polling when another path won the race
|
|
374
420
|
if (abortSignal?.aborted) {
|
|
@@ -377,44 +423,34 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
|
|
|
377
423
|
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
|
|
378
424
|
const normalized = normalizeAssistantSnapshot(snapshot);
|
|
379
425
|
if (normalized) {
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
stableCycles = 0;
|
|
384
|
-
lastChangeAt = Date.now();
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
stableCycles += 1;
|
|
426
|
+
// Generated-image answers stream no text and mount no action bar; accept immediately.
|
|
427
|
+
if (isGeneratedImageAssistantAnswer(normalized)) {
|
|
428
|
+
return normalized;
|
|
388
429
|
}
|
|
389
|
-
const [stopVisible,
|
|
430
|
+
const [stopVisible, barVisible, thinkingActivity] = await Promise.all([
|
|
390
431
|
isStopButtonVisible(Runtime),
|
|
391
|
-
isCompletionVisible(Runtime),
|
|
432
|
+
isCompletionVisible(Runtime, normalized.meta, minTurnIndex),
|
|
433
|
+
readThinkingActivity(Runtime),
|
|
392
434
|
]);
|
|
393
|
-
|
|
435
|
+
const decision = classifyTurnTerminal(gate, {
|
|
436
|
+
now: Date.now(),
|
|
437
|
+
len: normalized.text.length,
|
|
438
|
+
// Fingerprint = turn/message identity + the full text, so a same-length rewrite, a
|
|
439
|
+
// shorter final answer replacing a longer preamble, or a new turn all count as change.
|
|
440
|
+
contentKey: `${normalized.meta.messageId ?? normalized.meta.turnId ?? ""}::${normalized.text}`,
|
|
441
|
+
stopVisible,
|
|
442
|
+
barVisible,
|
|
443
|
+
strongThinkingActive: thinkingActivity.strong,
|
|
444
|
+
}, TERMINAL_GATE_CONFIG);
|
|
445
|
+
gate = decision.state;
|
|
446
|
+
if (decision.terminal) {
|
|
394
447
|
return normalized;
|
|
395
448
|
}
|
|
396
|
-
const shortAnswer = isImplausiblyShortAnswer(currentLength);
|
|
397
|
-
const mediumAnswer = currentLength >= MIN_CONFIDENT_ANSWER_LENGTH && currentLength < 40;
|
|
398
|
-
const longAnswer = currentLength >= 40 && currentLength < 500;
|
|
399
|
-
// Learned: short answers need a longer stability window or they truncate.
|
|
400
|
-
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
401
|
-
// use progressively longer windows to avoid truncation (#71).
|
|
402
|
-
const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
|
|
403
|
-
const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
|
|
404
|
-
const stableMs = Date.now() - lastChangeAt;
|
|
405
|
-
const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
|
|
406
|
-
// Require stop button to disappear before treating completion as final.
|
|
407
|
-
if (!stopVisible) {
|
|
408
|
-
const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
409
|
-
const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
|
|
410
|
-
if (completionEnough || (!shortAnswer && stableEnough)) {
|
|
411
|
-
return normalized;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
449
|
}
|
|
415
450
|
else {
|
|
416
|
-
|
|
417
|
-
|
|
451
|
+
// The turn disappeared/reset (navigation, re-render): restart the gate so a stale
|
|
452
|
+
// action-bar debounce cannot carry over onto a fresh turn.
|
|
453
|
+
gate = createTerminalGateState(Date.now());
|
|
418
454
|
}
|
|
419
455
|
await delay(400);
|
|
420
456
|
}
|
|
@@ -456,43 +492,70 @@ function buildStopButtonVisibilityPredicateJs(fnName) {
|
|
|
456
492
|
};`;
|
|
457
493
|
}
|
|
458
494
|
export const buildStopButtonVisibilityExpressionForTest = buildStopButtonVisibilityExpression;
|
|
459
|
-
|
|
495
|
+
function buildCompletionVisibilityExpression(meta, minTurnIndex) {
|
|
496
|
+
const expectedMessageId = meta.messageId ? JSON.stringify(meta.messageId) : "null";
|
|
497
|
+
const expectedTurnId = meta.turnId ? JSON.stringify(meta.turnId) : "null";
|
|
498
|
+
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
499
|
+
? Math.floor(minTurnIndex)
|
|
500
|
+
: -1;
|
|
501
|
+
return `(() => {
|
|
502
|
+
const EXPECTED_MESSAGE_ID = ${expectedMessageId};
|
|
503
|
+
const EXPECTED_TURN_ID = ${expectedTurnId};
|
|
504
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
505
|
+
// Find the LAST assistant turn to check completion status. Must match the same logic as
|
|
506
|
+
// buildAssistantExtractor, then correlate the controls to the sampled response.
|
|
507
|
+
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
508
|
+
const isAssistantTurn = (node) => {
|
|
509
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
510
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
511
|
+
if (turnAttr === 'assistant') return true;
|
|
512
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
513
|
+
if (role === 'assistant') return true;
|
|
514
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
515
|
+
if (testId.includes('assistant')) return true;
|
|
516
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const turns = ${buildConversationTurnListExpression()};
|
|
520
|
+
let lastAssistantTurn = null;
|
|
521
|
+
let lastAssistantIndex = -1;
|
|
522
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
523
|
+
if (isAssistantTurn(turns[i])) {
|
|
524
|
+
lastAssistantTurn = turns[i];
|
|
525
|
+
lastAssistantIndex = i;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (!lastAssistantTurn) return false;
|
|
530
|
+
|
|
531
|
+
const hasExpectedIdentity = Boolean(EXPECTED_MESSAGE_ID || EXPECTED_TURN_ID);
|
|
532
|
+
if (hasExpectedIdentity) {
|
|
533
|
+
const identityNodes = [
|
|
534
|
+
lastAssistantTurn,
|
|
535
|
+
...Array.from(lastAssistantTurn.querySelectorAll('[data-message-id], [data-testid]')),
|
|
536
|
+
];
|
|
537
|
+
const identityMatches = identityNodes.some((node) =>
|
|
538
|
+
(EXPECTED_MESSAGE_ID && node.getAttribute?.('data-message-id') === EXPECTED_MESSAGE_ID) ||
|
|
539
|
+
(EXPECTED_TURN_ID && node.getAttribute?.('data-testid') === EXPECTED_TURN_ID),
|
|
540
|
+
);
|
|
541
|
+
if (!identityMatches) return false;
|
|
542
|
+
} else if (MIN_TURN_INDEX < 0 || lastAssistantIndex < MIN_TURN_INDEX) {
|
|
543
|
+
// Fallback/project snapshots without an identity may use the new-turn baseline, but an
|
|
544
|
+
// uncorrelated persistent action bar from an older turn must never prove completion.
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) return true;
|
|
549
|
+
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
550
|
+
return Array.from(markdowns).some((node) => (node.textContent || '').trim() === 'Done');
|
|
551
|
+
})()`;
|
|
552
|
+
}
|
|
553
|
+
async function isCompletionVisible(Runtime, meta, minTurnIndex) {
|
|
554
|
+
if (hasScopedCompletionProof(meta))
|
|
555
|
+
return true;
|
|
460
556
|
try {
|
|
461
557
|
const { result } = await Runtime.evaluate({
|
|
462
|
-
expression:
|
|
463
|
-
// Find the LAST assistant turn to check completion status
|
|
464
|
-
// Must match the same logic as buildAssistantExtractor for consistency
|
|
465
|
-
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
466
|
-
const isAssistantTurn = (node) => {
|
|
467
|
-
if (!(node instanceof HTMLElement)) return false;
|
|
468
|
-
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
469
|
-
if (turnAttr === 'assistant') return true;
|
|
470
|
-
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
471
|
-
if (role === 'assistant') return true;
|
|
472
|
-
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
473
|
-
if (testId.includes('assistant')) return true;
|
|
474
|
-
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
const turns = ${buildConversationTurnListExpression()};
|
|
478
|
-
let lastAssistantTurn = null;
|
|
479
|
-
for (let i = turns.length - 1; i >= 0; i--) {
|
|
480
|
-
if (isAssistantTurn(turns[i])) {
|
|
481
|
-
lastAssistantTurn = turns[i];
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
if (!lastAssistantTurn) {
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
// Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
|
|
489
|
-
if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
|
|
490
|
-
return true;
|
|
491
|
-
}
|
|
492
|
-
// Also check for "Done" text in the last assistant turn's markdown
|
|
493
|
-
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
494
|
-
return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
|
|
495
|
-
})()`,
|
|
558
|
+
expression: buildCompletionVisibilityExpression(meta, minTurnIndex),
|
|
496
559
|
returnByValue: true,
|
|
497
560
|
});
|
|
498
561
|
return Boolean(result?.value);
|
|
@@ -501,6 +564,10 @@ async function isCompletionVisible(Runtime) {
|
|
|
501
564
|
return false;
|
|
502
565
|
}
|
|
503
566
|
}
|
|
567
|
+
export function hasScopedCompletionProof(meta) {
|
|
568
|
+
return meta.completionVisible === true;
|
|
569
|
+
}
|
|
570
|
+
export const buildCompletionVisibilityExpressionForTest = buildCompletionVisibilityExpression;
|
|
504
571
|
function normalizeAssistantSnapshot(snapshot) {
|
|
505
572
|
const text = snapshot?.text ? cleanAssistantText(snapshot.text) : "";
|
|
506
573
|
if (!text.trim()) {
|
|
@@ -519,7 +586,11 @@ function normalizeAssistantSnapshot(snapshot) {
|
|
|
519
586
|
return {
|
|
520
587
|
text,
|
|
521
588
|
html: snapshot?.html ?? undefined,
|
|
522
|
-
meta: {
|
|
589
|
+
meta: {
|
|
590
|
+
turnId: snapshot?.turnId ?? undefined,
|
|
591
|
+
messageId: snapshot?.messageId ?? undefined,
|
|
592
|
+
...(snapshot?.completionVisible === true ? { completionVisible: true } : {}),
|
|
593
|
+
},
|
|
523
594
|
};
|
|
524
595
|
}
|
|
525
596
|
function isGeneratedImageAssistantAnswer(answer) {
|
|
@@ -620,6 +691,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
|
|
|
620
691
|
return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
|
|
621
692
|
};
|
|
622
693
|
${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")}
|
|
694
|
+
${buildThinkingActivePredicateJs("isThinkingActiveNow")}
|
|
623
695
|
|
|
624
696
|
// Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency.
|
|
625
697
|
const isAssistantTurn = (node) => {
|
|
@@ -786,8 +858,15 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
|
|
|
786
858
|
}
|
|
787
859
|
const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
|
|
788
860
|
const finishedVisible = isLastAssistantTurnFinished();
|
|
861
|
+
// Defense in depth (the node side re-confirms every capture): never settle on a
|
|
862
|
+
// stable-but-quiet candidate while the model is actively thinking/generating, so the
|
|
863
|
+
// observer does not hand a settled preamble to the node path during the reasoning gap.
|
|
864
|
+
const thinkingActiveNow = isThinkingActiveNow();
|
|
789
865
|
|
|
790
|
-
if (
|
|
866
|
+
if (
|
|
867
|
+
finishedVisible ||
|
|
868
|
+
(!stopVisible && !thinkingActiveNow && stableCycles >= stableTarget)
|
|
869
|
+
) {
|
|
791
870
|
break;
|
|
792
871
|
}
|
|
793
872
|
}
|
|
@@ -892,7 +971,7 @@ function buildAssistantExtractor(functionName) {
|
|
|
892
971
|
normalizedText === 'edit' ||
|
|
893
972
|
normalizedText === 'stopped thinking' ||
|
|
894
973
|
normalizedText === 'stopped thinking edit' ||
|
|
895
|
-
/^thought for \\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\\s+edit$/.test(normalizedText);
|
|
974
|
+
/^(?:reasoning\\s+|pro thinking\\s+)?thought for \\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\\s+edit$/.test(normalizedText);
|
|
896
975
|
if (generatedImages.length > 0 && imageOnlyChrome) {
|
|
897
976
|
const label = generatedImages.length === 1 ? 'Generated image.' : \`Generated \${generatedImages.length} images.\`;
|
|
898
977
|
return { text: label, html: messageRoot?.innerHTML ?? html, messageId, turnId, turnIndex: index };
|
|
@@ -947,20 +1026,26 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
|
947
1026
|
const idx = turnNodes.findIndex((turn) => turn === node || turn.contains?.(node));
|
|
948
1027
|
return idx >= 0 ? idx : null;
|
|
949
1028
|
};
|
|
1029
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
1030
|
+
const collectLastUser = (scope) => {
|
|
1031
|
+
if (!scope?.querySelectorAll) return null;
|
|
1032
|
+
const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
|
|
1033
|
+
return userTurns[userTurns.length - 1] ?? null;
|
|
1034
|
+
};
|
|
1035
|
+
const lastUser = collectLastUser(root) || collectLastUser(document);
|
|
1036
|
+
const userText = lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
|
|
1037
|
+
const isAfterCurrentUser = (node) => {
|
|
1038
|
+
if (!lastUser || typeof lastUser.compareDocumentPosition !== 'function') return false;
|
|
1039
|
+
// Node.DOCUMENT_POSITION_FOLLOWING = 4. Use the numeric bit so the injected expression
|
|
1040
|
+
// also works in stripped browser test contexts without a global Node constructor.
|
|
1041
|
+
return Boolean(lastUser.compareDocumentPosition(node) & 4);
|
|
1042
|
+
};
|
|
950
1043
|
const isAfterMinTurn = (node) => {
|
|
951
1044
|
if (__minTurn === null) return true;
|
|
952
|
-
if (!hasTurns) return
|
|
1045
|
+
if (!hasTurns) return isAfterCurrentUser(node);
|
|
953
1046
|
const idx = resolveTurnIndex(node);
|
|
954
1047
|
return idx !== null && idx >= __minTurn;
|
|
955
1048
|
};
|
|
956
|
-
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
957
|
-
const collectUserText = (scope) => {
|
|
958
|
-
if (!scope?.querySelectorAll) return '';
|
|
959
|
-
const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
|
|
960
|
-
const lastUser = userTurns[userTurns.length - 1];
|
|
961
|
-
return lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
|
|
962
|
-
};
|
|
963
|
-
const userText = collectUserText(root) || collectUserText(document);
|
|
964
1049
|
const isUserEcho = (text) => {
|
|
965
1050
|
if (!userText) return false;
|
|
966
1051
|
const normalized = normalize(text);
|
|
@@ -1031,7 +1116,14 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
|
1031
1116
|
if (isUserEcho(text)) continue;
|
|
1032
1117
|
const html = node.innerHTML ?? '';
|
|
1033
1118
|
const turnIndex = resolveTurnIndex(node);
|
|
1034
|
-
return {
|
|
1119
|
+
return {
|
|
1120
|
+
text,
|
|
1121
|
+
html,
|
|
1122
|
+
messageId: null,
|
|
1123
|
+
turnId: null,
|
|
1124
|
+
turnIndex,
|
|
1125
|
+
completionVisible: actionMarkdowns.includes(node),
|
|
1126
|
+
};
|
|
1035
1127
|
}
|
|
1036
1128
|
return null;
|
|
1037
1129
|
})`;
|