@steipete/oracle 0.15.1 → 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.
Files changed (78) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/scripts/test-browser.js +13 -2
  3. package/dist/src/browser/actions/assistantResponse.js +235 -121
  4. package/dist/src/browser/actions/attachments.js +3 -4
  5. package/dist/src/browser/actions/deepResearch.js +6 -6
  6. package/dist/src/browser/actions/modelSelection.js +133 -22
  7. package/dist/src/browser/actions/navigation.js +235 -14
  8. package/dist/src/browser/actions/promptComposer.js +4 -5
  9. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  10. package/dist/src/browser/actions/thinkingTime.js +68 -11
  11. package/dist/src/browser/chatgptFiles.js +4 -7
  12. package/dist/src/browser/chatgptImages.js +3 -4
  13. package/dist/src/browser/chromeLifecycle.js +30 -28
  14. package/dist/src/browser/config.js +14 -1
  15. package/dist/src/browser/constants.js +6 -1
  16. package/dist/src/browser/controlPlan.js +2 -2
  17. package/dist/src/browser/conversationTurns.js +16 -0
  18. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  19. package/dist/src/browser/cookies.js +72 -0
  20. package/dist/src/browser/index.js +121 -72
  21. package/dist/src/browser/liveTabs.js +113 -31
  22. package/dist/src/browser/pageActions.js +1 -1
  23. package/dist/src/browser/projectSourcesRunner.js +7 -6
  24. package/dist/src/browser/reattach.js +29 -13
  25. package/dist/src/browser/reattachHelpers.js +14 -5
  26. package/dist/src/browser/recoverConversation.js +90 -29
  27. package/dist/src/cli/browserConfig.js +5 -1
  28. package/dist/src/cli/browserTabs.js +54 -33
  29. package/dist/src/cli/options.js +24 -0
  30. package/dist/src/cli/runOptions.js +6 -1
  31. package/dist/src/cli/sessionDisplay.js +38 -14
  32. package/dist/src/oracle/config.js +25 -0
  33. package/dist/src/oracle/geminiModels.js +2 -0
  34. package/dist/src/oracle/oscProgress.js +3 -2
  35. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  37. package/package.json +21 -22
  38. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  40. package/dist/bin/oracle.js +0 -569
  41. package/dist/docs-site/.nojekyll +0 -0
  42. package/dist/docs-site/CNAME +0 -1
  43. package/dist/docs-site/RELEASING.html +0 -410
  44. package/dist/docs-site/agents.html +0 -374
  45. package/dist/docs-site/anthropic.html +0 -368
  46. package/dist/docs-site/bridge.html +0 -416
  47. package/dist/docs-site/browser-mode.html +0 -594
  48. package/dist/docs-site/chromium-forks.html +0 -347
  49. package/dist/docs-site/cli-reference.html +0 -346
  50. package/dist/docs-site/configuration.html +0 -462
  51. package/dist/docs-site/favicon.svg +0 -14
  52. package/dist/docs-site/followup.html +0 -375
  53. package/dist/docs-site/gemini.html +0 -383
  54. package/dist/docs-site/grok.html +0 -325
  55. package/dist/docs-site/index.html +0 -360
  56. package/dist/docs-site/install.html +0 -335
  57. package/dist/docs-site/linux.html +0 -321
  58. package/dist/docs-site/llms.txt +0 -43
  59. package/dist/docs-site/manual-tests.html +0 -596
  60. package/dist/docs-site/mcp.html +0 -391
  61. package/dist/docs-site/multimodel.html +0 -364
  62. package/dist/docs-site/mythical-pro-agents.html +0 -360
  63. package/dist/docs-site/notifier.html +0 -338
  64. package/dist/docs-site/openai-endpoints.html +0 -399
  65. package/dist/docs-site/openrouter.html +0 -344
  66. package/dist/docs-site/quickstart.html +0 -369
  67. package/dist/docs-site/refactor/ux.html +0 -532
  68. package/dist/docs-site/sessions.html +0 -388
  69. package/dist/docs-site/social-card.png +0 -0
  70. package/dist/docs-site/social-card.svg +0 -79
  71. package/dist/docs-site/spec.html +0 -363
  72. package/dist/docs-site/testing.html +0 -320
  73. package/dist/docs-site/tui-debug.html +0 -326
  74. package/dist/docs-site/windows-work.html +0 -323
  75. package/dist/docs-site/windows.html +0 -320
  76. package/dist/src/browser/chromeCookies.js +0 -312
  77. package/dist/src/browser/keytarShim.js +0 -56
  78. package/dist/src/browser/windowsCookies.js +0 -219
