@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.
Files changed (67) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/src/browser/actions/assistantResponse.js +218 -126
  3. package/dist/src/browser/actions/modelSelection.js +133 -22
  4. package/dist/src/browser/actions/navigation.js +235 -14
  5. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  6. package/dist/src/browser/actions/thinkingTime.js +68 -11
  7. package/dist/src/browser/chromeLifecycle.js +29 -28
  8. package/dist/src/browser/config.js +14 -1
  9. package/dist/src/browser/constants.js +5 -1
  10. package/dist/src/browser/controlPlan.js +2 -2
  11. package/dist/src/browser/index.js +56 -16
  12. package/dist/src/browser/liveTabs.js +113 -31
  13. package/dist/src/browser/pageActions.js +1 -1
  14. package/dist/src/browser/projectSourcesRunner.js +4 -4
  15. package/dist/src/browser/reattach.js +2 -2
  16. package/dist/src/browser/recoverConversation.js +90 -29
  17. package/dist/src/cli/browserConfig.js +5 -1
  18. package/dist/src/cli/browserTabs.js +54 -33
  19. package/dist/src/cli/options.js +24 -0
  20. package/dist/src/cli/runOptions.js +6 -1
  21. package/dist/src/cli/sessionDisplay.js +38 -14
  22. package/dist/src/oracle/config.js +25 -0
  23. package/dist/src/oracle/geminiModels.js +2 -0
  24. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  25. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  26. package/package.json +9 -10
  27. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/dist/bin/oracle.js +0 -569
  30. package/dist/docs-site/.nojekyll +0 -0
  31. package/dist/docs-site/CNAME +0 -1
  32. package/dist/docs-site/RELEASING.html +0 -410
  33. package/dist/docs-site/agents.html +0 -374
  34. package/dist/docs-site/anthropic.html +0 -368
  35. package/dist/docs-site/bridge.html +0 -416
  36. package/dist/docs-site/browser-mode.html +0 -594
  37. package/dist/docs-site/chromium-forks.html +0 -347
  38. package/dist/docs-site/cli-reference.html +0 -346
  39. package/dist/docs-site/configuration.html +0 -462
  40. package/dist/docs-site/favicon.svg +0 -14
  41. package/dist/docs-site/followup.html +0 -375
  42. package/dist/docs-site/gemini.html +0 -383
  43. package/dist/docs-site/grok.html +0 -325
  44. package/dist/docs-site/index.html +0 -360
  45. package/dist/docs-site/install.html +0 -335
  46. package/dist/docs-site/linux.html +0 -321
  47. package/dist/docs-site/llms.txt +0 -43
  48. package/dist/docs-site/manual-tests.html +0 -596
  49. package/dist/docs-site/mcp.html +0 -391
  50. package/dist/docs-site/multimodel.html +0 -364
  51. package/dist/docs-site/mythical-pro-agents.html +0 -360
  52. package/dist/docs-site/notifier.html +0 -338
  53. package/dist/docs-site/openai-endpoints.html +0 -399
  54. package/dist/docs-site/openrouter.html +0 -344
  55. package/dist/docs-site/quickstart.html +0 -369
  56. package/dist/docs-site/refactor/ux.html +0 -532
  57. package/dist/docs-site/sessions.html +0 -388
  58. package/dist/docs-site/social-card.png +0 -0
  59. package/dist/docs-site/social-card.svg +0 -79
  60. package/dist/docs-site/spec.html +0 -363
  61. package/dist/docs-site/testing.html +0 -320
  62. package/dist/docs-site/tui-debug.html +0 -326
  63. package/dist/docs-site/windows-work.html +0 -323
  64. package/dist/docs-site/windows.html +0 -320
  65. package/dist/src/browser/chromeCookies.js +0 -312
  66. package/dist/src/browser/keytarShim.js +0 -56
  67. 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([]))
@@ -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 isImplausiblyShortAnswer(candidateLength) {
10
- return candidateLength > 0 && candidateLength < MIN_CONFIDENT_ANSWER_LENGTH;
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
- export function shouldConfirmAssistantCompletion(args) {
13
- if (args.stopVisible || args.completionVisible) {
14
- return true;
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 isImplausiblyShortAnswer(args.candidateLength);
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
- 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)
40
83
  return true;
41
84
  return trimmed.startsWith("pro thinking") && trimmed.length <= 40;
42
85
  }
43
- function isAnswerNowPlaceholderText(normalized) {
44
- const text = normalized.trim();
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.startsWith('thought for ') && normalized.length <= 40) ||
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 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.
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
- const [stopVisible, completionVisible] = await Promise.all([
182
- isStopButtonVisible(Runtime),
183
- isCompletionVisible(Runtime),
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
- if (suspiciouslyShort) {
205
- throw new Error("assistant-response short capture could not be confirmed before timeout; refusing to finalize it");
206
- }
207
- 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");
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
- 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);
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
- logger("Recovered assistant response via polling fallback");
281
- return recovered;
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 previousLength = 0;
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
- const currentLength = normalized.text.length;
381
- if (currentLength > previousLength) {
382
- previousLength = currentLength;
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, completionVisible] = await Promise.all([
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
- 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) {
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
- previousLength = 0;
417
- 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());
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
- 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;
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: { 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
+ },
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 (finishedVisible || (!stopVisible && stableCycles >= stableTarget)) {
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 true;
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 { 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
+ };
1035
1127
  }
1036
1128
  return null;
1037
1129
  })`;