4242 BROWSER_DOM_CONFIRM_WAIT ,
4343 BROWSER_TYPE_DELAY ,
4444 BROWSER_USER_DATA_DIR ,
45+ BROWSER_STOP_BUTTON_POLL_MS ,
46+ BROWSER_STOP_BUTTON_DEBOUNCE_MS ,
47+ BROWSER_MIN_GENERATION_TIME_MS ,
48+ BROWSER_CONFIRMATION_WINDOW_MS ,
49+ BROWSER_MUTATION_STABILITY_MS ,
4550 MAX_CONCURRENT_SESSIONS ,
4651 SELECTORS_PATH ,
4752 SEMAPHORE_TTL ,
@@ -859,25 +864,257 @@ async def _analyze_screenshot(self, screenshot_bytes: bytes) -> dict:
859864
860865 return json .loads (text )
861866
867+ # --- Smart Completion Detection methods (Phase 1-2, 5) ---
868+
869+ async def _wait_for_stop_button_cycle (self , page , timeout : int , start : float ) -> bool :
870+ """Wait for stop button to appear then disappear (with debounce).
871+
872+ Returns True if the stop button completed a full cycle (appeared → disappeared).
873+ Returns False if the stop button never appeared within 30s.
874+ """
875+ stop_selectors = (
876+ 'button[aria-label*="Stop"], button[aria-label*="Cancel"], '
877+ '[data-testid*="stop"], button:has(svg circle[stroke-dasharray]), '
878+ 'button[class*="stop"]'
879+ )
880+ poll_s = BROWSER_STOP_BUTTON_POLL_MS / 1000
881+ debounce_s = BROWSER_STOP_BUTTON_DEBOUNCE_MS / 1000
882+
883+ # Phase 1: Wait for stop button to appear (confirms generation started)
884+ _log ("Smart: waiting for stop button to appear..." )
885+ appear_deadline = start + 30 # 30s to detect stop button
886+ appeared = False
887+ while time .time () < appear_deadline and (time .time () - start ) * 1000 < timeout :
888+ try :
889+ has_stop = await page .evaluate (f"""() => {{
890+ return !!document.querySelector('{ stop_selectors } ');
891+ }}""" )
892+ if has_stop :
893+ appeared = True
894+ _log (f"Smart: stop button appeared ({ time .time () - start :.1f} s)" )
895+ break
896+ except Exception :
897+ pass
898+ await asyncio .sleep (poll_s )
899+
900+ if not appeared :
901+ _log ("Smart: stop button never appeared (30s), falling back" )
902+ return False
903+
904+ # Phase 2: Wait for stop button to disappear
905+ _log ("Smart: waiting for stop button to disappear..." )
906+ while (time .time () - start ) * 1000 < timeout :
907+ try :
908+ has_stop = await page .evaluate (f"""() => {{
909+ return !!document.querySelector('{ stop_selectors } ');
910+ }}""" )
911+ if not has_stop :
912+ _log (f"Smart: stop button disappeared ({ time .time () - start :.1f} s), debouncing { debounce_s } s..." )
913+ # Debounce: re-check after delay to handle inter-section flickers
914+ await asyncio .sleep (debounce_s )
915+ try :
916+ reappeared = await page .evaluate (f"""() => {{
917+ return !!document.querySelector('{ stop_selectors } ');
918+ }}""" )
919+ except Exception :
920+ reappeared = False
921+ if reappeared :
922+ _log ("Smart: stop button reappeared during debounce, re-entering wait loop" )
923+ continue
924+ _log (f"Smart: stop button confirmed gone ({ time .time () - start :.1f} s)" )
925+ return True
926+ except Exception :
927+ pass
928+ await asyncio .sleep (poll_s )
929+
930+ _log (f"Smart: timed out waiting for stop button to disappear ({ time .time () - start :.1f} s)" )
931+ return False
932+
933+ async def _inject_mutation_observer (self , page ) -> None :
934+ """Inject a MutationObserver on the .prose content area.
935+
936+ Tracks window.__mutationState = { lastMutationTime, isStable, stableForMs }.
937+ Stability = BROWSER_MUTATION_STABILITY_MS of zero mutations.
938+ """
939+ stability_ms = BROWSER_MUTATION_STABILITY_MS
940+ await page .evaluate (f"""() => {{
941+ window.__mutationState = {{
942+ lastMutationTime: Date.now(),
943+ isStable: false,
944+ stableForMs: 0,
945+ }};
946+ const target = document.querySelector('.prose') ||
947+ document.querySelector('div.prose.max-w-none') ||
948+ document.body;
949+ const observer = new MutationObserver((mutations) => {{
950+ if (mutations.length > 0) {{
951+ window.__mutationState.lastMutationTime = Date.now();
952+ window.__mutationState.isStable = false;
953+ window.__mutationState.stableForMs = 0;
954+ }}
955+ }});
956+ observer.observe(target, {{
957+ childList: true,
958+ characterData: true,
959+ subtree: true,
960+ }});
961+ // Periodic stability check
962+ setInterval(() => {{
963+ const elapsed = Date.now() - window.__mutationState.lastMutationTime;
964+ window.__mutationState.stableForMs = elapsed;
965+ window.__mutationState.isStable = elapsed >= { stability_ms } ;
966+ }}, 500);
967+ }}""" )
968+ _log ("Smart: MutationObserver injected on .prose content area" )
969+
970+ async def _check_mutation_stability (self , page ) -> bool :
971+ """Check if the MutationObserver reports stable (no mutations for threshold)."""
972+ try :
973+ state = await page .evaluate ("() => window.__mutationState || {}" )
974+ return bool (state .get ("isStable" , False ))
975+ except Exception :
976+ return False
977+
978+ async def _check_for_error_state (self , page ) -> bool :
979+ """Check for error indicators after stop button disappears.
980+
981+ Returns True if an error was detected.
982+ """
983+ try :
984+ error = await page .evaluate ("""() => {
985+ // Check for error text
986+ const body = document.body.innerText || '';
987+ const errorPatterns = [
988+ 'Something went wrong',
989+ 'Rate limit',
990+ 'Error generating',
991+ 'An error occurred',
992+ 'Please try again',
993+ ];
994+ for (const pattern of errorPatterns) {
995+ if (body.includes(pattern)) return pattern;
996+ }
997+ // Check for error-styled elements
998+ const errorEl = document.querySelector('[class*="error"]');
999+ if (errorEl && errorEl.textContent.trim().length > 5) {
1000+ return errorEl.textContent.trim().substring(0, 100);
1001+ }
1002+ return null;
1003+ }""" )
1004+ if error :
1005+ _log (f"Smart: error state detected: { error } " )
1006+ return True
1007+ except Exception :
1008+ pass
1009+ return False
1010+
1011+ async def _wait_research_smart (self , page , timeout : int , start : float ) -> bool :
1012+ """Smart completion detection for research/labs modes.
1013+
1014+ Signal hierarchy:
1015+ 1. Primary: stop button cycle (appeared → disappeared with debounce)
1016+ 2. Confirming: MutationObserver stability OR text stability (10s window)
1017+ 3. Fallback: existing _wait_research_fallback() with reduced guards
1018+
1019+ If the stop button disappears suspiciously fast (<30s), waits for
1020+ a confirming signal before accepting. If stop button never appears,
1021+ falls through to the CSS/text-stability fallback.
1022+ """
1023+ min_gen_s = BROWSER_MIN_GENERATION_TIME_MS / 1000
1024+ confirm_s = BROWSER_CONFIRMATION_WINDOW_MS / 1000
1025+ text_stable_s = 8 # seconds of unchanged text for confirmation
1026+
1027+ # Inject MutationObserver early
1028+ await self ._inject_mutation_observer (page )
1029+
1030+ # Primary signal: stop button cycle
1031+ stop_cycle = await self ._wait_for_stop_button_cycle (page , timeout , start )
1032+
1033+ if not stop_cycle :
1034+ # Stop button never appeared — fall through to existing fallback
1035+ _log ("Smart: no stop button detected, using fallback completion detection" )
1036+ return await self ._wait_research_fallback (page , timeout , start )
1037+
1038+ elapsed = time .time () - start
1039+
1040+ # Check for error state after stop button disappears
1041+ if await self ._check_for_error_state (page ):
1042+ _log ("Smart: error detected after stop button disappeared" )
1043+ return False
1044+
1045+ # Suspiciously fast? Wait for confirming signal
1046+ if elapsed < min_gen_s :
1047+ _log (f"Smart: stop button gone at { elapsed :.1f} s (< { min_gen_s } s), waiting for confirmation..." )
1048+ confirm_start = time .time ()
1049+ text_snapshot = await self ._get_text_length (page )
1050+ text_stable_since = time .time ()
1051+
1052+ while (time .time () - confirm_start ) < confirm_s :
1053+ # Check mutation stability
1054+ if await self ._check_mutation_stability (page ):
1055+ _log (f"Smart: confirmed via MutationObserver stability ({ time .time () - start :.1f} s)" )
1056+ return True
1057+ # Check text stability
1058+ current_len = await self ._get_text_length (page )
1059+ if current_len != text_snapshot :
1060+ text_snapshot = current_len
1061+ text_stable_since = time .time ()
1062+ elif (time .time () - text_stable_since ) >= text_stable_s :
1063+ _log (f"Smart: confirmed via text stability ({ text_stable_s } s, { time .time () - start :.1f} s)" )
1064+ return True
1065+ await asyncio .sleep (1 )
1066+
1067+ _log (f"Smart: no confirming signal in { confirm_s } s, falling back to CSS detection" )
1068+ return await self ._wait_research_fallback (page , timeout , start )
1069+
1070+ # Normal timing — brief confirmation phase (10s max)
1071+ _log (f"Smart: stop button gone at { elapsed :.1f} s, running brief confirmation..." )
1072+ confirm_start = time .time ()
1073+ text_snapshot = await self ._get_text_length (page )
1074+ text_stable_since = time .time ()
1075+
1076+ while (time .time () - confirm_start ) < confirm_s :
1077+ # Check mutation stability
1078+ if await self ._check_mutation_stability (page ):
1079+ _log (f"Smart: confirmed via MutationObserver stability ({ time .time () - start :.1f} s)" )
1080+ return True
1081+ # Check text stability
1082+ current_len = await self ._get_text_length (page )
1083+ if current_len != text_snapshot :
1084+ text_snapshot = current_len
1085+ text_stable_since = time .time ()
1086+ elif (time .time () - text_stable_since ) >= text_stable_s :
1087+ _log (f"Smart: confirmed via text stability ({ text_stable_s } s, { time .time () - start :.1f} s)" )
1088+ return True
1089+ await asyncio .sleep (1 )
1090+
1091+ # Confirmation window expired but stop button is still gone — trust it
1092+ _log (f"Smart: confirmation window expired, trusting stop button signal ({ time .time () - start :.1f} s)" )
1093+ return True
1094+
8621095 async def wait_for_completion (self , page , timeout : int | None = None ) -> bool :
8631096 """Wait for all model responses and synthesis to complete.
8641097
865- Primary: Vision-based detection via Haiku screenshot analysis.
1098+ Research/labs: Smart detection (stop button + multi-signal confirmation).
1099+ Council: Vision-based detection via Haiku screenshot analysis.
8661100 Fallback: CSS selector + stability polling (when ANTHROPIC_API_KEY not set).
8671101 """
8681102 timeout = timeout or self .timeout
8691103 start = time .time ()
8701104
1105+ # Research/labs: always use smart detection (stop button + multi-signal)
1106+ # regardless of vision availability. Vision is deprecated for research/labs.
1107+ if self .perplexity_mode in ("research" , "labs" ):
1108+ return await self ._wait_research_smart (page , timeout , start )
1109+
1110+ # Council mode: vision-based or CSS fallback
8711111 api_key = os .environ .get ("ANTHROPIC_API_KEY" )
8721112 use_vision = bool (api_key ) and VISION_ENABLED
8731113
8741114 if use_vision :
8751115 return await self ._wait_vision (page , timeout , start )
8761116 else :
8771117 _log ("Vision monitoring unavailable (no ANTHROPIC_API_KEY), using CSS fallback" )
878- # Route research/labs to dedicated fallback (mode-aware stability)
879- if self .perplexity_mode in ("research" , "labs" ):
880- return await self ._wait_research_fallback (page , timeout , start )
8811118 return await self ._wait_css_fallback (page , timeout , start )
8821119
8831120 async def _wait_vision (self , page , timeout : int , start : float ) -> bool :
@@ -887,9 +1124,8 @@ async def _wait_vision(self, page, timeout: int, start: float) -> bool:
8871124 Requires seeing 'synthesizing' before trusting 'complete', and
8881125 requires 2 consecutive 'complete' polls for confidence.
8891126 """
890- # Route research/labs to dedicated vision method (different prompt)
891- if self .perplexity_mode in ("research" , "labs" ):
892- return await self ._wait_vision_research (page , timeout , start )
1127+ # Note: research/labs now use _wait_research_smart() (routed in wait_for_completion)
1128+ # This method is only called for council mode.
8931129
8941130 poll_interval = VISION_POLL_INTERVAL_MODELS
8951131 all_models_done = False
0 commit comments