@@ -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([]))
@@ -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,9 +1,61 @@
1
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";
3
+ import { buildThinkingActivePredicateJs, readThinkingActivity } from "./thinkingStatus.js";
2
4
  import { delay } from "../utils.js";
3
5
  import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression, } from "../domDebug.js";
4
6
  import { buildClickDispatcher } from "./domEvents.js";
5
7
  const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
6
8
  const STOP_CONTROL_SELECTOR = STOP_BUTTON_SELECTORS.join(", ");
9
+ // Still used by the in-page settle heuristic's length buckets (see buildResponseObserverExpression).
10
+ const MIN_CONFIDENT_ANSWER_LENGTH = 16;
11
+ function readPositiveIntEnv(name, fallback) {
12
+ const raw = Number(process.env[name]);
13
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
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;
56
+ }
57
+ return { state: next, terminal };
58
+ }
7
59
  const THINKING_STATUS_LABELS = [
8
60
  "thinking",
9
61
  "pro thinking",
@@ -25,12 +77,14 @@ function matchesThinkingStatusLabel(trimmed) {
25
77
  return false;
26
78
  if (THINKING_STATUS_LABELS.includes(trimmed))
27
79
  return true;
28
- if (trimmed.startsWith("thought for ") && trimmed.length <= 40)
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)
29
83
  return true;
30
84
  return trimmed.startsWith("pro thinking") && trimmed.length <= 40;
31
85
  }
