""" Workflow Simulator - High-level GUI workflow automation for testing. This module provides the WorkflowSimulator class which orchestrates complex multi-step workflows through the GUI via the ApiHookClient. It is designed for integration testing and automated verification of GUI behavior. Key Capabilities: - Project setup and configuration - Discussion creation and switching - AI turn execution with stall detection - Context file management - MMA (Multi-Model Agent) orchestration simulation Stall Detection: The run_discussion_turn() method implements intelligent stall detection: - Monitors ai_status for transitions from busy -> idle - Detects stalled Tool results (non-busy state with Tool as last role) - Automatically triggers btn_gen_send to recover from stalls Integration with UserSimAgent: WorkflowSimulator delegates user simulation behavior (reading time, delays) to UserSimAgent for realistic interaction patterns. Thread Safety: This class is NOT thread-safe. All methods should be called from a single thread (typically the main test thread). Example Usage: client = ApiHookClient() sim = WorkflowSimulator(client) sim.setup_new_project("TestProject", "/path/to/git/dir") sim.create_discussion("Feature A") result = sim.run_discussion_turn("Please implement feature A") See Also: - simulation/sim_base.py for BaseSimulation class - simulation/user_agent.py for UserSimAgent - api_hook_client.py for ApiHookClient - docs/guide_simulations.md for full simulation documentation """ import time from api_hook_client import ApiHookClient from simulation.user_agent import UserSimAgent class WorkflowSimulator: def __init__(self, hook_client: ApiHookClient) -> None: self.client = hook_client self.user_agent = UserSimAgent(hook_client) def setup_new_project(self, name: str, git_dir: str, project_path: str = None) -> None: print(f"Setting up new project: {name}") if project_path: self.client.click("btn_project_new_automated", user_data=project_path) else: self.client.click("btn_project_new") time.sleep(1) self.client.set_value("project_git_dir", git_dir) self.client.click("btn_project_save") time.sleep(1) # Force state deterministic for tests self.client.set_value("auto_add_history", True) def create_discussion(self, name: str) -> None: print(f"Creating discussion: {name}") self.client.set_value("disc_new_name_input", name) self.client.click("btn_disc_create") self.client.select_list_item('disc_listbox', name) time.sleep(2) def switch_discussion(self, name: str) -> None: print(f"Switching to discussion: {name}") self.client.select_list_item("disc_listbox", name) time.sleep(1) def load_prior_log(self) -> None: print("Loading prior log") self.client.click("btn_load_log") # This usually opens a file dialog which we can't easily automate from here # without more hooks, but we can verify the button click. time.sleep(1) def truncate_history(self, pairs: int) -> None: print(f"Truncating history to {pairs} pairs") self.client.set_value("disc_truncate_pairs", pairs) self.client.click("btn_disc_truncate") time.sleep(1) def run_discussion_turn(self, user_message: str = None) -> dict | None: self.run_discussion_turn_async(user_message) # Wait for AI return self.wait_for_ai_response() def run_discussion_turn_async(self, user_message: str = None) -> None: if user_message is None: # Generate from AI history session = self.client.get_session() entries = session.get('session', {}).get('entries', []) user_message = self.user_agent.generate_response(entries) active_disc = self.client.get_value("active_discussion") print(f"[DEBUG] Current active discussion in GUI: {active_disc}") print(f"\n[USER]: {user_message}") self.user_agent.simulate_typing(user_message) self.client.set_value("ai_input", user_message) self.client.click("btn_gen_send") def wait_for_ai_response(self, timeout: int = 60) -> dict | None: self.user_agent.wait_to_think(probability=0.1) print("Waiting for AI response...", end="", flush=True) start_time = time.time() last_debug_time = 0 stalled_start_time = None # Statuses that indicate the system is still actively processing the AI request busy_indicators = [ "thinking", "streaming", "sending", "running powershell", "awaiting ai", "fetching", "searching" ] was_busy = False while time.time() - start_time < timeout: elapsed = time.time() - start_time status = (self.client.get_value("ai_status") or "idle").lower() is_busy = any(indicator in status for indicator in busy_indicators) if is_busy: was_busy = True # Always fetch latest entries session_data = self.client.get_session() or {} entries = session_data.get('session', {}).get('entries', []) # Find the last entry that is NOT role 'System' non_system_entries = [e for e in entries if e.get('role') != 'System'] last_entry = non_system_entries[-1] if non_system_entries else {} last_role = last_entry.get('role', 'none') # AI entries for return value current_ai_entries = [e for e in entries if e.get('role') == 'AI'] last_ai_entry = current_ai_entries[-1] if current_ai_entries else {} if elapsed - last_debug_time >= 5: roles = [e.get("role") for e in entries] print(f"\n[DEBUG] {elapsed:.1f}s - status: '{status}', roles: {roles}") last_debug_time = elapsed if "error" in status: resp = self.client.get_value("ai_response") print(f"\n[ABORT] GUI reported error status: {status} | AI Response: {resp}") return last_ai_entry if last_ai_entry else {"role": "AI", "content": f"ERROR: {status}"} # Turn completion logic: # 1. Transition: we were busy and now we are not, and the last role is AI. # 2. Fallback: we are idle/done and the last role is AI, after some initial delay. is_complete = False if was_busy and not is_busy and last_role == 'AI': is_complete = True elif status in ("idle", "done") and last_role == 'AI' and elapsed > 2: is_complete = True if is_complete: content = last_ai_entry.get('content', '') print(f"\n[AI]: {content[:100]}...") self.user_agent.wait_to_read(content) return last_ai_entry if non_system_entries: # Stall detection for 'Tool' results if last_role == 'Tool' and not is_busy: if stalled_start_time is None: stalled_start_time = time.time() elif time.time() - stalled_start_time > 5: print("\n[STALL DETECTED] Turn stalled with Tool result. Clicking 'btn_gen_send' to continue.") self.client.click("btn_gen_send") stalled_start_time = time.time() else: stalled_start_time = None # Maintain the 'thinking/streaming' wait loop time.sleep(1) print(".", end="", flush=True) print("\nTimeout waiting for AI") active_disc = self.client.get_value("active_discussion") print(f"[DEBUG] Active discussion in GUI at timeout: {active_disc}") return None