32
- function isAnswerNowPlaceholderText(normalized) {
33
- const text = normalized.trim();
86
+ export function isAnswerNowPlaceholderText(value) {
87
+ const text = value.toLowerCase().replace(/\s+/g, " ").trim();
34
88
  if (!text)
35
89
  return false;
36
90
  // Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
@@ -52,7 +106,7 @@ function buildActiveThinkingStatusPredicateJs(fnName) {
52
106
  const labels = ${labelsLiteral};
53
107
  const matches =
54
108
  labels.includes(normalized) ||
55
- (normalized.startsWith('thought for ') && normalized.length <= 40) ||
109
+ (normalized.includes('thought for ') && normalized.length <= 40) ||
56
110
  (normalized.startsWith('pro thinking') && normalized.length <= 40);
57
111
  return matches && isStopControlVisible();
58
112
  };`;
@@ -109,7 +163,9 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
109
163
  if (source === "poll" &&
110
164
  error instanceof Error &&
111
165
  error.message === ASSISTANT_POLL_TIMEOUT_ERROR) {
112
- evaluation = await evaluationPromise;
166
+ evaluationPromise.catch(() => undefined);
167
+ await terminateRuntimeExecution(Runtime);
168
+ throw error;
113
169
  }
114
170
  else if (source === "poll") {
115
171
  throw error;
@@ -159,29 +215,32 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
159
215
  logger("Captured assistant generated image response");
160
216
  return candidate;
161
217
  }
162
- // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
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.
163
225
  const elapsedMs = Date.now() - start;
164
226
  const remainingMs = Math.max(0, timeoutMs - elapsedMs);
165
227
  if (remainingMs > 0) {
166
- const [stopVisible, completionVisible] = await Promise.all([
167
- isStopButtonVisible(Runtime),
168
- isCompletionVisible(Runtime),
169
- ]);
170
- // Completion controls can appear briefly while Pro is still replacing its thinking UI.
171
- // Confirm every capture from that transition with the stability-based watchdog; a
172
- // partial first paragraph can be arbitrarily long.
173
- const candidateText = String(candidate?.text ?? "").trim();
174
- if (stopVisible || completionVisible) {
175
- logger(stopVisible
176
- ? "Assistant still generating; waiting for completion"
177
- : "Completion controls surfaced; confirming stable assistant response");
178
- const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
179
- if (completed && String(completed.text ?? "").trim().length >= candidateText.length) {
180
- return completed;
181
- }
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;
182
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");
183
238
  }
184
- return candidate;
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");
185
244
  }
186
245
  export async function readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId) {
187
246
  const { result } = await Runtime.evaluate({
@@ -243,13 +302,31 @@ async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex
243
302
  if (recoveryTimeoutMs === 0) {
244
303
  return null;
245
304
  }
305
+ const recoveryStartedAt = Date.now();
246
306
  const recovered = await waitForCondition(async () => {
247
307
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
248
308
  return normalizeAssistantSnapshot(snapshot);
249
309
  }, recoveryTimeoutMs, 400);
250
310
  if (recovered) {
251
- logger("Recovered assistant response via polling fallback");
252
- return recovered;
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;
325
+ }
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;
253
330
  }
254
331
  await logConversationSnapshot(Runtime, logger).catch(() => undefined);
255
332
  return null;
@@ -337,9 +414,7 @@ async function terminateRuntimeExecution(Runtime) {
337
414
  }
338
415
  async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, abortSignal) {
339
416
  const watchdogDeadline = Date.now() + timeoutMs;
340
- let previousLength = 0;
341
- let stableCycles = 0;
342
- let lastChangeAt = Date.now();
417
+ let gate = createTerminalGateState(Date.now());
343
418
  while (Date.now() < watchdogDeadline) {
344
419
  // Check abort signal to stop polling when another path won the race
345
420
  if (abortSignal?.aborted) {
@@ -348,44 +423,34 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
348
423
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
349
424
  const normalized = normalizeAssistantSnapshot(snapshot);
350
425
  if (normalized) {
351
- const currentLength = normalized.text.length;
352
- if (currentLength > previousLength) {
353
- previousLength = currentLength;
354
- stableCycles = 0;
355
- lastChangeAt = Date.now();
356
- }
357
- else {
358
- stableCycles += 1;
426
+ // Generated-image answers stream no text and mount no action bar; accept immediately.
427
+ if (isGeneratedImageAssistantAnswer(normalized)) {
428
+ return normalized;
359
429
  }
360
- const [stopVisible, completionVisible] = await Promise.all([
430
+ const [stopVisible, barVisible, thinkingActivity] = await Promise.all([
361
431
  isStopButtonVisible(Runtime),
362
- isCompletionVisible(Runtime),
432
+ isCompletionVisible(Runtime, normalized.meta, minTurnIndex),
433
+ readThinkingActivity(Runtime),
363
434
  ]);
364
- if (isGeneratedImageAssistantAnswer(normalized)) {
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) {
365
447
  return normalized;
366
448
  }
367
- const shortAnswer = currentLength > 0 && currentLength < 16;
368
- const mediumAnswer = currentLength >= 16 && currentLength < 40;
369
- const longAnswer = currentLength >= 40 && currentLength < 500;
370
- // Learned: short answers need a longer stability window or they truncate.
371
- // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
372
- // use progressively longer windows to avoid truncation (#71).
373
- const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
374
- const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
375
- const stableMs = Date.now() - lastChangeAt;
376
- const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
377
- // Require stop button to disappear before treating completion as final.
378
- if (!stopVisible) {
379
- const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
380
- const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
381
- if (completionEnough || stableEnough) {
382
- return normalized;
383
- }
384
- }
385
449
  }
386
450
  else {
387
- previousLength = 0;
388
- stableCycles = 0;
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());
389
454
  }
390
455
  await delay(400);
391
456
  }
@@ -427,43 +492,70 @@ function buildStopButtonVisibilityPredicateJs(fnName) {
427
492
  };`;
428
493
  }
429
494
  export const buildStopButtonVisibilityExpressionForTest = buildStopButtonVisibilityExpression;
430
- async function isCompletionVisible(Runtime) {
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;
431
556
  try {
432
557
  const { result } = await Runtime.evaluate({
433
- expression: `(() => {
434
- // Find the LAST assistant turn to check completion status
435
- // Must match the same logic as buildAssistantExtractor for consistency
436
- const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
437
- const isAssistantTurn = (node) => {
438
- if (!(node instanceof HTMLElement)) return false;
439
- const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
440
- if (turnAttr === 'assistant') return true;
441
- const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
442
- if (role === 'assistant') return true;
443
- const testId = (node.getAttribute('data-testid') || '').toLowerCase();
444
- if (testId.includes('assistant')) return true;
445
- return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
446
- };
447
-
448
- const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
449
- let lastAssistantTurn = null;
450
- for (let i = turns.length - 1; i >= 0; i--) {
451
- if (isAssistantTurn(turns[i])) {
452
- lastAssistantTurn = turns[i];
453
- break;
454
- }
455
- }
456
- if (!lastAssistantTurn) {
457
- return false;
458
- }
459
- // Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
460
- if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
461
- return true;
462
- }
463
- // Also check for "Done" text in the last assistant turn's markdown
464
- const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
465
- return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
466
- })()`,
558
+ expression: buildCompletionVisibilityExpression(meta, minTurnIndex),
467
559
  returnByValue: true,
468
560
  });
469
561
  return Boolean(result?.value);
@@ -472,6 +564,10 @@ async function isCompletionVisible(Runtime) {
472
564
  return false;
473
565
  }
474
566
  }
567
+ export function hasScopedCompletionProof(meta) {
568
+ return meta.completionVisible === true;
569
+ }
570
+ export const buildCompletionVisibilityExpressionForTest = buildCompletionVisibilityExpression;
475
571
  function normalizeAssistantSnapshot(snapshot) {
476
572
  const text = snapshot?.text ? cleanAssistantText(snapshot.text) : "";
477
573
  if (!text.trim()) {
@@ -490,7 +586,11 @@ function normalizeAssistantSnapshot(snapshot) {
490
586
  return {
491
587
  text,
492
588
  html: snapshot?.html ?? undefined,
493
- meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
589
+ meta: {
590
+ turnId: snapshot?.turnId ?? undefined,
591
+ messageId: snapshot?.messageId ?? undefined,
592
+ ...(snapshot?.completionVisible === true ? { completionVisible: true } : {}),
593
+ },
494
594
  };
495
595
  }
496
596
  function isGeneratedImageAssistantAnswer(answer) {
@@ -557,7 +657,6 @@ function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId)
557
657
  }
558
658
  function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId) {
559
659
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
560
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
561
660
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
562
661
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
563
662
  ? Math.floor(minTurnIndex)
@@ -570,7 +669,6 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
570
669
  const SELECTORS = ${selectorsLiteral};
571
670
  const STOP_SELECTOR = ${JSON.stringify(STOP_CONTROL_SELECTOR)};
572
671
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
573
- const CONVERSATION_SELECTOR = ${conversationLiteral};
574
672
  const ASSISTANT_SELECTOR = ${assistantLiteral};
575
673
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
576
674
  // Learned: settling avoids capturing mid-stream HTML; keep short.
@@ -593,6 +691,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
593
691
  return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
594
692
  };
595
693
  ${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")}
694
+ ${buildThinkingActivePredicateJs("isThinkingActiveNow")}
596
695
 
597
696
  // Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency.
598
697
  const isAssistantTurn = (node) => {
@@ -693,7 +792,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
693
792
 
694
793
  // Check if the last assistant turn has finished (scoped to avoid detecting old turns).
695
794
  const isLastAssistantTurnFinished = () => {
696
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
795
+ const turns = ${buildConversationTurnListExpression()};
697
796
  let lastAssistantTurn = null;
698
797
  for (let i = turns.length - 1; i >= 0; i--) {
699
798
  if (isAssistantTurn(turns[i])) {
@@ -717,8 +816,8 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
717
816
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
718
817
  // use progressively longer windows to avoid truncation (#71).
719
818
  const initialLength = snapshot?.text?.length ?? 0;
720
- const shortAnswer = initialLength > 0 && initialLength < 16;
721
- const mediumAnswer = initialLength >= 16 && initialLength < 40;
819
+ const shortAnswer = initialLength > 0 && initialLength < ${MIN_CONFIDENT_ANSWER_LENGTH};
820
+ const mediumAnswer = initialLength >= ${MIN_CONFIDENT_ANSWER_LENGTH} && initialLength < 40;
722
821
  const longAnswer = initialLength >= 40 && initialLength < 500;
723
822
  const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
724
823
  const settleIntervalMs = 400;
@@ -759,8 +858,15 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
759
858
  }
760
859
  const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
761
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();
762
865
 
763
- if (finishedVisible || (!stopVisible && stableCycles >= stableTarget)) {
866
+ if (
867
+ finishedVisible ||
868
+ (!stopVisible && !thinkingActiveNow && stableCycles >= stableTarget)
869
+ ) {
764
870
  break;
765
871
  }
766
872
  }
@@ -792,11 +898,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
792
898
  })()`;
793
899
  }
794
900
  function buildAssistantExtractor(functionName) {
795
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
796
901
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
797
902
  return `const ${functionName} = () => {
798
903
  ${buildClickDispatcher()}
799
- const CONVERSATION_SELECTOR = ${conversationLiteral};
800
904
  const ASSISTANT_SELECTOR = ${assistantLiteral};
801
905
  const isAssistantTurn = (node) => {
802
906
  if (!(node instanceof HTMLElement)) return false;
@@ -832,7 +936,7 @@ function buildAssistantExtractor(functionName) {
832
936
  }
833
937
  };
834
938
 
835
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
939
+ const turns = ${buildConversationTurnListExpression()};
836
940
  for (let index = turns.length - 1; index >= 0; index -= 1) {
837
941
  const turn = turns[index];
838
942
  if (!isAssistantTurn(turn)) {
@@ -867,7 +971,7 @@ function buildAssistantExtractor(functionName) {
867
971
  normalizedText === 'edit' ||
868
972
  normalizedText === 'stopped thinking' ||
869
973
  normalizedText === 'stopped thinking edit' ||
870
- /^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);
871
975
  if (generatedImages.length > 0 && imageOnlyChrome) {
872
976
  const label = generatedImages.length === 1 ? 'Generated image.' : \`Generated \${generatedImages.length} images.\`;
873
977
  return { text: label, html: messageRoot?.innerHTML ?? html, messageId, turnId, turnIndex: index };
@@ -916,29 +1020,32 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
916
1020
  }
917
1021
  }
918
1022
  if (!root) return null;
919
- const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
920
- const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1023
+ const turnNodes = ${buildConversationTurnListExpression()};
921
1024
  const hasTurns = turnNodes.length > 0;
922
1025
  const resolveTurnIndex = (node) => {
923
- const turn = node?.closest?.(CONVERSATION_SELECTOR);
924
- if (!turn) return null;
925
- const idx = turnNodes.indexOf(turn);
1026
+ const idx = turnNodes.findIndex((turn) => turn === node || turn.contains?.(node));
926
1027
  return idx >= 0 ? idx : null;
927
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
+ };
928
1043
  const isAfterMinTurn = (node) => {
929
1044
  if (__minTurn === null) return true;
930
- if (!hasTurns) return true;
1045
+ if (!hasTurns) return isAfterCurrentUser(node);
931
1046
  const idx = resolveTurnIndex(node);
932
1047
  return idx !== null && idx >= __minTurn;
933
1048
  };
934
- const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
935
- const collectUserText = (scope) => {
936
- if (!scope?.querySelectorAll) return '';
937
- const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
938
- const lastUser = userTurns[userTurns.length - 1];
939
- return lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
940
- };
941
- const userText = collectUserText(root) || collectUserText(document);
942
1049
  const isUserEcho = (text) => {
943
1050
  if (!userText) return false;
944
1051
  const normalized = normalize(text);
@@ -1009,7 +1116,14 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
1009
1116
  if (isUserEcho(text)) continue;
1010
1117
  const html = node.innerHTML ?? '';
1011
1118
  const turnIndex = resolveTurnIndex(node);
1012
- return { text, html, messageId: null, turnId: null, turnIndex };
1119
+ return {
1120
+ text,
1121
+ html,
1122
+ messageId: null,
1123
+ turnId: null,
1124
+ turnIndex,
1125
+ completionVisible: actionMarkdowns.includes(node),
1126
+ };
1013
1127
  }
1014
1128
  return null;
1015
1129
  })`;
@@ -1050,7 +1164,7 @@ function buildCopyExpression(meta) {
1050
1164
  if (testId.includes('assistant')) return true;
1051
1165
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
1052
1166
  };
1053
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1167
+ const turns = ${buildConversationTurnListExpression()};
1054
1168
  for (let i = turns.length - 1; i >= 0; i -= 1) {
1055
1169
  const turn = turns[i];
1056
1170
  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";
@@ -1594,14 +1595,12 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1594
1595
  throw new Error("Attachment was not present on the sent user message.");
1595
1596
  }
1596
1597
  function buildUserTurnAttachmentExpression(options) {
1597
- const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1598
1598
  const minTurnLiteral = options.minTurnIndex === null ? "null" : String(options.minTurnIndex);
1599
1599
  const expectedPromptLiteral = JSON.stringify(options.expectedPromptPrefix);
1600
1600
  const expectedConversationLiteral = options.expectedConversationId
1601
1601
  ? JSON.stringify(options.expectedConversationId)
1602
1602
  : "null";
1603
1603
  return `(() => {
1604
- const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
1605
1604
  const MIN_TURN_INDEX = ${minTurnLiteral};
1606
1605
  const EXPECTED_PROMPT_PREFIX = ${expectedPromptLiteral};
1607
1606
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
@@ -1614,7 +1613,7 @@ function buildUserTurnAttachmentExpression(options) {
1614
1613
  ) {
1615
1614
  return { ok: false, conversationMismatch: true };
1616
1615
  }
1617
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1616
+ const turns = ${buildConversationTurnListExpression()};
1618
1617
  const userTurns = turns.map((node, index) => ({ node, index })).filter(({ node }) => {
1619
1618
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
1620
1619
  if (attr === 'user') return true;