diff --git a/CODEBASE.md b/CODEBASE.md index 52d12b693..5bdd4ae39 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -69,9 +69,13 @@ src/cli_agent_orchestrator/ ├── api/ # Entry Point: HTTP API │ └── main.py # FastAPI endpoints (port 9889) ├── services/ # Service Layer: Business logic +│ ├── event_bus.py # Pub/sub event routing with wildcard topic matching +│ ├── fifo_reader.py # Publisher: terminal.{id}.output (FIFO → event bus) +│ ├── status_monitor.py # Consumer: terminal.{id}.output → Publisher: terminal.{id}.status +│ ├── log_writer.py # Consumer: terminal.{id}.output (writes debug logs) +│ ├── inbox_service.py # Consumer: terminal.{id}.status (delivers queued messages) │ ├── session_service.py # List, get, delete sessions -│ ├── terminal_service.py# Create, get, send input (+ mark_input_received), get output, delete terminals -│ ├── inbox_service.py # Terminal-to-terminal messaging with watchdog +│ ├── terminal_service.py# Create, get, send input, get output, delete terminals │ └── flow_service.py # Scheduled flow execution ├── clients/ # Client Layer: External systems │ ├── tmux.py # Tmux operations (sets CAO_TERMINAL_ID, send_keys, send_keys_via_paste for bracketed paste) @@ -117,7 +121,7 @@ provider_manager.create_provider() ↓ provider.initialize() # Waits for shell (all providers), sends command, waits for IDLE ↓ -inbox_service.register_terminal() # Starts watchdog observer +fifo_manager.create_reader(terminal_id) # Starts FIFO reader thread ↓ Returns Terminal model ``` @@ -133,9 +137,9 @@ database.create_inbox_message() # Status: PENDING inbox_service.check_and_send_pending_messages() ↓ If receiver IDLE → send immediately -If receiver PROCESSING → watchdog monitors log file +If receiver PROCESSING → DeliveryConsumer waits for status event ↓ -On log change → detect IDLE pattern → send message +On status change to IDLE → DeliveryConsumer delivers message ↓ Update message status: DELIVERED ``` diff --git a/docs/api.md b/docs/api.md index 608348657..78e1394e9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -186,7 +186,7 @@ Send a message to another terminal's inbox. **Behavior:** - Messages are queued and delivered when the receiver terminal is IDLE - Messages are delivered in order (oldest first) -- Delivery is automatic via watchdog file monitoring +- Delivery is automatic via event-driven status detection --- diff --git a/docs/event-driven-architecture.md b/docs/event-driven-architecture.md new file mode 100644 index 000000000..7fff9d681 --- /dev/null +++ b/docs/event-driven-architecture.md @@ -0,0 +1,138 @@ +# Event-Driven Architecture + +## Overview + +CAO uses an event-driven architecture for terminal output processing, status detection, and inbox message delivery. Terminal output streams through a pipeline of components connected by an in-process pub/sub event bus. + +## Architecture + +``` +┌───────────────────┐ publish ┌──────────────────────────┐ subscribe ┌─────────────┐ +│ FifoReader │────────────▶│ EVENT BUS │────────────▶│ LogWriter │ +│ (thread) │ terminal. │ │ terminal. │ (async) │ +│ │ {id}. │ pub/sub with wildcard │ {id}. │ │ +│ tmux pipe-pane │ output │ topic matching │ output │ writes to │ +│ ▼ Named FIFO │ │ │ │ log files │ +│ ▼ os.read() │ │ │ └─────────────┘ +└───────────────────┘ │ │ + │ │ subscribe ┌───────────────┐ + │ │────────────▶│ StatusMonitor │ + │ │ terminal. │ (async) │ + │ │ {id}. │ │ + │ │ output │ rolling buffer│ + │ │ │ + detection │ + │ │◀────────────│ │ + │ │ publish └───────────────┘ + │ │ terminal. + │ │ {id}. + │ │ status + │ │ + │ │ subscribe ┌─────────────┐ + │ │────────────▶│InboxService │ + │ │ terminal. │ (async) │ + │ │ {id}. │ │ + │ │ status │ delivers │ + └──────────────────────────┘ │ messages │ + └─────────────┘ +``` + +```mermaid +graph LR + subgraph FifoReader ["FifoReader (thread)"] + FR1[tmux pipe-pane] + FR2[Named FIFO] + FR3[os.read] + FR1 --> FR2 --> FR3 + end + + EB["EVENT BUS — pub/sub with wildcard topic matching"] + + subgraph LogWriter ["LogWriter (async)"] + LW[writes to log files] + end + + subgraph StatusMonitor ["StatusMonitor (async)"] + SM[rolling buffer + detection] + end + + subgraph InboxService ["InboxService (async)"] + IS[delivers messages] + end + + FifoReader -- "terminal.{id}.output" --> EB + EB -- "terminal.{id}.output" --> LogWriter + EB -- "terminal.{id}.output" --> StatusMonitor + StatusMonitor -- "terminal.{id}.status" --> EB + EB -- "terminal.{id}.status" --> InboxService +``` + +All inter-service communication flows through the event bus. No service calls another service directly for event processing — the bus is the sole brokering mechanism. + +## Event Bus (`services/event_bus.py`) + +The event bus is the **central brokering mechanism** that connects all publishers and consumers. It implements an in-process pub/sub router with wildcard topic matching, thread-safe publishing, and async consumption via `asyncio.Queue`. + +Every component in the pipeline communicates exclusively through the event bus — publishers never call consumers directly. This decouples components, allows new consumers to be added without modifying publishers, and ensures a clear data flow through the system. + +**Topics:** + +| Topic | Publisher | Consumers | +|-------|----------|-----------| +| `terminal.{id}.output` | FifoReader | StatusMonitor, LogWriter | +| `terminal.{id}.status` | StatusMonitor | InboxService | + +**Subscription patterns:** + +- Exact: `terminal.abc12345.output` +- Wildcard: `terminal.*.output` (matches any terminal ID) + +**Thread safety:** Publishers call `bus.publish()` from any thread. The event bus uses `loop.call_soon_threadsafe()` to dispatch events into the asyncio event loop registered at startup via `bus.set_loop()`. + +## Component Roles + +Each service has a clearly defined role as a **publisher**, **consumer**, or **both**: + +| Component | Role | Subscribes To | Publishes To | +|-----------|------|---------------|--------------| +| **FifoReader** | Publisher only | — (reads from OS FIFO) | `terminal.{id}.output` | +| **StatusMonitor** | Publisher + Consumer | `terminal.*.output` | `terminal.{id}.status` | +| **LogWriter** | Consumer only | `terminal.*.output` | — | +| **InboxService** | Consumer only | `terminal.*.status` | — (delivers via `send_input`) | + +- **Pure publishers** (FifoReader) are the data sources that inject events into the bus. +- **Pure consumers** (LogWriter, InboxService) react to events and perform side effects (writing logs, delivering messages). +- **Publisher + Consumer** (StatusMonitor) transforms events: it consumes raw output, derives status, and publishes status change events for downstream consumers. + +> **Warning: Threading and event loop discipline.** Publisher and consumer implementations must take great care when managing threading. The FifoReader runs in a dedicated OS thread (blocking `os.read` on the FIFO) and publishes into the asyncio loop via `call_soon_threadsafe`. All consumers (`StatusMonitor`, `LogWriter`, `InboxService`) run as asyncio tasks on the main event loop. Consumer `run()` methods must **always yield back to the event loop** (via `await queue.get()`) and avoid long-running synchronous operations that would block other consumers from processing events. If a consumer needs to perform blocking I/O, it should offload to a thread pool via `asyncio.to_thread()`. + +## Components + +### FIFO Reader (`services/fifo_reader.py`) — Publisher + +Creates a named pipe (FIFO) per terminal and starts a daemon reader thread. tmux's `pipe-pane` writes terminal output to the FIFO; the reader reads 4KB chunks and publishes `terminal.{id}.output` events. + +### Status Monitor (`services/status_monitor.py`) — Publisher + Consumer + +Subscribes to `terminal.*.output`. Accumulates output into a rolling buffer (8KB) per terminal, detects status via the registered provider (or a generic shell prompt pattern before init), and publishes `terminal.{id}.status` on change. Also the source of truth for current terminal status. + +### Log Writer (`services/log_writer.py`) — Consumer + +Subscribes to `terminal.*.output`. Appends chunks to per-terminal log files (`~/.cao/logs/terminal/{id}.log`) for debugging. + +### Inbox Service (`services/inbox_service.py`) — Consumer + +Subscribes to `terminal.*.status`. On IDLE or COMPLETED, delivers the oldest pending inbox message to the terminal via `send_input` and updates the message status in the database. + +## Startup & Shutdown + +During server startup (`api/main.py` lifespan): + +1. Register the asyncio event loop with the event bus: `bus.set_loop(loop)` +2. Start consumer tasks: `StatusMonitor.run()`, `LogWriter.run()`, `InboxService.run()` + +During shutdown: + +1. Cancel all consumer tasks +2. `asyncio.gather()` with `return_exceptions=True` to wait for clean exit + +FIFO readers are started/stopped per-terminal by `terminal_service` during create/delete operations. diff --git a/pyproject.toml b/pyproject.toml index 9cd6ac092..ee30416d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,8 @@ dependencies = [ "uvicorn[standard]>=0.24.0", "websockets>=12.0", "libtmux>=0.51.0", - "aiofiles>=24.1.0", "click>=8.0.0", "python-frontmatter>=1.1.0", - "watchdog==6.0.0", "requests>=2.32.0", ] diff --git a/src/cli_agent_orchestrator/api/main.py b/src/cli_agent_orchestrator/api/main.py index 10602627e..8b743336c 100644 --- a/src/cli_agent_orchestrator/api/main.py +++ b/src/cli_agent_orchestrator/api/main.py @@ -8,7 +8,6 @@ from fastapi import FastAPI, HTTPException, Path, Query, status from fastapi.middleware.trustedhost import TrustedHostMiddleware from pydantic import BaseModel, Field, field_validator -from watchdog.observers.polling import PollingObserver from cli_agent_orchestrator.clients.database import ( create_inbox_message, @@ -21,19 +20,20 @@ SERVER_HOST, SERVER_PORT, SERVER_VERSION, - TERMINAL_LOG_DIR, ) from cli_agent_orchestrator.models.inbox import MessageStatus from cli_agent_orchestrator.models.terminal import Terminal, TerminalId from cli_agent_orchestrator.providers.manager import provider_manager from cli_agent_orchestrator.services import ( flow_service, - inbox_service, session_service, terminal_service, ) from cli_agent_orchestrator.services.cleanup_service import cleanup_old_data -from cli_agent_orchestrator.services.inbox_service import LogFileHandler +from cli_agent_orchestrator.services.event_bus import bus +from cli_agent_orchestrator.services.inbox_service import inbox_service +from cli_agent_orchestrator.services.log_writer import log_writer +from cli_agent_orchestrator.services.status_monitor import status_monitor from cli_agent_orchestrator.services.terminal_service import OutputMode from cli_agent_orchestrator.utils.agent_profiles import resolve_provider from cli_agent_orchestrator.utils.logging import setup_logging @@ -50,7 +50,7 @@ async def flow_daemon(): flows = flow_service.get_flows_to_run() for flow in flows: try: - executed = flow_service.execute_flow(flow.name) + executed = await flow_service.execute_flow(flow.name) if executed: logger.info(f"Flow '{flow.name}' executed successfully") else: @@ -90,23 +90,32 @@ async def lifespan(app: FastAPI): # Start flow daemon as background task daemon_task = asyncio.create_task(flow_daemon()) - # Start inbox watcher - inbox_observer = PollingObserver(timeout=INBOX_POLLING_INTERVAL) - inbox_observer.schedule(LogFileHandler(), str(TERMINAL_LOG_DIR), recursive=False) - inbox_observer.start() - logger.info("Inbox watcher started (PollingObserver)") + # Register event loop with event bus for thread-safe publishing + loop = asyncio.get_running_loop() + bus.set_loop(loop) - yield + # Start event bus consumers as background tasks + status_monitor_task = asyncio.create_task(status_monitor.run()) + log_writer_task = asyncio.create_task(log_writer.run()) + inbox_service_task = asyncio.create_task(inbox_service.run()) + logger.info("Event bus consumers started (StatusMonitor, LogWriter, InboxService)") - # Stop inbox observer - inbox_observer.stop() - inbox_observer.join() - logger.info("Inbox watcher stopped") + yield - # Cancel daemon on shutdown + # Cancel consumer tasks on shutdown + status_monitor_task.cancel() + log_writer_task.cancel() + inbox_service_task.cancel() daemon_task.cancel() + try: - await daemon_task + await asyncio.gather( + status_monitor_task, + log_writer_task, + inbox_service_task, + daemon_task, + return_exceptions=True, + ) except asyncio.CancelledError: pass @@ -143,7 +152,7 @@ async def create_session( ) -> Terminal: """Create a new session with exactly one terminal.""" try: - result = terminal_service.create_terminal( + result = await terminal_service.create_terminal( provider=provider, agent_profile=agent_profile, session_name=session_name, @@ -213,8 +222,7 @@ async def create_terminal_in_session( """Create additional terminal in existing session.""" try: resolved_provider = resolve_provider(agent_profile, fallback_provider=provider) - - result = terminal_service.create_terminal( + result = await terminal_service.create_terminal( provider=resolved_provider, agent_profile=agent_profile, session_name=session_name, @@ -359,12 +367,10 @@ async def create_inbox_message_endpoint( detail=f"Failed to create inbox message: {str(e)}", ) - # Best-effort immediate delivery. If the receiver terminal is idle, the - # message is delivered now; otherwise the watchdog will deliver it when - # the terminal becomes idle. Delivery failures must not cause the API - # to report an error — the message was already persisted above. + # Attempt immediate delivery if terminal is already IDLE. + # If not, InboxService will deliver on next IDLE status event. try: - inbox_service.check_and_send_pending_messages(receiver_id) + inbox_service.deliver_pending(receiver_id) except Exception as e: logger.warning(f"Immediate delivery attempt failed for {receiver_id}: {e}") diff --git a/src/cli_agent_orchestrator/constants.py b/src/cli_agent_orchestrator/constants.py index ce8b91cc1..7482115b6 100644 --- a/src/cli_agent_orchestrator/constants.py +++ b/src/cli_agent_orchestrator/constants.py @@ -49,12 +49,19 @@ TERMINAL_LOG_DIR = LOG_DIR / "terminal" # Per-terminal log files for pipe-pane output TERMINAL_LOG_DIR.mkdir(parents=True, exist_ok=True) +# FIFO directory for event-driven terminal output streaming +FIFO_DIR = CAO_HOME_DIR / "fifos" # Named pipes for tmux pipe-pane streaming +FIFO_DIR.mkdir(parents=True, exist_ok=True) + # ============================================================================= -# Inbox Service Configuration +# Event-Driven State Detection Configuration # ============================================================================= -# Polling interval for detecting log file changes (seconds) -# Lower values = faster response, higher CPU usage -INBOX_POLLING_INTERVAL = 5 +# Rolling buffer size for state detection (8KB) +# Keeps trailing 8KB of terminal output for pattern matching +STATE_BUFFER_MAX = 8192 + +# Max events buffered per subscriber queue before dropping +EVENT_BUS_MAX_QUEUE_SIZE = 1024 # ============================================================================= # Cleanup Service Configuration diff --git a/src/cli_agent_orchestrator/models/terminal.py b/src/cli_agent_orchestrator/models/terminal.py index e633fd73b..400b31b7a 100644 --- a/src/cli_agent_orchestrator/models/terminal.py +++ b/src/cli_agent_orchestrator/models/terminal.py @@ -13,6 +13,7 @@ class TerminalStatus(str, Enum): """Terminal status enumeration with provider-aware states.""" + UNKNOWN = "unknown" IDLE = "idle" PROCESSING = "processing" COMPLETED = "completed" diff --git a/src/cli_agent_orchestrator/providers/base.py b/src/cli_agent_orchestrator/providers/base.py index 88c5346b1..229d37b40 100644 --- a/src/cli_agent_orchestrator/providers/base.py +++ b/src/cli_agent_orchestrator/providers/base.py @@ -37,7 +37,6 @@ class BaseProvider(ABC): terminal_id: Unique identifier for the terminal this provider manages session_name: Name of the tmux session containing the terminal window_name: Name of the tmux window containing the terminal - _status: Internal status cache (use get_status() for current status) """ def __init__(self, terminal_id: str, session_name: str, window_name: str): @@ -51,12 +50,6 @@ def __init__(self, terminal_id: str, session_name: str, window_name: str): self.terminal_id = terminal_id self.session_name = session_name self.window_name = window_name - self._status = TerminalStatus.IDLE - - @property - def status(self) -> TerminalStatus: - """Get current provider status.""" - return self._status @property def paste_enter_count(self) -> int: @@ -72,7 +65,7 @@ def paste_enter_count(self) -> int: return 2 @abstractmethod - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize the provider (e.g., start CLI tool, send setup commands). Returns: @@ -81,26 +74,17 @@ def initialize(self) -> bool: pass @abstractmethod - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - """Get current provider status by analyzing terminal output. + def get_status(self, buffer: str) -> TerminalStatus: + """Detect terminal status from output buffer using provider-specific patterns. - Args: - tail_lines: Number of lines to capture from terminal (default: provider-specific) + Called by StatusMonitor with accumulated terminal output. - Returns: - TerminalStatus: Current status of the provider - """ - pass - - @abstractmethod - def get_idle_pattern_for_log(self) -> str: - """Get pattern that indicates IDLE state in log file output. - - Used for quick detection in file watcher before calling full get_status(). - Should return a simple pattern that appears in the IDLE prompt. + Args: + buffer: Terminal output (up to ~8KB rolling buffer) Returns: - str: Pattern to search for in log file tail + TerminalStatus - always returns a valid status. + UNKNOWN if no pattern matched, ERROR only for matched error patterns. """ pass @@ -146,12 +130,5 @@ def mark_input_received(self) -> None: Called by the terminal service after send_input() delivers a message. Providers can override this to adjust status detection behavior. - For example, providers with initial prompts can use this to - distinguish post-init idle (ready for first input) from - post-task completed. """ pass - - def _update_status(self, status: TerminalStatus) -> None: - """Update internal status.""" - self._status = status diff --git a/src/cli_agent_orchestrator/providers/claude_code.py b/src/cli_agent_orchestrator/providers/claude_code.py index 011de947e..8c1afb6fc 100644 --- a/src/cli_agent_orchestrator/providers/claude_code.py +++ b/src/cli_agent_orchestrator/providers/claude_code.py @@ -1,5 +1,6 @@ """Claude Code provider implementation.""" +import asyncio import json import logging import re @@ -37,7 +38,6 @@ class ProviderError(Exception): r"❯.*\d+\." # Pattern for Claude showing selection options with arrow cursor ) TRUST_PROMPT_PATTERN = r"Yes, I trust this folder" # Workspace trust dialog -IDLE_PROMPT_PATTERN_LOG = r"[>❯][\s\xa0]" # Same pattern for log files class ClaudeCodeProvider(BaseProvider): @@ -106,7 +106,7 @@ def _build_claude_command(self) -> str: # This correctly handles multiline strings, quotes, and special characters return shlex.join(command_parts) - def _handle_trust_prompt(self, timeout: float = 20.0) -> None: + async def _handle_trust_prompt(self, timeout: float = 20.0) -> None: """Auto-accept the workspace trust prompt if it appears. Claude Code shows a trust dialog when opening an untrusted directory. @@ -118,7 +118,7 @@ def _handle_trust_prompt(self, timeout: float = 20.0) -> None: while time.time() - start_time < timeout: output = tmux_client.get_history(self.session_name, self.window_name) if not output: - time.sleep(1.0) + await asyncio.sleep(1.0) continue # Clean ANSI codes for reliable text matching @@ -139,13 +139,13 @@ def _handle_trust_prompt(self, timeout: float = 20.0) -> None: logger.info("Claude Code started without trust prompt") return - time.sleep(1.0) + await asyncio.sleep(1.0) logger.warning("Trust prompt handler timed out") - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Claude Code provider by starting claude command.""" # Wait for shell prompt to appear in the tmux window - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") # Build properly escaped command string @@ -155,13 +155,13 @@ def initialize(self) -> bool: tmux_client.send_keys(self.session_name, self.window_name, command) # Handle workspace trust prompt if it appears (new/untrusted directories) - self._handle_trust_prompt(timeout=20.0) + await self._handle_trust_prompt(timeout=20.0) # Wait for Claude Code prompt to be ready. # Accept both IDLE and COMPLETED — some CLI versions show a startup # message that get_status() interprets as a completed response. - if not wait_until_status( - self, + if not await wait_until_status( + self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0, polling_interval=1.0, @@ -171,14 +171,9 @@ def initialize(self) -> bool: self._initialized = True return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - """Get Claude Code status by analyzing terminal output.""" - - # Use tmux client singleton to get window history - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - + def get_status(self, output: str) -> TerminalStatus: if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN # Check for processing state first if re.search(PROCESSING_PATTERN, output): @@ -199,12 +194,7 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: if re.search(IDLE_PROMPT_PATTERN, output): return TerminalStatus.IDLE - # If no recognizable state, return ERROR - return TerminalStatus.ERROR - - def get_idle_pattern_for_log(self) -> str: - """Return Claude Code IDLE prompt pattern for log files.""" - return IDLE_PROMPT_PATTERN_LOG + return TerminalStatus.UNKNOWN def extract_last_message_from_script(self, script_output: str) -> str: """Extract Claude's final response message using ⏺ indicator.""" diff --git a/src/cli_agent_orchestrator/providers/codex.py b/src/cli_agent_orchestrator/providers/codex.py index 2b49cd357..d9b3bd836 100644 --- a/src/cli_agent_orchestrator/providers/codex.py +++ b/src/cli_agent_orchestrator/providers/codex.py @@ -1,5 +1,6 @@ """Codex CLI provider implementation.""" +import asyncio import logging import re import shlex @@ -28,7 +29,6 @@ # is active. This is intentionally permissive — _has_idle_pattern() is a # lightweight pre-check; the real status decision is made by get_status() # which uses capture-pane (rendered screen). -IDLE_PROMPT_PATTERN_LOG = r"\? for shortcuts" # Match assistant response start: "assistant:/codex:/agent:" (label style from synthetic # test fixtures) or "•" bullet point (real Codex interactive output format). ASSISTANT_PREFIX_PATTERN = r"^(?:(?:assistant|codex|agent)\s*:|\s*•)" @@ -189,7 +189,7 @@ def _build_codex_command(self) -> str: return shlex.join(command_parts) - def _handle_trust_prompt(self, timeout: float = 20.0) -> None: + async def _handle_trust_prompt(self, timeout: float = 20.0) -> None: """Auto-accept the workspace trust prompt if it appears. Codex shows a folder approval dialog when opening a new directory. @@ -201,7 +201,7 @@ def _handle_trust_prompt(self, timeout: float = 20.0) -> None: while time.time() - start_time < timeout: output = tmux_client.get_history(self.session_name, self.window_name) if not output: - time.sleep(1.0) + await asyncio.sleep(1.0) continue # Clean ANSI codes for reliable text matching @@ -221,19 +221,19 @@ def _handle_trust_prompt(self, timeout: float = 20.0) -> None: logger.info("Codex started without trust prompt") return - time.sleep(1.0) + await asyncio.sleep(1.0) logger.warning("Codex trust prompt handler timed out") - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Codex provider by starting codex command.""" - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") # Send a warm-up command before launching codex. # Codex exits immediately in freshly-created tmux sessions where the shell # has not yet processed a full interactive command cycle. tmux_client.send_keys(self.session_name, self.window_name, "echo ready") - time.sleep(2.0) + await asyncio.sleep(2.0) # Build command with flags and agent profile (developer_instructions). # --no-alt-screen: run in inline mode so output stays in normal scrollback, @@ -244,10 +244,10 @@ def initialize(self) -> bool: tmux_client.send_keys(self.session_name, self.window_name, command) # Handle workspace trust prompt if it appears (new/untrusted directories) - self._handle_trust_prompt(timeout=20.0) + await self._handle_trust_prompt(timeout=20.0) - if not wait_until_status( - self, + if not await wait_until_status( + self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=60.0, polling_interval=1.0, @@ -257,12 +257,9 @@ def initialize(self) -> bool: self._initialized = True return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - """Get Codex status by analyzing terminal output.""" - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - + def get_status(self, output: str) -> TerminalStatus: if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN clean_output = re.sub(ANSI_CODE_PATTERN, "", output) tail_output = "\n".join(clean_output.splitlines()[-25:]) @@ -358,10 +355,6 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: # assume the CLI is still producing output. return TerminalStatus.PROCESSING - def get_idle_pattern_for_log(self) -> str: - """Return Codex IDLE prompt pattern for log files.""" - return IDLE_PROMPT_PATTERN_LOG - def extract_last_message_from_script(self, script_output: str) -> str: """Extract Codex's final response from terminal output. diff --git a/src/cli_agent_orchestrator/providers/gemini_cli.py b/src/cli_agent_orchestrator/providers/gemini_cli.py index 4fc1f4bf6..135cf63d1 100644 --- a/src/cli_agent_orchestrator/providers/gemini_cli.py +++ b/src/cli_agent_orchestrator/providers/gemini_cli.py @@ -27,6 +27,7 @@ - ERROR: Error message patterns or empty output """ +import asyncio import json import logging import os @@ -71,10 +72,6 @@ class ProviderError(Exception): # use 50 to account for tall terminals and additional TUI padding. IDLE_PROMPT_TAIL_LINES = 50 -# Simplified idle pattern for log file monitoring. -# Just looks for the asterisk + "Type your message" text for quick detection. -IDLE_PROMPT_PATTERN_LOG = r"\*.*Type your message" - # Gemini welcome banner, shown once during startup as ASCII art. # The banner includes the word "GEMINI" in block characters using █ and ░. # Used to detect successful initialization. @@ -344,7 +341,7 @@ def _unregister_mcp_servers(self) -> None: self._mcp_server_names = [] - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Gemini CLI provider by starting the gemini command. Steps: @@ -358,17 +355,14 @@ def initialize(self) -> bool: Raises: TimeoutError: If shell or Gemini CLI doesn't start within timeout """ - # Wait for shell prompt to appear in the tmux window - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + from cli_agent_orchestrator.services.status_monitor import status_monitor + + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") - # Send a warm-up command before launching Gemini. - # Gemini's Ink TUI exits silently in freshly-created tmux sessions where - # the shell environment (PATH, node, nvm, homebrew) is not fully loaded. - # wait_for_shell() returns when the prompt text stabilizes, but slow - # shell init scripts (.zshrc, brew shellenv) may still be running. - # An echo round-trip with output verification ensures the shell has - # fully processed its init before we launch gemini. + # Shell warm-up: Gemini's Ink TUI exits silently in freshly-created + # tmux sessions where the shell environment is not fully loaded. + # An echo round-trip ensures the shell has fully processed its init. warmup_marker = "CAO_SHELL_READY" tmux_client.send_keys(self.session_name, self.window_name, f"echo {warmup_marker}") warmup_start = time.time() @@ -377,37 +371,23 @@ def initialize(self) -> bool: output = tmux_client.get_history(self.session_name, self.window_name) if output and warmup_marker in output: break - time.sleep(0.5) + await asyncio.sleep(0.5) else: logger.warning("Shell warm-up marker not detected within timeout, proceeding anyway") # Allow the shell to fully render the post-echo prompt before sending # the next paste. Without this delay, zsh may still be processing the # previous command's output when the bracketed paste arrives, causing - # the gemini command to be silently dropped. 2 seconds is sufficient - # for prompt rendering + any .zshrc hooks. - time.sleep(2) + # the gemini command to be silently dropped. + await asyncio.sleep(2) - # Build properly escaped command string command = self._build_gemini_command() - - # Send Gemini command to the tmux window tmux_client.send_keys(self.session_name, self.window_name, command) # Wait for Gemini CLI to finish initialization. - # Gemini takes 10-15+ seconds to load due to Node.js/Ink startup. - # - # IMPORTANT: Gemini's Ink TUI shows the idle prompt ("* Type your - # message") immediately on startup, BEFORE the -i prompt is processed - # and BEFORE MCP servers are connected. If we accept IDLE too early, - # messages sent to the terminal are lost because Gemini is still - # processing the -i system prompt (lesson #13c). - # # When -i is used: wait for COMPLETED specifically. The -i flag always - # produces a response (query + ✦ response + idle prompt), so COMPLETED - # means the system prompt has been fully processed and Gemini is ready. - # - # Without -i: accept IDLE (just the idle prompt, no prior interaction). + # produces a response, so COMPLETED means the system prompt has been + # fully processed. Without -i: accept IDLE or COMPLETED. init_start = time.time() init_timeout = 240.0 # MCP server download (uvx from git) + -i prompt processing if self._uses_prompt_interactive: @@ -416,14 +396,13 @@ def initialize(self) -> bool: target_states = (TerminalStatus.IDLE, TerminalStatus.COMPLETED) while time.time() - init_start < init_timeout: - status = self.get_status() + status = status_monitor.get_status(self.terminal_id) if status in target_states: break - time.sleep(1.0) + await asyncio.sleep(1.0) else: - # Capture diagnostic info for debugging initialization failures. - diag_output = tmux_client.get_history(self.session_name, self.window_name) - diag_last_50 = "\n".join((diag_output or "").splitlines()[-50:]) + buf = status_monitor.get_buffer(self.terminal_id) + diag_last_50 = "\n".join((buf or "").splitlines()[-50:]) logger.error( f"Gemini CLI init timeout diagnostic — terminal {self.terminal_id}, " f"uses_prompt_interactive={self._uses_prompt_interactive}, " @@ -432,7 +411,7 @@ def initialize(self) -> bool: ) raise TimeoutError( f"Gemini CLI initialization timed out after {init_timeout}s. " - f"Last status: {self.get_status()}" + f"Last status: {status_monitor.get_status(self.terminal_id)}" ) self._initialized = True @@ -447,27 +426,9 @@ def mark_input_received(self) -> None: """ self._received_input_after_init = True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - """Get Gemini CLI status by analyzing terminal output. - - Status detection logic: - 1. Capture tmux pane output (full or tail) - 2. Strip ANSI codes for reliable text matching - 3. Check bottom N lines for the idle prompt pattern (* + placeholder text) - 4. If idle prompt found: distinguish IDLE vs COMPLETED by checking for ✦ response - 5. If no idle prompt: check for processing indicators or errors - 6. Check for ERROR patterns as fallback - - Args: - tail_lines: Optional number of lines to capture from bottom - - Returns: - TerminalStatus indicating current state - """ - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - + def get_status(self, output: str) -> TerminalStatus: if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN # Strip ANSI codes for reliable pattern matching clean_output = re.sub(ANSI_CODE_PATTERN, "", output) diff --git a/src/cli_agent_orchestrator/providers/kimi_cli.py b/src/cli_agent_orchestrator/providers/kimi_cli.py index 1d2c1d560..6ce835153 100644 --- a/src/cli_agent_orchestrator/providers/kimi_cli.py +++ b/src/cli_agent_orchestrator/providers/kimi_cli.py @@ -301,7 +301,7 @@ def _ensure_mcp_timeout(cls) -> None: cls._mcp_timeout_configured = True - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Kimi CLI provider by starting the kimi command. Steps: @@ -316,7 +316,7 @@ def initialize(self) -> bool: TimeoutError: If shell or Kimi CLI doesn't start within timeout """ # Wait for shell prompt to appear in the tmux window - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") # Build properly escaped command string @@ -330,8 +330,8 @@ def initialize(self) -> bool: # message that get_status() interprets as a completed response. # Longer timeout (120s) to account for first-run setup and when # multiple Kimi instances are starting concurrently (e.g. assign flow). - if not wait_until_status( - self, + if not await wait_until_status( + self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=120.0, polling_interval=1.0, @@ -341,18 +341,17 @@ def initialize(self) -> bool: self._initialized = True return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: + def get_status(self, output: str) -> TerminalStatus: """Get Kimi CLI status by analyzing terminal output. Status detection logic: - 1. Capture tmux pane output (full or tail) - 2. Strip ANSI codes for reliable text matching - 3. Latch ``_has_received_input`` when user input box (╭─) is detected - 4. Check bottom N lines for the idle prompt pattern - 5. If prompt found + input was received → COMPLETED - 6. If prompt found + no input yet → IDLE - 7. If no prompt: agent is PROCESSING (streaming response) - 8. Check for ERROR patterns as fallback + 1. Strip ANSI codes for reliable text matching + 2. Latch ``_has_received_input`` when user input box (╭─) is detected + 3. Check bottom N lines for the idle prompt pattern + 4. If prompt found + input was received → COMPLETED + 5. If prompt found + no input yet → IDLE + 6. If no prompt: agent is PROCESSING (streaming response) + 7. Check for ERROR patterns as fallback The latching flag approach is necessary because: - Long responses (>200 lines) push the user input box out of the @@ -363,15 +362,13 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: IS still visible in the capture, and persists through completion Args: - tail_lines: Optional number of lines to capture from bottom + output: Terminal output buffer (up to ~8KB rolling buffer) Returns: TerminalStatus indicating current state """ - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN # Strip ANSI codes for reliable pattern matching clean_output = re.sub(ANSI_CODE_PATTERN, "", output) @@ -421,14 +418,6 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: # No prompt visible and no error: Kimi is actively processing/streaming return TerminalStatus.PROCESSING - def get_idle_pattern_for_log(self) -> str: - """Return Kimi CLI idle prompt pattern for log file monitoring. - - Used by the inbox service for quick IDLE state detection in pipe-pane - log files before calling the full get_status() method. - """ - return IDLE_PROMPT_PATTERN_LOG - def extract_last_message_from_script(self, script_output: str) -> str: """Extract Kimi's final response from terminal output. diff --git a/src/cli_agent_orchestrator/providers/kiro_cli.py b/src/cli_agent_orchestrator/providers/kiro_cli.py index 914b33f52..e268bd2a9 100644 --- a/src/cli_agent_orchestrator/providers/kiro_cli.py +++ b/src/cli_agent_orchestrator/providers/kiro_cli.py @@ -26,6 +26,7 @@ from cli_agent_orchestrator.models.terminal import TerminalStatus from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status +from cli_agent_orchestrator.utils.text import strip_terminal_escapes logger = logging.getLogger(__name__) @@ -33,24 +34,10 @@ # Regex Patterns for Kiro CLI Output Analysis # ============================================================================= -# Green arrow pattern indicates the start of an agent response (ANSI-stripped) +# Green arrow pattern indicates the start of an agent response (escape-stripped) # Example: "> Here is the code you requested..." GREEN_ARROW_PATTERN = r"^>\s*" -# ANSI escape code pattern for stripping terminal colors -# Matches sequences like \x1b[32m (green), \x1b[0m (reset), etc. -ANSI_CODE_PATTERN = r"\x1b\[[0-9;]*m" - -# Additional escape sequences that may appear in terminal output -ESCAPE_SEQUENCE_PATTERN = r"\[[?0-9;]*[a-zA-Z]" - -# Control characters to strip from final output -CONTROL_CHAR_PATTERN = r"[\x00-\x1f\x7f-\x9f]" - -# Bell character (audible alert) -BELL_CHAR = "\x07" -IDLE_PROMPT_PATTERN_LOG = r"\x1b\[38;5;\d+m\[.+?\].*\x1b\[38;5;\d+m>\s*\x1b\[\d*m" - # ============================================================================= # Error Detection # ============================================================================= @@ -99,7 +86,7 @@ def __init__(self, terminal_id: str, session_name: str, window_name: str, agent_ ) self._permission_prompt_pattern = r"Allow this action\?.*?\[.*?y.*?/.*?n.*?/.*?t.*?\]:" - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Kiro CLI provider by starting kiro-cli chat command. This method: @@ -115,7 +102,7 @@ def initialize(self) -> bool: """ # Step 1: Wait for shell prompt to appear in the tmux window # This ensures the terminal is ready before we send commands - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") # Step 2: Start the Kiro CLI chat session with the specified agent profile @@ -125,42 +112,31 @@ def initialize(self) -> bool: # Step 3: Wait for Kiro CLI to fully initialize and show the agent prompt. # Accept both IDLE and COMPLETED — some CLI versions show a startup # message that get_status() interprets as a completed response. - if not wait_until_status( - self, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 + if not await wait_until_status( + self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 ): raise TimeoutError("Kiro CLI initialization timed out after 30 seconds") self._initialized = True return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: + def get_status(self, output: str) -> TerminalStatus: """Get Kiro CLI status by analyzing terminal output. Status detection logic (in priority order): - 1. No output → ERROR + 1. No output → UNKNOWN 2. No IDLE prompt visible → PROCESSING (agent is generating response) 3. Error indicators present → ERROR 4. Permission prompt visible → WAITING_USER_ANSWER 5. Green arrow + prompt visible → COMPLETED (response ready) 6. Only prompt visible → IDLE (waiting for input) - - Args: - tail_lines: Number of lines to capture from terminal history. - If None, uses default from tmux_client. - - Returns: - Current TerminalStatus enum value """ - logger.debug(f"get_status: tail_lines={tail_lines}") - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - - # No output indicates a terminal error if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN # Strip ANSI codes once for all pattern matching # This simplifies regex patterns and improves reliability - clean_output = re.sub(ANSI_CODE_PATTERN, "", output) + clean_output = strip_terminal_escapes(output) # Check 1: Look for the agent's IDLE prompt pattern # If not found, the agent is still processing a response @@ -209,7 +185,7 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: def extract_last_message_from_script(self, script_output: str) -> str: """Extract agent's final response message using green arrow indicator.""" # Strip ANSI codes for pattern matching - clean_output = re.sub(ANSI_CODE_PATTERN, "", script_output) + clean_output = strip_terminal_escapes(script_output) # Find patterns in clean output green_arrows = list(re.finditer(GREEN_ARROW_PATTERN, clean_output, re.MULTILINE)) @@ -245,16 +221,8 @@ def extract_last_message_from_script(self, script_output: str) -> str: if not final_answer: raise ValueError("Empty Kiro CLI response - no content found") - # Clean up the message - final_answer = re.sub(ANSI_CODE_PATTERN, "", final_answer) - final_answer = re.sub(ESCAPE_SEQUENCE_PATTERN, "", final_answer) - final_answer = re.sub(CONTROL_CHAR_PATTERN, "", final_answer) return final_answer.strip() - def get_idle_pattern_for_log(self) -> str: - """Return Kiro CLI IDLE prompt pattern for log files.""" - return IDLE_PROMPT_PATTERN_LOG - def exit_cli(self) -> str: """Get the command to exit Kiro CLI.""" return "/exit" diff --git a/src/cli_agent_orchestrator/providers/q_cli.py b/src/cli_agent_orchestrator/providers/q_cli.py index 116965f84..75058d3c3 100644 --- a/src/cli_agent_orchestrator/providers/q_cli.py +++ b/src/cli_agent_orchestrator/providers/q_cli.py @@ -18,7 +18,6 @@ ESCAPE_SEQUENCE_PATTERN = r"\[[?0-9;]*[a-zA-Z]" CONTROL_CHAR_PATTERN = r"[\x00-\x1f\x7f-\x9f]" BELL_CHAR = "\x07" -IDLE_PROMPT_PATTERN_LOG = r"\x1b\[38;5;13m>\s*\x1b\[39m" # Error indicators ERROR_INDICATORS = ["Amazon Q is having trouble responding right now"] @@ -41,30 +40,26 @@ def __init__(self, terminal_id: str, session_name: str, window_name: str, agent_ ) self._permission_prompt_pattern = r"Allow this action\?.*?\[.*?y.*?/.*?n.*?/.*?t.*?\]:" - def initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Q CLI provider by starting q chat command.""" # Wait for shell to be ready first - if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): + if not await wait_for_shell(self.terminal_id, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") command = shlex.join(["q", "chat", "--agent", self._agent_profile]) tmux_client.send_keys(self.session_name, self.window_name, command) - if not wait_until_status( - self, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 + if not await wait_until_status( + self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 ): raise TimeoutError("Q CLI initialization timed out after 30 seconds") self._initialized = True return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - """Get Q CLI status by analyzing terminal output.""" - logger.debug(f"get_status: tail_lines={tail_lines}") - output = tmux_client.get_history(self.session_name, self.window_name, tail_lines=tail_lines) - + def get_status(self, output: str) -> TerminalStatus: if not output: - return TerminalStatus.ERROR + return TerminalStatus.UNKNOWN # Strip ANSI codes once for all pattern matching clean_output = re.sub(ANSI_CODE_PATTERN, "", output) @@ -154,10 +149,6 @@ def extract_last_message_from_script(self, script_output: str) -> str: final_answer = re.sub(CONTROL_CHAR_PATTERN, "", final_answer) return final_answer.strip() - def get_idle_pattern_for_log(self) -> str: - """Return Q CLI IDLE prompt pattern for log files.""" - return IDLE_PROMPT_PATTERN_LOG - # TODO: exit_cli should run the tmux.send_keys directly with /exit or ctrl-c twice def exit_cli(self) -> str: """Get the command to exit Q CLI.""" diff --git a/src/cli_agent_orchestrator/services/cleanup_service.py b/src/cli_agent_orchestrator/services/cleanup_service.py index 5d97cf994..22f4d83f5 100644 --- a/src/cli_agent_orchestrator/services/cleanup_service.py +++ b/src/cli_agent_orchestrator/services/cleanup_service.py @@ -6,6 +6,8 @@ from cli_agent_orchestrator.clients.database import InboxModel, SessionLocal, TerminalModel from cli_agent_orchestrator.constants import LOG_DIR, RETENTION_DAYS, TERMINAL_LOG_DIR +from cli_agent_orchestrator.services.fifo_reader import fifo_manager +from cli_agent_orchestrator.services.status_monitor import status_monitor logger = logging.getLogger(__name__) @@ -18,8 +20,14 @@ def cleanup_old_data(): f"Starting cleanup of data older than {RETENTION_DAYS} days (before {cutoff_date})" ) - # Clean up old terminals + # Clean up old terminals (stop FIFO readers and clear state first) with SessionLocal() as db: + old_terminals = ( + db.query(TerminalModel).filter(TerminalModel.last_active < cutoff_date).all() + ) + for terminal in old_terminals: + fifo_manager.stop_reader(terminal.id) + status_monitor.clear_terminal(terminal.id) deleted_terminals = ( db.query(TerminalModel).filter(TerminalModel.last_active < cutoff_date).delete() ) diff --git a/src/cli_agent_orchestrator/services/event_bus.py b/src/cli_agent_orchestrator/services/event_bus.py new file mode 100644 index 000000000..290e4ebb0 --- /dev/null +++ b/src/cli_agent_orchestrator/services/event_bus.py @@ -0,0 +1,97 @@ +"""In-process pub/sub event bus with wildcard topic matching. + +Event Topics: +- terminal.{id}.output → raw output chunks (from FIFO readers) +- terminal.{id}.status → status changes (from StatusMonitor) +""" + +import asyncio +import logging +import re +import threading +from typing import Dict, List, Optional, Tuple + +from cli_agent_orchestrator.constants import EVENT_BUS_MAX_QUEUE_SIZE + +logger = logging.getLogger(__name__) + + +class EventBus: + """Thread-safe publishing, async consumption via asyncio.Queue.""" + + def __init__(self): + self._exact: Dict[str, List[asyncio.Queue]] = {} + self._wildcard: Dict[str, Tuple[re.Pattern, List[asyncio.Queue]]] = {} + self._lock = threading.Lock() + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Register the asyncio event loop (required for thread-safe publishing).""" + self._loop = loop + + def publish(self, topic: str, data: dict) -> None: + """Publish event to all matching subscribers. Safe to call from any thread.""" + if self._loop: + self._loop.call_soon_threadsafe(self._dispatch, topic, data) + + def subscribe(self, pattern: str) -> asyncio.Queue: + """Subscribe to a topic pattern (e.g., 'terminal.*.output'). Returns async queue.""" + queue: asyncio.Queue = asyncio.Queue(maxsize=EVENT_BUS_MAX_QUEUE_SIZE) + + with self._lock: + if "*" in pattern: + regex = pattern.replace(".", r"\.").replace("*", "[^.]+") + if regex not in self._wildcard: + self._wildcard[regex] = (re.compile(f"^{regex}$"), []) + self._wildcard[regex][1].append(queue) + else: + if pattern not in self._exact: + self._exact[pattern] = [] + self._exact[pattern].append(queue) + + return queue + + def unsubscribe(self, pattern: str, queue: asyncio.Queue) -> None: + """Remove a queue from a subscription pattern.""" + with self._lock: + if "*" in pattern: + regex = pattern.replace(".", r"\.").replace("*", "[^.]+") + if regex in self._wildcard: + queues = self._wildcard[regex][1] + try: + queues.remove(queue) + except ValueError: + pass + if not queues: + del self._wildcard[regex] + else: + if pattern in self._exact: + try: + self._exact[pattern].remove(queue) + except ValueError: + pass + if not self._exact[pattern]: + del self._exact[pattern] + + def _dispatch(self, topic: str, data: dict) -> None: + """Route event to matching subscriber queues.""" + event = {"topic": topic, "data": data} + with self._lock: + # O(1) exact match lookup + for q in self._exact.get(topic, []): + try: + q.put_nowait(event) + except asyncio.QueueFull: + logger.error(f"Queue full, dropping event: {topic}") + + # Wildcard pattern matching + for compiled, queues in self._wildcard.values(): + if compiled.match(topic): + for q in queues: + try: + q.put_nowait(event) + except asyncio.QueueFull: + logger.error(f"Queue full, dropping event: {topic}") + + +bus = EventBus() diff --git a/src/cli_agent_orchestrator/services/fifo_reader.py b/src/cli_agent_orchestrator/services/fifo_reader.py new file mode 100644 index 000000000..7e3399a9c --- /dev/null +++ b/src/cli_agent_orchestrator/services/fifo_reader.py @@ -0,0 +1,104 @@ +"""FIFO reader for streaming terminal output from tmux pipe-pane. + +Publisher: terminal.{id}.output +""" + +import logging +import os +import threading +import time +from typing import Dict + +from cli_agent_orchestrator.constants import FIFO_DIR +from cli_agent_orchestrator.services.event_bus import bus + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 4096 + + +class FifoManager: + """Manages FIFO lifecycle: create named pipe, start reader thread, stop and cleanup.""" + + def __init__(self): + self._readers: Dict[str, threading.Event] = {} # terminal_id -> stop flag + self._threads: Dict[str, threading.Thread] = {} + self._lock = threading.Lock() + FIFO_DIR.mkdir(parents=True, exist_ok=True) + + def create_reader(self, terminal_id: str) -> None: + """Create FIFO and start reader thread.""" + fifo_path = FIFO_DIR / f"{terminal_id}.fifo" + + with self._lock: + if terminal_id in self._readers: + return + + if not fifo_path.exists(): + os.mkfifo(fifo_path) + + stop_flag = threading.Event() + thread = threading.Thread( + target=self._reader_loop, + args=(terminal_id, fifo_path, stop_flag), + daemon=True, + name=f"fifo-{terminal_id}", + ) + self._readers[terminal_id] = stop_flag + self._threads[terminal_id] = thread + thread.start() + + logger.info(f"Started FIFO reader for terminal {terminal_id}") + + def stop_reader(self, terminal_id: str) -> None: + """Stop reader thread and delete FIFO file.""" + with self._lock: + stop_flag = self._readers.pop(terminal_id, None) + thread = self._threads.pop(terminal_id, None) + + if not stop_flag or not thread: + return + + stop_flag.set() + + # Unblock thread if stuck on open() by briefly opening write side + fifo_path = FIFO_DIR / f"{terminal_id}.fifo" + try: + fd = os.open(fifo_path, os.O_WRONLY | os.O_NONBLOCK) + os.close(fd) + except OSError: + pass + + thread.join(timeout=2.0) + + try: + fifo_path.unlink() + except OSError: + pass + + logger.info(f"Stopped FIFO reader for terminal {terminal_id}") + + @staticmethod + def _reader_loop(terminal_id: str, fifo_path, stop_flag: threading.Event) -> None: + """Read chunks from FIFO and publish to event bus. Reopens on EOF.""" + while not stop_flag.is_set(): + fd = -1 + try: + fd = os.open(str(fifo_path), os.O_RDONLY) + while not stop_flag.is_set(): + raw = os.read(fd, CHUNK_SIZE) + if not raw: + break + chunk = raw.decode("utf-8", errors="replace") + bus.publish(f"terminal.{terminal_id}.output", {"data": chunk}) + except Exception as e: + if not stop_flag.is_set(): + logger.error(f"FIFO read error for terminal {terminal_id}: {e}") + time.sleep(1.0) + finally: + if fd >= 0: + os.close(fd) + + +# Module-level singleton +fifo_manager = FifoManager() diff --git a/src/cli_agent_orchestrator/services/flow_service.py b/src/cli_agent_orchestrator/services/flow_service.py index 24b1663c2..bd406bcc4 100644 --- a/src/cli_agent_orchestrator/services/flow_service.py +++ b/src/cli_agent_orchestrator/services/flow_service.py @@ -142,7 +142,7 @@ def enable_flow(name: str) -> bool: return True -def execute_flow(name: str) -> bool: +async def execute_flow(name: str) -> bool: """Execute flow: run script, render prompt, launch session.""" try: logger.info(f"Executing flow: {name}") @@ -202,7 +202,7 @@ def execute_flow(name: str) -> bool: # Launch session session_name = generate_session_name() - terminal = create_terminal( + terminal = await create_terminal( session_name=session_name, provider=flow.provider, agent_profile=flow.agent_profile, diff --git a/src/cli_agent_orchestrator/services/inbox_service.py b/src/cli_agent_orchestrator/services/inbox_service.py index 6761518e8..dab2d0a24 100644 --- a/src/cli_agent_orchestrator/services/inbox_service.py +++ b/src/cli_agent_orchestrator/services/inbox_service.py @@ -1,151 +1,59 @@ -"""Inbox service with watchdog for automatic message delivery. +"""Delivers queued inbox messages when terminals become ready. -This module provides the inbox functionality for agent-to-agent communication, -using file system monitoring to detect when agents become idle and can receive messages. - -Architecture: -- Messages are queued in the database (inbox table) via send_message MCP tool -- LogFileHandler monitors terminal log files for changes using watchdog -- When a terminal becomes idle (detected via log patterns), pending messages are delivered -- Messages are sent via terminal_service.send_input() which types into the tmux pane - -Message Flow: -1. Agent A calls send_message(terminal_id, message) → message queued in DB -2. Agent B's terminal log file updates (via tmux pipe-pane) -3. LogFileHandler.on_modified() triggered → checks for pending messages -4. If terminal is IDLE and has pending messages → deliver via send_input() -5. Message status updated to DELIVERED or FAILED - -Performance Optimization: -- Uses fast log tail check before expensive tmux status queries -- Only queries full provider status when idle pattern detected in log +Consumer: terminal.{id}.status """ import logging -import re -import subprocess -from pathlib import Path - -from watchdog.events import FileModifiedEvent, FileSystemEventHandler from cli_agent_orchestrator.clients.database import get_pending_messages, update_message_status -from cli_agent_orchestrator.constants import TERMINAL_LOG_DIR from cli_agent_orchestrator.models.inbox import MessageStatus from cli_agent_orchestrator.models.terminal import TerminalStatus -from cli_agent_orchestrator.providers.manager import provider_manager from cli_agent_orchestrator.services import terminal_service +from cli_agent_orchestrator.services.event_bus import bus +from cli_agent_orchestrator.services.status_monitor import status_monitor +from cli_agent_orchestrator.utils.event import terminal_id_from_topic logger = logging.getLogger(__name__) -def _get_log_tail(terminal_id: str, lines: int = 100) -> str: - """Get last N lines from terminal log file. - - Default of 100 lines covers full-screen TUI providers where the idle - prompt sits mid-screen with 30+ padding lines below it. - Reading 100 lines via tail is still sub-millisecond. - """ - log_path = TERMINAL_LOG_DIR / f"{terminal_id}.log" - try: - result = subprocess.run( - ["tail", "-n", str(lines), str(log_path)], capture_output=True, text=True, timeout=1 - ) - return result.stdout - except Exception: - return "" - - -def _has_idle_pattern(terminal_id: str) -> bool: - """Check if log tail contains idle pattern without expensive tmux calls.""" - tail = _get_log_tail(terminal_id) - if not tail: - return False - - try: - provider = provider_manager.get_provider(terminal_id) - if provider is None: - return False - idle_pattern = provider.get_idle_pattern_for_log() - return bool(re.search(idle_pattern, tail)) - except Exception: - return False - - -def check_and_send_pending_messages(terminal_id: str) -> bool: - """Check for pending messages and send if terminal is ready. +class InboxService: + """Delivers one pending message per terminal per IDLE cycle.""" - Args: - terminal_id: Terminal ID to check messages for + async def run(self) -> None: + queue = bus.subscribe("terminal.*.status") + logger.info("InboxService started") - Returns: - bool: True if a message was sent, False otherwise + while True: + try: + event = await queue.get() + status_value = event["data"]["status"] + if status_value in (TerminalStatus.IDLE.value, TerminalStatus.COMPLETED.value): + terminal_id = terminal_id_from_topic(event["topic"]) + self.deliver_pending(terminal_id) + except Exception as e: + logger.error(f"Error in InboxService: {e}") - Raises: - ValueError: If provider not found for terminal - """ - # Check for pending messages - messages = get_pending_messages(terminal_id, limit=1) - if not messages: - return False + def deliver_pending(self, terminal_id: str, num_messages: int = 1) -> None: + """Deliver pending messages to terminal. Use num_messages=0 for all.""" + limit = num_messages if num_messages > 0 else 100 + messages = get_pending_messages(terminal_id, limit=limit) + if not messages: + return - message = messages[0] + status = status_monitor.get_status(terminal_id) + if status not in (TerminalStatus.IDLE, TerminalStatus.COMPLETED): + return - # Get provider and check status - provider = provider_manager.get_provider(terminal_id) - if provider is None: - raise ValueError(f"Provider not found for terminal {terminal_id}") - # Let the provider use its own default tail_lines. Each provider knows how - # many lines it needs to reliably detect the idle prompt (TUI providers - # need 50 lines due to TUI padding). Previously this passed - # INBOX_SERVICE_TAIL_LINES=5, which was too few for TUI-based providers — - # the idle prompt was never found, so messages stayed PENDING forever. - status = provider.get_status() - - if status not in (TerminalStatus.IDLE, TerminalStatus.COMPLETED): - logger.debug(f"Terminal {terminal_id} not ready (status={status})") - return False - - # Send message - try: - terminal_service.send_input(terminal_id, message.message) - update_message_status(message.id, MessageStatus.DELIVERED) - logger.info(f"Delivered message {message.id} to terminal {terminal_id}") - return True - except Exception as e: - logger.error(f"Failed to send message {message.id} to {terminal_id}: {e}") - update_message_status(message.id, MessageStatus.FAILED) - raise - - -class LogFileHandler(FileSystemEventHandler): - """Handler for terminal log file changes.""" - - def on_modified(self, event): - """Handle file modification events.""" - if isinstance(event, FileModifiedEvent) and event.src_path.endswith(".log"): - log_path = Path(event.src_path) - terminal_id = log_path.stem - logger.debug(f"Log file modified: {terminal_id}.log") - self._handle_log_change(terminal_id) - - def _handle_log_change(self, terminal_id: str): - """Handle log file change and attempt message delivery.""" + combined = "\n".join(m.message for m in messages) try: - # Check for pending messages first - messages = get_pending_messages(terminal_id, limit=1) - if not messages: - logger.debug(f"No pending messages for {terminal_id}, skipping") - return - - # Fast check: does log tail have idle pattern? - if not _has_idle_pattern(terminal_id): - logger.debug( - f"Terminal {terminal_id} not idle (no idle pattern in log tail), skipping" - ) - return + terminal_service.send_input(terminal_id, combined) + for message in messages: + update_message_status(message.id, MessageStatus.DELIVERED) + logger.info(f"Delivered {len(messages)} message(s) to terminal {terminal_id}") + except Exception as e: + for message in messages: + logger.error(f"Failed to deliver message {message.id} to {terminal_id}: {e}") + update_message_status(message.id, MessageStatus.FAILED) - # Attempt delivery - check_and_send_pending_messages(terminal_id) - except Exception as e: - logger.error(f"Error handling log change for {terminal_id}: {e}") +inbox_service = InboxService() diff --git a/src/cli_agent_orchestrator/services/log_writer.py b/src/cli_agent_orchestrator/services/log_writer.py new file mode 100644 index 000000000..35a68072d --- /dev/null +++ b/src/cli_agent_orchestrator/services/log_writer.py @@ -0,0 +1,38 @@ +"""Writes terminal output to per-terminal log files for debugging. + +Consumer: terminal.{id}.output +""" + +import asyncio +import logging + +from cli_agent_orchestrator.constants import TERMINAL_LOG_DIR +from cli_agent_orchestrator.services.event_bus import bus +from cli_agent_orchestrator.utils.event import terminal_id_from_topic + +logger = logging.getLogger(__name__) + + +class LogWriter: + """Appends terminal output chunks to log files.""" + + async def run(self) -> None: + queue = bus.subscribe("terminal.*.output") + logger.info("LogWriter started") + + while True: + try: + event = await queue.get() + terminal_id = terminal_id_from_topic(event["topic"]) + log_path = TERMINAL_LOG_DIR / f"{terminal_id}.log" + await asyncio.to_thread(self._write, log_path, event["data"]["data"]) + except Exception as e: + logger.error(f"Failed to write log: {e}") + + @staticmethod + def _write(path, data: str) -> None: + with open(path, "a") as f: + f.write(data) + + +log_writer = LogWriter() diff --git a/src/cli_agent_orchestrator/services/session_service.py b/src/cli_agent_orchestrator/services/session_service.py index 0dab4b9b9..adaf3af66 100644 --- a/src/cli_agent_orchestrator/services/session_service.py +++ b/src/cli_agent_orchestrator/services/session_service.py @@ -22,13 +22,9 @@ import logging from typing import Dict, List -from cli_agent_orchestrator.clients.database import ( - delete_terminals_by_session, - list_terminals_by_session, -) +from cli_agent_orchestrator.clients.database import list_terminals_by_session from cli_agent_orchestrator.clients.tmux import tmux_client from cli_agent_orchestrator.constants import SESSION_PREFIX -from cli_agent_orchestrator.providers.manager import provider_manager logger = logging.getLogger(__name__) @@ -69,18 +65,20 @@ def delete_session(session_name: str) -> bool: if not tmux_client.session_exists(session_name): raise ValueError(f"Session '{session_name}' not found") + from cli_agent_orchestrator.services import terminal_service + terminals = list_terminals_by_session(session_name) - # Cleanup providers + # Clean up each terminal (FIFO, state detector, provider, DB) for terminal in terminals: - provider_manager.cleanup_provider(terminal["id"]) + try: + terminal_service.delete_terminal(terminal["id"]) + except Exception as e: + logger.warning(f"Failed to cleanup terminal {terminal['id']}: {e}") # Kill tmux session tmux_client.kill_session(session_name) - # Delete terminal metadata - delete_terminals_by_session(session_name) - logger.info(f"Deleted session: {session_name}") return True diff --git a/src/cli_agent_orchestrator/services/status_monitor.py b/src/cli_agent_orchestrator/services/status_monitor.py new file mode 100644 index 000000000..79a5e1448 --- /dev/null +++ b/src/cli_agent_orchestrator/services/status_monitor.py @@ -0,0 +1,82 @@ +"""Monitors terminal status by accumulating output and detecting changes. + +Consumer: terminal.{id}.output +Publisher: terminal.{id}.status +""" + +import logging +from typing import Dict + +from cli_agent_orchestrator.constants import STATE_BUFFER_MAX +from cli_agent_orchestrator.models.terminal import TerminalStatus +from cli_agent_orchestrator.providers.manager import provider_manager +from cli_agent_orchestrator.services.event_bus import bus +from cli_agent_orchestrator.utils.event import terminal_id_from_topic + +logger = logging.getLogger(__name__) + + +class StatusMonitor: + """Accumulates terminal output into rolling buffers and detects status changes.""" + + def __init__(self): + self._buffers: Dict[str, str] = {} + self._last_status: Dict[str, TerminalStatus] = {} + + async def run(self) -> None: + """Subscribe to output events and detect status changes.""" + queue = bus.subscribe("terminal.*.output") + logger.info("StatusMonitor started") + + while True: + try: + event = await queue.get() + terminal_id = terminal_id_from_topic(event["topic"]) + self._process_chunk(terminal_id, event["data"]["data"]) + except Exception as e: + logger.exception(f"Error in StatusMonitor: {e}") + + def _process_chunk(self, terminal_id: str, chunk: str) -> None: + """Append chunk to rolling buffer and check for status changes.""" + if terminal_id not in self._buffers: + self._buffers[terminal_id] = "" + self._buffers[terminal_id] += chunk + + if len(self._buffers[terminal_id]) > STATE_BUFFER_MAX: + self._buffers[terminal_id] = self._buffers[terminal_id][-STATE_BUFFER_MAX:] + + new_status = self._detect_status(terminal_id, self._buffers[terminal_id]) + + if new_status != self._last_status.get(terminal_id): + bus.publish(f"terminal.{terminal_id}.status", {"status": new_status.value}) + logger.info(f"Terminal {terminal_id} status changed: {new_status.value}") + self._last_status[terminal_id] = new_status + + def _detect_status(self, terminal_id: str, buffer: str) -> TerminalStatus: + """Detect status: provider-specific patterns or UNKNOWN if no provider.""" + provider = provider_manager.get_provider(terminal_id) + if provider is None: + return TerminalStatus.UNKNOWN + + try: + return provider.get_status(buffer) + except Exception as e: + logger.error(f"Error detecting status for {terminal_id}: {e}") + return TerminalStatus.UNKNOWN + + def clear_terminal(self, terminal_id: str) -> None: + """Free buffer and status for a deleted terminal.""" + self._buffers.pop(terminal_id, None) + self._last_status.pop(terminal_id, None) + + def get_status(self, terminal_id: str) -> TerminalStatus: + """Get current terminal status. Source of truth — derived from streaming output.""" + return self._last_status.get(terminal_id, TerminalStatus.UNKNOWN) + + def get_buffer(self, terminal_id: str) -> str: + """Get accumulated output buffer for a terminal.""" + return self._buffers.get(terminal_id, "") + + +# Module-level singleton +status_monitor = StatusMonitor() diff --git a/src/cli_agent_orchestrator/services/terminal_service.py b/src/cli_agent_orchestrator/services/terminal_service.py index b7aabc3cb..212fae478 100644 --- a/src/cli_agent_orchestrator/services/terminal_service.py +++ b/src/cli_agent_orchestrator/services/terminal_service.py @@ -30,10 +30,12 @@ update_last_active, ) from cli_agent_orchestrator.clients.tmux import tmux_client -from cli_agent_orchestrator.constants import SESSION_PREFIX, TERMINAL_LOG_DIR +from cli_agent_orchestrator.constants import FIFO_DIR, SESSION_PREFIX from cli_agent_orchestrator.models.provider import ProviderType from cli_agent_orchestrator.models.terminal import Terminal, TerminalStatus from cli_agent_orchestrator.providers.manager import provider_manager +from cli_agent_orchestrator.services.fifo_reader import fifo_manager +from cli_agent_orchestrator.services.status_monitor import status_monitor from cli_agent_orchestrator.utils.terminal import ( generate_session_name, generate_terminal_id, @@ -54,7 +56,7 @@ class OutputMode(str, Enum): LAST = "last" -def create_terminal( +async def create_terminal( provider: str, agent_profile: str, session_name: Optional[str] = None, @@ -116,18 +118,21 @@ def create_terminal( # Step 3: Persist terminal metadata to database db_create_terminal(terminal_id, session_name, window_name, provider, agent_profile) - # Step 4: Create and initialize the CLI provider + # Step 4: Set up FIFO reader for event-driven output streaming + # Must happen BEFORE provider.initialize() so reader is ready when pipe-pane starts + fifo_manager.create_reader(terminal_id) + + # Step 5: Configure tmux pipe-pane to stream output to FIFO + # This enables real-time event-driven processing via StatusMonitor and LogWriter + fifo_path = FIFO_DIR / f"{terminal_id}.fifo" + tmux_client.pipe_pane(session_name, window_name, str(fifo_path)) + + # Step 6: Create and initialize the CLI provider # This starts the agent (e.g., runs "kiro-cli chat --agent developer") provider_instance = provider_manager.create_provider( provider, terminal_id, session_name, window_name, agent_profile ) - provider_instance.initialize() - - # Step 5: Set up terminal logging via tmux pipe-pane - # This captures all terminal output to a log file for inbox monitoring - log_path = TERMINAL_LOG_DIR / f"{terminal_id}.log" - log_path.touch() # Ensure file exists before watching - tmux_client.pipe_pane(session_name, window_name, str(log_path)) + await provider_instance.initialize() # Build and return the Terminal object terminal = Terminal( @@ -146,8 +151,16 @@ def create_terminal( return terminal except Exception as e: - # Cleanup on failure: clean up provider resources and kill session + # Cleanup on failure: clean up FIFO reader, status monitor, provider, and session logger.error(f"Failed to create terminal: {e}") + try: + fifo_manager.stop_reader(terminal_id) + except Exception: + pass # Ignore cleanup errors + try: + status_monitor.clear_terminal(terminal_id) + except Exception: + pass # Ignore cleanup errors try: provider_manager.cleanup_provider(terminal_id) except Exception: @@ -167,11 +180,7 @@ def get_terminal(terminal_id: str) -> Dict: if not metadata: raise ValueError(f"Terminal '{terminal_id}' not found") - # Get status from provider - provider = provider_manager.get_provider(terminal_id) - if provider is None: - raise ValueError(f"Provider not found for terminal {terminal_id}") - status = provider.get_status().value + status = status_monitor.get_status(terminal_id).value return { "id": metadata["id"], @@ -298,7 +307,11 @@ def get_output(terminal_id: str, mode: OutputMode = OutputMode.FULL) -> str: if not metadata: raise ValueError(f"Terminal '{terminal_id}' not found") - full_output = tmux_client.get_history(metadata["tmux_session"], metadata["tmux_window"]) + # Get output from StatusMonitor buffer (instant, no tmux call) + full_output = status_monitor.get_buffer(terminal_id) + if not full_output: + # Fallback to tmux only if buffer not available (edge case) + full_output = tmux_client.get_history(metadata["tmux_session"], metadata["tmux_window"]) if mode == OutputMode.FULL: return full_output @@ -346,6 +359,18 @@ def delete_terminal(terminal_id: str) -> bool: except Exception as e: logger.warning(f"Failed to stop pipe-pane for {terminal_id}: {e}") + # Stop FIFO reader and cleanup FIFO file + try: + fifo_manager.stop_reader(terminal_id) + except Exception as e: + logger.warning(f"Failed to stop FIFO reader for {terminal_id}: {e}") + + # Clear state detector buffers for this terminal + try: + status_monitor.clear_terminal(terminal_id) + except Exception as e: + logger.warning(f"Failed to clear state detector for {terminal_id}: {e}") + # Existing cleanup provider_manager.cleanup_provider(terminal_id) deleted = db_delete_terminal(terminal_id) diff --git a/src/cli_agent_orchestrator/utils/event.py b/src/cli_agent_orchestrator/utils/event.py new file mode 100644 index 000000000..f2c25cc79 --- /dev/null +++ b/src/cli_agent_orchestrator/utils/event.py @@ -0,0 +1,6 @@ +"""Event bus utilities.""" + + +def terminal_id_from_topic(topic: str) -> str: + """Extract terminal ID from event topic (e.g., 'terminal.abc123.output' → 'abc123').""" + return topic.split(".")[1] diff --git a/src/cli_agent_orchestrator/utils/terminal.py b/src/cli_agent_orchestrator/utils/terminal.py index 5c3373d4e..5d28bfae4 100644 --- a/src/cli_agent_orchestrator/utils/terminal.py +++ b/src/cli_agent_orchestrator/utils/terminal.py @@ -1,26 +1,22 @@ """Session utilities for CLI Agent Orchestrator.""" +import asyncio import logging import time import uuid -from typing import TYPE_CHECKING, Union +from typing import Union -import httpx +import requests from cli_agent_orchestrator.constants import API_BASE_URL, SESSION_PREFIX from cli_agent_orchestrator.models.terminal import TerminalStatus -if TYPE_CHECKING: - from cli_agent_orchestrator.clients.tmux import TmuxClient - from cli_agent_orchestrator.providers.base import BaseProvider - logger = logging.getLogger(__name__) def generate_session_name() -> str: """Generate a unique session name with SESSION_PREFIX.""" - session_uuid = uuid.uuid4().hex[:8] - return f"{SESSION_PREFIX}{session_uuid}" + return f"{SESSION_PREFIX}{uuid.uuid4().hex[:8]}" def generate_terminal_id() -> str: @@ -33,50 +29,71 @@ def generate_window_name(agent_profile: str) -> str: return f"{agent_profile}-{uuid.uuid4().hex[:4]}" -def wait_for_shell( - tmux_client: "TmuxClient", - session_name: str, - window_name: str, +async def wait_for_shell( + terminal_id: str, timeout: float = 10.0, - polling_interval: float = 0.5, + stable_duration: float = 2.0, + polling_interval: float = 0.3, ) -> bool: - """Wait for shell to be ready by checking if output is stable (2 consecutive reads are the same and non-empty).""" - logger.info(f"Waiting for shell to be ready in {session_name}:{window_name}...") - start_time = time.time() - previous_output = None + """Wait for shell to be ready by checking if the output buffer is stable and non-empty. - while time.time() - start_time < timeout: - output = tmux_client.get_history(session_name, window_name) + Reads the StatusMonitor's in-memory buffer (populated by the FIFO reader + → event bus → StatusMonitor pipeline). Returns True when the buffer is + non-empty and has not changed for *stable_duration* seconds. + + This does NOT use provider-specific status detection because the provider + is already registered before initialize() runs, and provider patterns + don't match raw shell output. + """ + from cli_agent_orchestrator.services.status_monitor import status_monitor + + logger.info(f"Waiting for shell to be ready for terminal {terminal_id}...") + + deadline = time.time() + timeout + previous_buffer = "" + last_change = time.time() + + while time.time() < deadline: + buf = status_monitor.get_buffer(terminal_id) + + if buf != previous_buffer: + previous_buffer = buf + last_change = time.time() - if output and output.strip() and previous_output is not None and output == previous_output: - logger.info(f"Shell ready") + stable_elapsed = time.time() - last_change + + if buf.strip() and stable_elapsed >= stable_duration: + logger.info(f"Shell ready for {terminal_id} (buffer stable, {len(buf)} bytes)") return True - previous_output = output - time.sleep(polling_interval) + await asyncio.sleep(polling_interval) - logger.warning(f"Timeout waiting for shell to be ready") + logger.warning(f"Timeout waiting for shell to be ready for {terminal_id}") return False -def wait_until_status( - provider_instance: "BaseProvider", +async def wait_until_status( + terminal_id: str, target_status: "TerminalStatus | set[TerminalStatus]", timeout: float = 30.0, polling_interval: float = 1.0, ) -> bool: - """Wait until provider reaches target status or timeout.""" - targets = target_status if isinstance(target_status, set) else {target_status} - start_time = time.time() + """Wait until terminal reaches target status by polling status_monitor.""" + from cli_agent_orchestrator.services.status_monitor import status_monitor - while time.time() - start_time < timeout: - status = provider_instance.get_status() - target_str = ", ".join(s.value for s in targets) - logger.info(f"Waiting for {{{target_str}}}, current status: {status}") - if status in targets: + targets = target_status if isinstance(target_status, set) else {target_status} + target_str = ", ".join(s.value for s in targets) + logger.info( + f"wait_until_status [{terminal_id}]: waiting for {{{target_str}}}, timeout={timeout}s" + ) + start = time.time() + while time.time() - start < timeout: + current = status_monitor.get_status(terminal_id) + if current in targets: + logger.info(f"wait_until_status [{terminal_id}]: reached {current.value}") return True - time.sleep(polling_interval) - + await asyncio.sleep(polling_interval) + logger.warning(f"wait_until_status [{terminal_id}]: timeout waiting for {{{target_str}}}") return False @@ -86,10 +103,10 @@ def wait_until_terminal_status( timeout: float = 30.0, polling_interval: float = 1.0, ) -> bool: - """Wait until terminal reaches target status using API endpoint. + """Wait until terminal reaches target status by polling GET /terminals/{id}. Args: - terminal_id: Terminal to poll. + terminal_id: Terminal to poll status for. target_status: A single TerminalStatus or a set of acceptable statuses. timeout: Maximum wait time in seconds. polling_interval: Seconds between polls. @@ -105,11 +122,10 @@ def wait_until_terminal_status( start_time = time.time() while time.time() - start_time < timeout: try: - response = httpx.get(f"{API_BASE_URL}/terminals/{terminal_id}", timeout=10.0) - logger.info(response) + response = requests.get(f"{API_BASE_URL}/terminals/{terminal_id}", timeout=5.0) if response.status_code == 200: - terminal_data = response.json() - if terminal_data["status"] in target_values: + current_status = response.json().get("status") + if current_status in target_values: return True except Exception: pass diff --git a/src/cli_agent_orchestrator/utils/text.py b/src/cli_agent_orchestrator/utils/text.py new file mode 100644 index 000000000..33f0409c4 --- /dev/null +++ b/src/cli_agent_orchestrator/utils/text.py @@ -0,0 +1,55 @@ +"""Text utilities for cleaning raw terminal output.""" + +import re + +# Cursor-to-column-1 sequences that semantically start a new logical line. +# Must be replaced with \n BEFORE the general CSI strip, otherwise the text +# that follows gets glued to the previous content and ^ anchors fail. +# - \x1b[1G / \x1b[G — CHA (Cursor Horizontal Absolute) to column 1 +# - \x1b[nA — CUU (Cursor Up), used with CHA for spinner redraws +# - \x1b[E / \x1b[nE — CNL (Cursor Next Line) +_LINE_START_CSI = re.compile(r"\x1b\[(?:1?G|\d*A|\d*E)") + +# CSI (Control Sequence Introducer) — covers SGR, cursor, erase, scroll, etc. +# Per ECMA-48: ESC [ +_CSI_PATTERN = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -/]*[@-~]") + +# OSC (Operating System Command) — terminal title, hyperlinks, etc. +# ESC ] ... (BEL | ST) +_OSC_PATTERN = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + +# Non-printable control characters (except \t and \n which are meaningful) +# Includes C1 control range (\x80-\x9f) minus \x9B which is handled as CSI above +_CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f\x80-\x9a\x9c-\x9f]") + + +def strip_terminal_escapes(text: str) -> str: + """Strip terminal escape sequences and control characters from text. + + Handles CSI sequences (colors, cursor movement, erase), OSC sequences + (terminal title), non-printable control characters, and normalizes + carriage returns to newlines so regex anchors (^/$) work correctly. + + WARNING: This function does NOT render carriage returns — it normalizes + bare \\r to \\n. In a real terminal, \\r moves the cursor to column 0 so the + next write overwrites the current line (used by spinners, progress bars). + Here, each \\r becomes a new line, so spinner frames like "⠋ Thinking..." + will appear as separate lines rather than collapsing into one. This is + acceptable for status detection (pattern matching still works) but NOT + suitable for extracting user-visible output. + + Used for status detection on raw FIFO buffer output. + For message extraction, use tmux capture-pane which renders the terminal. + """ + # Replace cursor-to-column-1 sequences with \n BEFORE stripping other CSI. + # These sequences mean "start writing from column 1" (e.g. spinner redraws, + # prompt redraws) — semantically a new logical line for pattern matching. + text = _LINE_START_CSI.sub("\n", text) + text = _CSI_PATTERN.sub("", text) + text = _OSC_PATTERN.sub("", text) + text = _CONTROL_CHARS_PATTERN.sub("", text) + # Normalize \r\n and bare \r to \n so ^ anchors work after carriage returns. + # FIFO output uses \r for in-place redraws (spinners, prompts) — for status + # detection, each redraw is a new logical line of output. + text = text.replace("\r\n", "\n").replace("\r", "\n") + return text diff --git a/test/api/test_terminals.py b/test/api/test_terminals.py index 704cb0d5d..8c34054b5 100644 --- a/test/api/test_terminals.py +++ b/test/api/test_terminals.py @@ -1,6 +1,6 @@ """Tests for terminal-related API endpoints including working directory and exit.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient @@ -71,12 +71,14 @@ class TestSessionCreationWithWorkingDirectory: def test_create_session_passes_working_directory(self, client, tmp_path): """Test that working_directory parameter is passed to service.""" with patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc: - mock_svc.create_terminal.return_value = Terminal( - id="abcd1234", - name="test-window", - session_name="test-session", - provider="q_cli", - agent_profile="developer", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd1234", + name="test-window", + session_name="test-session", + provider="q_cli", + agent_profile="developer", + ) ) response = client.post( @@ -96,12 +98,14 @@ def test_create_session_passes_working_directory(self, client, tmp_path): def test_create_session_with_working_directory(self, client): """Test POST /sessions with working_directory parameter.""" with patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc: - mock_svc.create_terminal.return_value = Terminal( - id="abcd1234", - name="test-window", - session_name="test-session", - provider="q_cli", - agent_profile="developer", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd1234", + name="test-window", + session_name="test-session", + provider="q_cli", + agent_profile="developer", + ) ) response = client.post( @@ -130,12 +134,14 @@ def test_create_terminal_passes_working_directory(self, client, tmp_path): ), patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc, ): - mock_svc.create_terminal.return_value = Terminal( - id="abcd5678", - name="test-window", - session_name="test-session", - provider="q_cli", - agent_profile="analyst", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd5678", + name="test-window", + session_name="test-session", + provider="q_cli", + agent_profile="analyst", + ) ) response = client.post( @@ -160,12 +166,14 @@ def test_create_terminal_in_session_with_working_directory(self, client): ), patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc, ): - mock_svc.create_terminal.return_value = Terminal( - id="abcd5678", - name="test-window", - session_name="test-session", - provider="q_cli", - agent_profile="analyst", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd5678", + name="test-window", + session_name="test-session", + provider="q_cli", + agent_profile="analyst", + ) ) response = client.post( @@ -280,12 +288,14 @@ def test_create_terminal_uses_profile_provider(self, client): patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc, ): mock_resolve.return_value = "claude_code" - mock_svc.create_terminal.return_value = Terminal( - id="abcd1234", - name="test-window", - session_name="test-session", - provider="claude_code", - agent_profile="developer", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd1234", + name="test-window", + session_name="test-session", + provider="claude_code", + agent_profile="developer", + ) ) response = client.post( @@ -311,12 +321,14 @@ def test_create_terminal_falls_back_when_no_profile_provider(self, client): ): # resolve_provider returns the fallback (no profile provider key) mock_resolve.return_value = "kiro_cli" - mock_svc.create_terminal.return_value = Terminal( - id="abcd5678", - name="test-window", - session_name="test-session", - provider="kiro_cli", - agent_profile="reviewer", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd5678", + name="test-window", + session_name="test-session", + provider="kiro_cli", + agent_profile="reviewer", + ) ) response = client.post( @@ -337,12 +349,14 @@ def test_create_session_does_not_resolve_provider(self, client): patch("cli_agent_orchestrator.api.main.resolve_provider") as mock_resolve, patch("cli_agent_orchestrator.api.main.terminal_service") as mock_svc, ): - mock_svc.create_terminal.return_value = Terminal( - id="abcd1234", - name="test-window", - session_name="test-session", - provider="kiro_cli", - agent_profile="supervisor", + mock_svc.create_terminal = AsyncMock( + return_value=Terminal( + id="abcd1234", + name="test-window", + session_name="test-session", + provider="kiro_cli", + agent_profile="supervisor", + ) ) response = client.post( diff --git a/test/providers/conftest.py b/test/providers/conftest.py new file mode 100644 index 000000000..98006af48 --- /dev/null +++ b/test/providers/conftest.py @@ -0,0 +1,98 @@ +"""Shared fixtures for provider integration tests. + +Bootstraps the event-driven pipeline (EventBus → StatusMonitor) and mocks +the database layer so integration tests can use the real create_terminal() +flow without needing a running DB. +""" + +import asyncio +from datetime import datetime +from unittest.mock import patch + +import pytest +import pytest_asyncio + +from cli_agent_orchestrator.clients.tmux import tmux_client +from cli_agent_orchestrator.providers.manager import provider_manager +from cli_agent_orchestrator.services.event_bus import bus +from cli_agent_orchestrator.services.fifo_reader import fifo_manager +from cli_agent_orchestrator.services.status_monitor import status_monitor + + +@pytest_asyncio.fixture +async def event_pipeline(): + """Bootstrap EventBus + StatusMonitor for the current test's event loop. + + This enables the full pipeline: + tmux pipe-pane → FIFO → FifoReader thread → EventBus → StatusMonitor + so that provider.initialize() (which polls status_monitor) works correctly. + """ + loop = asyncio.get_running_loop() + + # Clear stale subscriptions from previous tests (each test gets a new loop) + with bus._lock: + bus._exact.clear() + bus._wildcard.clear() + bus.set_loop(loop) + + # Start StatusMonitor as a background task + monitor_task = asyncio.create_task(status_monitor.run()) + + yield + + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass + bus.set_loop(None) + + +@pytest.fixture +def mock_db(): + """Mock database functions with an in-memory dict. + + Patches the DB calls used by terminal_service so integration tests + can run create_terminal() / delete_terminal() without a real database. + The mock stores terminal metadata in a dict and serves it back from + get_terminal_metadata(), mimicking real DB behavior. + """ + terminals = {} + + def _create(terminal_id, session_name, window_name, provider, agent_profile): + terminals[terminal_id] = { + "id": terminal_id, + "tmux_session": session_name, + "tmux_window": window_name, + "provider": provider, + "agent_profile": agent_profile, + "last_active": datetime.now(), + } + return terminals[terminal_id] + + def _get(terminal_id): + return terminals.get(terminal_id) + + with ( + patch( + "cli_agent_orchestrator.services.terminal_service.db_create_terminal", + side_effect=_create, + ), + patch( + "cli_agent_orchestrator.services.terminal_service.get_terminal_metadata", + side_effect=_get, + ), + patch( + "cli_agent_orchestrator.services.terminal_service.db_delete_terminal", + return_value=True, + ), + patch( + "cli_agent_orchestrator.services.terminal_service.update_last_active", + ), + # Also patch get_terminal_metadata in provider_manager (on-demand lookup) + patch( + "cli_agent_orchestrator.providers.manager.get_terminal_metadata", + side_effect=_get, + ), + ): + yield terminals diff --git a/test/providers/test_base_provider.py b/test/providers/test_base_provider.py index 1c2360354..115c46de2 100644 --- a/test/providers/test_base_provider.py +++ b/test/providers/test_base_provider.py @@ -1,7 +1,5 @@ """Tests for base provider.""" -from typing import Optional - import pytest from cli_agent_orchestrator.models.terminal import TerminalStatus @@ -11,14 +9,13 @@ class ConcreteProvider(BaseProvider): """Concrete implementation of BaseProvider for testing.""" - def initialize(self) -> bool: + async def initialize(self) -> bool: return True - def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus: - return self._status - - def get_idle_pattern_for_log(self) -> str: - return r"\[test\]>" + def get_status(self, buffer: str) -> TerminalStatus: + if not buffer: + return TerminalStatus.UNKNOWN + return TerminalStatus.IDLE def extract_last_message_from_script(self, script_output: str) -> str: return "extracted message" @@ -40,38 +37,12 @@ def test_init(self): assert provider.terminal_id == "term-123" assert provider.session_name == "session-1" assert provider.window_name == "window-0" - assert provider._status == TerminalStatus.IDLE - - def test_status_property(self): - """Test status property getter.""" - provider = ConcreteProvider("term-123", "session-1", "window-0") - - assert provider.status == TerminalStatus.IDLE - - def test_update_status(self): - """Test _update_status method.""" - provider = ConcreteProvider("term-123", "session-1", "window-0") - - provider._update_status(TerminalStatus.PROCESSING) - - assert provider._status == TerminalStatus.PROCESSING - assert provider.status == TerminalStatus.PROCESSING - - def test_update_status_all_values(self): - """Test _update_status with all status values.""" - provider = ConcreteProvider("term-123", "session-1", "window-0") - - for status in TerminalStatus: - provider._update_status(status) - assert provider.status == status def test_abstract_methods_implemented(self): """Test that concrete implementation works.""" provider = ConcreteProvider("term-123", "session-1", "window-0") - assert provider.initialize() is True - assert provider.get_status() == TerminalStatus.IDLE - assert provider.get_idle_pattern_for_log() == r"\[test\]>" + assert provider.get_status("some output") == TerminalStatus.IDLE assert provider.extract_last_message_from_script("test") == "extracted message" assert provider.exit_cli() == "/exit" provider.cleanup() # Should not raise diff --git a/test/providers/test_claude_code_unit.py b/test/providers/test_claude_code_unit.py index f8d65071d..2ccdf9705 100644 --- a/test/providers/test_claude_code_unit.py +++ b/test/providers/test_claude_code_unit.py @@ -1,7 +1,7 @@ """Unit tests for Claude Code provider.""" import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,10 +12,11 @@ class TestClaudeCodeProviderInitialization: """Tests for ClaudeCodeProvider initialization.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test successful initialization.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True @@ -23,7 +24,7 @@ def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - result = provider.initialize() + result = await provider.initialize() assert result is True assert provider._initialized is True @@ -31,21 +32,23 @@ def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): mock_tmux.send_keys.assert_called_once() mock_wait_status.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): """Test initialization with shell timeout.""" mock_wait_shell.return_value = False provider = ClaudeCodeProvider("test123", "test-session", "window-0") with pytest.raises(TimeoutError, match="Shell initialization timed out"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test initialization timeout.""" mock_wait_shell.return_value = True mock_wait_status.return_value = False @@ -54,13 +57,14 @@ def test_initialize_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): provider = ClaudeCodeProvider("test123", "test-session", "window-0") with pytest.raises(TimeoutError, match="Claude Code initialization timed out"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_with_agent_profile( + async def test_initialize_with_agent_profile( self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load ): """Test initialization with agent profile.""" @@ -73,15 +77,18 @@ def test_initialize_with_agent_profile( mock_load.return_value = mock_profile provider = ClaudeCodeProvider("test123", "test-session", "window-0", "test-agent") - result = provider.initialize() + result = await provider.initialize() assert result is True mock_load.assert_called_once_with("test-agent") + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_with_invalid_agent_profile(self, mock_tmux, mock_load, mock_wait_shell): + async def test_initialize_with_invalid_agent_profile( + self, mock_tmux, mock_load, mock_wait_shell + ): """Test initialization with invalid agent profile.""" mock_wait_shell.return_value = True mock_load.side_effect = FileNotFoundError("Profile not found") @@ -89,13 +96,14 @@ def test_initialize_with_invalid_agent_profile(self, mock_tmux, mock_load, mock_ provider = ClaudeCodeProvider("test123", "test-session", "window-0", "invalid-agent") with pytest.raises(ProviderError, match="Failed to load agent profile"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_with_mcp_servers( + async def test_initialize_with_mcp_servers( self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load ): """Test initialization with MCP servers in profile.""" @@ -108,21 +116,24 @@ def test_initialize_with_mcp_servers( mock_load.return_value = mock_profile provider = ClaudeCodeProvider("test123", "test-session", "window-0", "test-agent") - result = provider.initialize() + result = await provider.initialize() assert result is True + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_sends_claude_command(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_sends_claude_command( + self, mock_tmux, mock_wait_status, mock_wait_shell + ): """Test that initialize sends the 'claude' command to tmux.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - provider.initialize() + await provider.initialize() mock_tmux.send_keys.assert_called_once_with( "test-session", "window-0", "claude --dangerously-skip-permissions" @@ -132,84 +143,76 @@ def test_initialize_sends_claude_command(self, mock_tmux, mock_wait_status, mock class TestClaudeCodeProviderStatusDetection: """Tests for ClaudeCodeProvider status detection.""" - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_idle_old_prompt(self, mock_tmux): + def test_get_status_idle_old_prompt(self): """Test IDLE status detection with old '>' prompt.""" - mock_tmux.get_history.return_value = "> " + output = "> " provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_idle_new_prompt(self, mock_tmux): + def test_get_status_idle_new_prompt(self): """Test IDLE status detection with new '❯' prompt.""" - mock_tmux.get_history.return_value = "❯ " + output = "❯ " provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_idle_with_ansi_codes(self, mock_tmux): + def test_get_status_idle_with_ansi_codes(self): """Test IDLE status detection with ANSI codes around prompt.""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[2m\x1b[38;2;136;136;136m────────────\n" '\x1b[0m❯ \x1b[7mT\x1b[0;2mry\x1b[0m \x1b[2m"hello"\x1b[0m\n' "\x1b[2m\x1b[38;2;136;136;136m────────────\x1b[0m" ) provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_completed(self, mock_tmux): + def test_get_status_completed(self): """Test COMPLETED status detection.""" - mock_tmux.get_history.return_value = "⏺ Here is the response\n> " + output = "⏺ Here is the response\n> " provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_completed_with_new_prompt(self, mock_tmux): + def test_get_status_completed_with_new_prompt(self): """Test COMPLETED status detection with new '❯' prompt.""" - mock_tmux.get_history.return_value = "⏺ Here is the response\n❯ " + output = "⏺ Here is the response\n❯ " provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_processing(self, mock_tmux): + def test_get_status_processing(self): """Test PROCESSING status detection.""" - mock_tmux.get_history.return_value = "✶ Processing… (esc to interrupt)" + output = "✶ Processing… (esc to interrupt)" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_processing_minimal_spinner(self, mock_tmux): + def test_get_status_processing_minimal_spinner(self): """Test PROCESSING detection with minimal spinner format (no parenthesized text).""" - mock_tmux.get_history.return_value = "✻ Orbiting…" + output = "✻ Orbiting…" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_processing_beats_stale_completed(self, mock_tmux): + def test_get_status_processing_beats_stale_completed(self): """Test that PROCESSING is detected even when stale ⏺ and ❯ markers are in scrollback.""" - mock_tmux.get_history.return_value = ( + output = ( "⏺ Previous response from init\n" "❯ user task message\n" "⏺ Let me read the file\n" @@ -217,14 +220,13 @@ def test_get_status_processing_beats_stale_completed(self, mock_tmux): ) provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_idle_not_false_processing_from_status_bar(self, mock_tmux): + def test_get_status_idle_not_false_processing_from_status_bar(self): """Status bar '· latest:…' must not false-positive as PROCESSING.""" - mock_tmux.get_history.return_value = ( + output = ( "Claude Code v2.1.63\n" "────────────────────\n" "❯ \n" @@ -232,47 +234,34 @@ def test_get_status_idle_not_false_processing_from_status_bar(self, mock_tmux): " current: 2.1.63 · latest:…" ) provider = ClaudeCodeProvider("test123", "test-session", "window-0") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_waiting_user_answer(self, mock_tmux): + def test_get_status_waiting_user_answer(self): """Test WAITING_USER_ANSWER status detection.""" - mock_tmux.get_history.return_value = "❯ 1. Option one\n 2. Option two" + output = "❯ 1. Option one\n 2. Option two" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_error_empty(self, mock_tmux): - """Test ERROR status with empty output.""" - mock_tmux.get_history.return_value = "" - - provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() - - assert status == TerminalStatus.ERROR - - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_error_unrecognized(self, mock_tmux): - """Test ERROR status with unrecognized output.""" - mock_tmux.get_history.return_value = "Some random output without patterns" + def test_get_status_error_empty(self): + """Test UNKNOWN status with empty output.""" + output = "" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) - assert status == TerminalStatus.ERROR + assert status == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - """Test status detection with tail_lines parameter.""" - mock_tmux.get_history.return_value = "> " + def test_get_status_error_unrecognized(self): + """Test UNKNOWN status with unrecognized output.""" + output = "Some random output without patterns" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - provider.get_status(tail_lines=50) + status = provider.get_status(output) - mock_tmux.get_history.assert_called_with("test-session", "window-0", tail_lines=50) + assert status == TerminalStatus.UNKNOWN class TestClaudeCodeProviderMessageExtraction: @@ -340,15 +329,6 @@ def test_exit_cli(self): provider = ClaudeCodeProvider("test123", "test-session", "window-0") assert provider.exit_cli() == "/exit" - def test_get_idle_pattern_for_log(self): - """Test idle pattern for log files.""" - provider = ClaudeCodeProvider("test123", "test-session", "window-0") - pattern = provider.get_idle_pattern_for_log() - - assert pattern is not None - assert ">" in pattern - assert "❯" in pattern - def test_cleanup(self): """Test cleanup resets initialized state.""" provider = ClaudeCodeProvider("test123", "test-session", "window-0") @@ -460,8 +440,9 @@ def test_build_command_mcp_does_not_override_existing_terminal_id(self, mock_loa class TestClaudeCodeProviderTrustPrompt: """Tests for Claude Code workspace trust prompt handling.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): + async def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): """Test that trust prompt is detected and auto-accepted.""" # Simulate trust prompt appearing in terminal output mock_tmux.get_history.return_value = ( @@ -475,40 +456,40 @@ def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): mock_window.active_pane = mock_pane provider = ClaudeCodeProvider("test123", "test-session", "window-0") - provider._handle_trust_prompt(timeout=2.0) + await provider._handle_trust_prompt(timeout=2.0) # Verify Enter was sent to accept the trust prompt mock_pane.send_keys.assert_called_once_with("", enter=True) + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_handle_trust_prompt_not_needed(self, mock_tmux): + async def test_handle_trust_prompt_not_needed(self, mock_tmux): """Test early return when Claude Code starts without trust prompt.""" mock_tmux.get_history.return_value = "Welcome to Claude Code v2.1.0" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - provider._handle_trust_prompt(timeout=2.0) + await provider._handle_trust_prompt(timeout=2.0) # No session/pane access should happen mock_tmux.server.sessions.get.assert_not_called() - @patch("cli_agent_orchestrator.providers.claude_code.time") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.claude_code.asyncio.sleep", new_callable=AsyncMock) @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_handle_trust_prompt_timeout(self, mock_tmux, mock_time): + async def test_handle_trust_prompt_timeout(self, mock_tmux, mock_async_sleep): """Test trust prompt handler times out gracefully.""" # Return output that doesn't match trust prompt or welcome banner mock_tmux.get_history.return_value = "Loading..." - # Simulate time passing past the timeout - mock_time.time.side_effect = [0.0, 0.0, 25.0] - mock_time.sleep = MagicMock() provider = ClaudeCodeProvider("test123", "test-session", "window-0") # Should not raise, just log a warning and return - provider._handle_trust_prompt(timeout=20.0) + await provider._handle_trust_prompt(timeout=0.1) mock_tmux.server.sessions.get.assert_not_called() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_handle_trust_prompt_empty_output_then_detected(self, mock_tmux): + async def test_handle_trust_prompt_empty_output_then_detected(self, mock_tmux): """Test trust prompt detection after initially empty output.""" # First call returns empty, second returns trust prompt mock_tmux.get_history.side_effect = [ @@ -523,28 +504,26 @@ def test_handle_trust_prompt_empty_output_then_detected(self, mock_tmux): mock_window.active_pane = mock_pane provider = ClaudeCodeProvider("test123", "test-session", "window-0") - provider._handle_trust_prompt(timeout=5.0) + await provider._handle_trust_prompt(timeout=5.0) mock_pane.send_keys.assert_called_once_with("", enter=True) - @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_get_status_trust_prompt_not_waiting_user_answer(self, mock_tmux): + def test_get_status_trust_prompt_not_waiting_user_answer(self): """Test that trust prompt is NOT detected as WAITING_USER_ANSWER.""" # This output has both WAITING_USER_ANSWER pattern AND trust prompt pattern - mock_tmux.get_history.return_value = ( - "❯ 1. Yes, I trust this folder\n" " 2. No, don't trust this folder" - ) + output = "❯ 1. Yes, I trust this folder\n" " 2. No, don't trust this folder" provider = ClaudeCodeProvider("test123", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) # Should NOT be WAITING_USER_ANSWER since trust prompt is excluded assert status != TerminalStatus.WAITING_USER_ANSWER + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") @patch("cli_agent_orchestrator.providers.claude_code.wait_until_status") @patch("cli_agent_orchestrator.providers.claude_code.tmux_client") - def test_initialize_calls_handle_trust_prompt( + async def test_initialize_calls_handle_trust_prompt( self, mock_tmux, mock_wait_status, mock_wait_shell ): """Test that initialize calls _handle_trust_prompt.""" @@ -560,7 +539,7 @@ def test_initialize_calls_handle_trust_prompt( mock_window.active_pane = mock_pane provider = ClaudeCodeProvider("test123", "test-session", "window-0") - result = provider.initialize() + result = await provider.initialize() assert result is True # Verify trust prompt was auto-accepted (Enter sent) diff --git a/test/providers/test_codex_provider_unit.py b/test/providers/test_codex_provider_unit.py index 28cbc53dc..dd74dfc15 100644 --- a/test/providers/test_codex_provider_unit.py +++ b/test/providers/test_codex_provider_unit.py @@ -1,7 +1,7 @@ """Unit tests for Codex provider.""" from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -17,16 +17,17 @@ def load_fixture(filename: str) -> str: class TestCodexProviderInitialization: + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.wait_until_status") @patch("cli_agent_orchestrator.providers.codex.wait_for_shell") @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): mock_wait_shell.return_value = True mock_wait_status.return_value = True mock_tmux.get_history.return_value = "OpenAI Codex (v0.98.0)" provider = CodexProvider("test1234", "test-session", "window-0", None) - result = provider.initialize() + result = await provider.initialize() assert result is True mock_wait_shell.assert_called_once() @@ -40,20 +41,22 @@ def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): ) mock_wait_status.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.wait_for_shell") @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): mock_wait_shell.return_value = False provider = CodexProvider("test1234", "test-session", "window-0", None) with pytest.raises(TimeoutError, match="Shell initialization timed out"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.wait_until_status") @patch("cli_agent_orchestrator.providers.codex.wait_for_shell") @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_initialize_codex_timeout(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_codex_timeout(self, mock_tmux, mock_wait_shell, mock_wait_status): mock_wait_shell.return_value = True mock_wait_status.return_value = False mock_tmux.get_history.return_value = "OpenAI Codex (v0.98.0)" @@ -61,7 +64,7 @@ def test_initialize_codex_timeout(self, mock_tmux, mock_wait_shell, mock_wait_st provider = CodexProvider("test1234", "test-session", "window-0", None) with pytest.raises(TimeoutError, match="Codex initialization timed out"): - provider.initialize() + await provider.initialize() class TestCodexBuildCommand: @@ -217,11 +220,12 @@ def test_build_command_profile_load_failure(self, mock_load_profile): with pytest.raises(ProviderError, match="Failed to load agent profile"): provider._build_codex_command() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.wait_until_status") @patch("cli_agent_orchestrator.providers.codex.wait_for_shell") @patch("cli_agent_orchestrator.providers.codex.load_agent_profile") @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_initialize_with_agent_profile( + async def test_initialize_with_agent_profile( self, mock_tmux, mock_load_profile, mock_wait_shell, mock_wait_status ): mock_wait_shell.return_value = True @@ -233,7 +237,7 @@ def test_initialize_with_agent_profile( mock_load_profile.return_value = mock_profile provider = CodexProvider("test1234", "test-session", "window-0", "code_supervisor") - result = provider.initialize() + result = await provider.initialize() assert result is True # The second send_keys call should contain developer_instructions @@ -243,76 +247,57 @@ def test_initialize_with_agent_profile( class TestCodexProviderStatusDetection: - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_idle(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_idle_output.txt") + def test_get_status_idle(self): + output = load_fixture("codex_idle_output.txt") provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_completed_output.txt") + def test_get_status_completed(self): + output = load_fixture("codex_completed_output.txt") provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_processing_output.txt") + def test_get_status_processing(self): + output = load_fixture("codex_processing_output.txt") provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_waiting_user_answer(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_permission_output.txt") + def test_get_status_waiting_user_answer(self): + output = load_fixture("codex_permission_output.txt") provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_error(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_error_output.txt") + def test_get_status_error(self): + output = load_fixture("codex_error_output.txt") provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_empty_output(self, mock_tmux): - mock_tmux.get_history.return_value = "" - + def test_get_status_empty_output(self): provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() - - assert status == TerminalStatus.ERROR + status = provider.get_status("") - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - mock_tmux.get_history.return_value = load_fixture("codex_idle_output.txt") + assert status == TerminalStatus.UNKNOWN - provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status(tail_lines=50) - - assert status == TerminalStatus.IDLE - mock_tmux.get_history.assert_called_once_with("test-session", "window-0", tail_lines=50) - - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_when_old_prompt_present(self, mock_tmux): + def test_get_status_processing_when_old_prompt_present(self): # If the captured history contains an earlier prompt but the *latest* output is processing, # we should report PROCESSING. The old prompt should be far enough from the bottom # (more than IDLE_PROMPT_TAIL_LINES) to avoid false idle detection. - mock_tmux.get_history.return_value = ( + output = ( "Welcome to Codex\n" "❯ \n" "You Fix the failing tests\n" @@ -324,15 +309,14 @@ def test_get_status_processing_when_old_prompt_present(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_not_error_on_failed_in_message(self, mock_tmux): + def test_get_status_not_error_on_failed_in_message(self): # "failed" is commonly used in normal assistant output; it should not automatically # force ERROR. - mock_tmux.get_history.return_value = ( + output = ( "You Explain why the test failed\n" "assistant: The test failed because the assertion is incorrect.\n" "\n" @@ -340,35 +324,32 @@ def test_get_status_not_error_on_failed_in_message(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_idle_if_no_assistant_after_last_user(self, mock_tmux): + def test_get_status_idle_if_no_assistant_after_last_user(self): # If there is a user message but no assistant response after it, we should not # treat the session as COMPLETED. - mock_tmux.get_history.return_value = "assistant: Welcome\n" "You Do the thing\n" "\n" "❯ \n" + output = "assistant: Welcome\n" "You Do the thing\n" "\n" "❯ \n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_when_no_prompt_and_no_keywords(self, mock_tmux): + def test_get_status_processing_when_no_prompt_and_no_keywords(self): # Codex output may not always include explicit "thinking/processing" keywords. # Without an idle prompt at the end, we should assume it's still processing. - mock_tmux.get_history.return_value = "You Run the command\nWorking...\n" + output = "You Run the command\nWorking...\n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_not_error_when_assistant_mentions_error_text(self, mock_tmux): - mock_tmux.get_history.return_value = ( + def test_get_status_not_error_when_assistant_mentions_error_text(self): + output = ( "You Explain the failure\n" "assistant: Here's an example error:\n" "Error: example only\n" @@ -377,13 +358,12 @@ def test_get_status_not_error_when_assistant_mentions_error_text(self, mock_tmux ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_not_waiting_when_assistant_mentions_approval_text(self, mock_tmux): - mock_tmux.get_history.return_value = ( + def test_get_status_not_waiting_when_assistant_mentions_approval_text(self): + output = ( "You Explain approvals\n" "assistant: You might see this prompt:\n" "Approve this command? [y/n]\n" @@ -392,41 +372,37 @@ def test_get_status_not_waiting_when_assistant_mentions_approval_text(self, mock ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_error_when_error_after_user_and_prompt(self, mock_tmux): - mock_tmux.get_history.return_value = "You Run thing\nError: failed\n\n❯ \n" + def test_get_status_error_when_error_after_user_and_prompt(self): + output = "You Run thing\nError: failed\n\n❯ \n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_waiting_user_answer_when_no_user_prefix(self, mock_tmux): - mock_tmux.get_history.return_value = "Approve this command? [y/n]\n" + def test_get_status_waiting_user_answer_when_no_user_prefix(self): + output = "Approve this command? [y/n]\n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_error_when_no_user_prefix(self, mock_tmux): - mock_tmux.get_history.return_value = "Error: something failed\n" + def test_get_status_error_when_no_user_prefix(self): + output = "Error: something failed\n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_idle_tui_with_status_bar(self, mock_tmux): + def test_get_status_idle_tui_with_status_bar(self): """Test IDLE detection with realistic TUI output (status bar after prompt).""" - mock_tmux.get_history.return_value = ( + output = ( "╭───────────────────────────────────────────╮\n" "│ >_ OpenAI Codex (v0.98.0) │\n" "│ model: gpt-5.3-codex high │\n" @@ -438,14 +414,13 @@ def test_get_status_idle_tui_with_status_bar(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_tui_with_status_bar(self, mock_tmux): + def test_get_status_completed_tui_with_status_bar(self): """Test COMPLETED detection with TUI output (status bar after prompt).""" - mock_tmux.get_history.return_value = ( + output = ( "You Fix the bug\n" "assistant: I've fixed the issue in main.py.\n" "\n" @@ -454,7 +429,7 @@ def test_get_status_completed_tui_with_status_bar(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED @@ -462,10 +437,9 @@ def test_get_status_completed_tui_with_status_bar(self, mock_tmux): class TestCodexBulletFormatStatusDetection: """Tests for Codex's real interactive output format using › prompt and • bullets.""" - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_bullet_format(self, mock_tmux): + def test_get_status_completed_bullet_format(self): """COMPLETED when › user message followed by • response and idle prompt.""" - mock_tmux.get_history.return_value = ( + output = ( "› what is your role?\n" "• I am the Coding Supervisor Agent.\n" "• I coordinate tasks between developer and reviewer agents.\n" @@ -474,14 +448,13 @@ def test_get_status_completed_bullet_format(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_bullet_format(self, mock_tmux): + def test_get_status_processing_bullet_format(self): """PROCESSING when • response started but no idle prompt at bottom.""" - mock_tmux.get_history.return_value = ( + output = ( "› fix the failing tests\n" "• Let me look at the test files.\n" "Reading src/test_main.py...\n" @@ -492,24 +465,22 @@ def test_get_status_processing_bullet_format(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_idle_bullet_format_no_response(self, mock_tmux): + def test_get_status_idle_bullet_format_no_response(self): """IDLE when › user message but no • response yet and idle prompt at bottom.""" - mock_tmux.get_history.return_value = "› hello\n\n› \n" + output = "› hello\n\n› \n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_bullet_with_code_block(self, mock_tmux): + def test_get_status_completed_bullet_with_code_block(self): """COMPLETED with • response containing code blocks.""" - mock_tmux.get_history.return_value = ( + output = ( "› show me a function\n" "• Here's the function:\n" "\n" @@ -524,24 +495,22 @@ def test_get_status_completed_bullet_with_code_block(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_error_not_masked_by_bullet_pattern(self, mock_tmux): + def test_get_status_error_not_masked_by_bullet_pattern(self): """ERROR still detected when no • response and error after › user message.""" - mock_tmux.get_history.return_value = "› do something\nError: connection refused\n" + output = "› do something\nError: connection refused\n" provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_multi_turn_bullet(self, mock_tmux): + def test_get_status_completed_multi_turn_bullet(self): """COMPLETED uses last user message in multi-turn bullet format.""" - mock_tmux.get_history.return_value = ( + output = ( "› first question\n" "• First answer.\n" "\n" @@ -552,14 +521,13 @@ def test_get_status_completed_multi_turn_bullet(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_bullet_with_tui_status_bar(self, mock_tmux): + def test_get_status_completed_bullet_with_tui_status_bar(self): """COMPLETED with bullet format and TUI status bar after prompt.""" - mock_tmux.get_history.return_value = ( + output = ( "› fix the bug\n" "• I've fixed the issue in main.py by correcting the import.\n" "\n" @@ -568,14 +536,13 @@ def test_get_status_completed_bullet_with_tui_status_bar(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_tui_spinner(self, mock_tmux): + def test_get_status_processing_tui_spinner(self): """PROCESSING when TUI shows • Working spinner, not false COMPLETED.""" - mock_tmux.get_history.return_value = ( + output = ( "› [CAO Handoff] Supervisor terminal ID: sup-123. Do the task.\n" "\n" "• Working (0s • esc to interrupt)\n" @@ -586,14 +553,13 @@ def test_get_status_processing_tui_spinner(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_tui_thinking_spinner(self, mock_tmux): + def test_get_status_processing_tui_thinking_spinner(self): """PROCESSING when TUI shows • Thinking spinner.""" - mock_tmux.get_history.return_value = ( + output = ( "› Implement feature X\n" "\n" "• Thinking (3s • esc to interrupt)\n" @@ -604,14 +570,13 @@ def test_get_status_processing_tui_thinking_spinner(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_dynamic_spinner_text(self, mock_tmux): + def test_get_status_processing_dynamic_spinner_text(self): """PROCESSING when TUI shows spinner with dynamic prefix text.""" - mock_tmux.get_history.return_value = ( + output = ( "› [CAO Handoff] Do the task.\n" "\n" "• Creating /tmp/file.py\n" @@ -624,7 +589,7 @@ def test_get_status_processing_dynamic_spinner_text(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING @@ -638,10 +603,9 @@ class TestCodexV0111FooterFormat: The new format uses "N% left" instead of "N% context left" and removes "? for shortcuts". """ - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_idle_v0111_footer(self, mock_tmux): + def test_get_status_idle_v0111_footer(self): """IDLE with v0.111.0 footer format (no '? for shortcuts').""" - mock_tmux.get_history.return_value = ( + output = ( "╭───────────────────────────────────────────╮\n" "│ >_ OpenAI Codex (v0.111.0) │\n" "│ model: gpt-5.3-codex high │\n" @@ -655,14 +619,11 @@ def test_get_status_idle_v0111_footer(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + assert provider.get_status(output) == TerminalStatus.IDLE - assert status == TerminalStatus.IDLE - - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_v0111_footer(self, mock_tmux): + def test_get_status_completed_v0111_footer(self): """COMPLETED with v0.111.0 footer (suggestion hint must not be treated as user input).""" - mock_tmux.get_history.return_value = ( + output = ( "› fix the bug\n" "• I've fixed the issue in main.py by correcting the import.\n" "\n" @@ -672,14 +633,11 @@ def test_get_status_completed_v0111_footer(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() - - assert status == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_completed_v0111_multi_turn(self, mock_tmux): + def test_get_status_completed_v0111_multi_turn(self): """COMPLETED in multi-turn with v0.111.0 footer.""" - mock_tmux.get_history.return_value = ( + output = ( "› first question\n" "• First answer.\n" "\n" @@ -692,14 +650,11 @@ def test_get_status_completed_v0111_multi_turn(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() - - assert status == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_processing_v0111_spinner(self, mock_tmux): + def test_get_status_processing_v0111_spinner(self): """PROCESSING when TUI shows spinner with v0.111.0 footer.""" - mock_tmux.get_history.return_value = ( + output = ( "› [CAO Handoff] Do the task.\n" "\n" "• Working (0s • esc to interrupt)\n" @@ -710,9 +665,7 @@ def test_get_status_processing_v0111_spinner(self, mock_tmux): ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() - - assert status == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING class TestCodexProviderMessageExtraction: @@ -897,16 +850,6 @@ def test_extract_double_blank_between_hint_and_status(self): class TestCodexProviderMisc: - def test_get_idle_pattern_for_log(self): - provider = CodexProvider("test1234", "test-session", "window-0") - pattern = provider.get_idle_pattern_for_log() - # Codex TUI renders ❯ via cursor positioning (capture-pane only). - # The pipe-pane log contains "? for shortcuts" from the TUI footer. - assert pattern == r"\? for shortcuts" - import re - - assert re.search(pattern, "? for shortcuts") - def test_exit_cli(self): provider = CodexProvider("test1234", "test-session", "window-0") assert provider.exit_cli() == "/exit" @@ -927,8 +870,9 @@ def test_extract_last_message_without_trailing_prompt(self): class TestCodexProviderTrustPrompt: """Tests for Codex workspace trust prompt handling.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): + async def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): """Test that trust prompt is detected and auto-accepted.""" mock_tmux.get_history.return_value = ( "> You are running Codex in /Users/test/project\n" @@ -947,39 +891,40 @@ def test_handle_trust_prompt_detected_and_accepted(self, mock_tmux): mock_window.active_pane = mock_pane provider = CodexProvider("test1234", "test-session", "window-0") - provider._handle_trust_prompt(timeout=2.0) + await provider._handle_trust_prompt(timeout=2.0) mock_pane.send_keys.assert_called_once_with("", enter=True) + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_handle_trust_prompt_not_needed(self, mock_tmux): + async def test_handle_trust_prompt_not_needed(self, mock_tmux): """Test early return when Codex starts without trust prompt.""" mock_tmux.get_history.return_value = "OpenAI Codex (v0.98.0)\n› " provider = CodexProvider("test1234", "test-session", "window-0") - provider._handle_trust_prompt(timeout=2.0) + await provider._handle_trust_prompt(timeout=2.0) mock_tmux.server.sessions.get.assert_not_called() - @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_get_status_trust_prompt_is_waiting_user_answer(self, mock_tmux): + def test_get_status_trust_prompt_is_waiting_user_answer(self): """Test that trust prompt reports WAITING_USER_ANSWER, not PROCESSING.""" - mock_tmux.get_history.return_value = ( + output = ( "> You are running Codex in /Users/test/project\n" "allow Codex to work in this folder without asking for approval.\n" "› 1. Yes\n" ) provider = CodexProvider("test1234", "test-session", "window-0") - status = provider.get_status() + status = provider.get_status(output) # Should be WAITING_USER_ANSWER (not PROCESSING despite "running" in text) assert status == TerminalStatus.WAITING_USER_ANSWER + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.codex.wait_until_status") @patch("cli_agent_orchestrator.providers.codex.wait_for_shell") @patch("cli_agent_orchestrator.providers.codex.tmux_client") - def test_initialize_with_trust_prompt(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_with_trust_prompt(self, mock_tmux, mock_wait_shell, mock_wait_status): """Test that initialize handles trust prompt during startup.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True @@ -994,7 +939,7 @@ def test_initialize_with_trust_prompt(self, mock_tmux, mock_wait_shell, mock_wai mock_window.active_pane = mock_pane provider = CodexProvider("test1234", "test-session", "window-0") - result = provider.initialize() + result = await provider.initialize() assert result is True mock_pane.send_keys.assert_called_with("", enter=True) diff --git a/test/providers/test_gemini_cli_unit.py b/test/providers/test_gemini_cli_unit.py index 71a680cfa..89a94527b 100644 --- a/test/providers/test_gemini_cli_unit.py +++ b/test/providers/test_gemini_cli_unit.py @@ -1,9 +1,6 @@ -"""Tests for Gemini CLI provider. - -Covers initialization, status detection, message extraction, command building, -pattern matching, and cleanup — targeting >90% code coverage. -""" +"""Tests for Gemini CLI provider.""" +import asyncio import re from pathlib import Path from unittest.mock import MagicMock, patch @@ -15,7 +12,6 @@ ANSI_CODE_PATTERN, ERROR_PATTERN, IDLE_PROMPT_PATTERN, - IDLE_PROMPT_PATTERN_LOG, IDLE_PROMPT_TAIL_LINES, INPUT_BOX_BOTTOM_PATTERN, INPUT_BOX_TOP_PATTERN, @@ -48,20 +44,26 @@ def _read_fixture(name: str) -> str: class TestGeminiCliProviderInitialization: """Tests for GeminiCliProvider initialization flow.""" - @patch("cli_agent_orchestrator.providers.gemini_cli.time") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.gemini_cli.asyncio.sleep", return_value=None) @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=True) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_time): + async def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_async_sleep): """Test successful initialization sends warm-up + gemini command and reaches IDLE.""" - # Configure time mock: first call returns 0 (warm-up start), subsequent calls - # for the init loop need to return 0 then trigger the IDLE status check. - mock_time.time.side_effect = [0, 0, 0, 0, 0] - mock_time.sleep = MagicMock() - # Simulate warm-up marker appearing in shell output, then IDLE status - idle_output = " * Type your message or @path/to/file\n" - mock_tmux.get_history.side_effect = ["CAO_SHELL_READY", idle_output] + mock_tmux.get_history.return_value = "CAO_SHELL_READY" provider = GeminiCliProvider("term-1", "session-1", "window-1") - result = provider.initialize() + + mock_monitor = MagicMock() + mock_monitor.get_status.return_value = TerminalStatus.IDLE + with ( + patch( + "cli_agent_orchestrator.providers.gemini_cli.status_monitor", + mock_monitor, + create=True, + ), + patch("cli_agent_orchestrator.services.status_monitor.status_monitor", mock_monitor), + ): + result = await provider.initialize() assert result is True assert provider._initialized is True @@ -69,46 +71,51 @@ def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_time): mock_tmux.send_keys.assert_any_call("session-1", "window-1", "echo CAO_SHELL_READY") mock_wait_shell.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=False) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): """Test shell init timeout raises TimeoutError.""" provider = GeminiCliProvider("term-1", "session-1", "window-1") with pytest.raises(TimeoutError, match="Shell initialization"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.gemini_cli.asyncio.sleep", return_value=None) @patch("cli_agent_orchestrator.providers.gemini_cli.time") @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=True) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_initialize_gemini_timeout(self, mock_tmux, mock_wait_shell, mock_time): + async def test_initialize_gemini_timeout( + self, mock_tmux, mock_wait_shell, mock_time, mock_async_sleep + ): """Test Gemini CLI init timeout raises TimeoutError.""" - # Simulate time progressing past timeout (120s) call_count = [0] def advancing_time(): call_count[0] += 1 - return call_count[0] * 10.0 # each call advances 10s + return call_count[0] * 10.0 mock_time.time.side_effect = advancing_time - mock_time.sleep = MagicMock() - # Warm-up succeeds, but CLI never reaches IDLE (always returns PROCESSING) mock_tmux.get_history.return_value = "CAO_SHELL_READY" provider = GeminiCliProvider("term-1", "session-1", "window-1") - with pytest.raises(TimeoutError, match="Gemini CLI initialization timed out"): - provider.initialize() - @patch("cli_agent_orchestrator.providers.gemini_cli.time") + mock_monitor = MagicMock() + mock_monitor.get_status.return_value = TerminalStatus.UNKNOWN + mock_monitor.get_buffer.return_value = "" + with patch("cli_agent_orchestrator.services.status_monitor.status_monitor", mock_monitor): + with pytest.raises(TimeoutError, match="Gemini CLI initialization timed out"): + await provider.initialize() + + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.gemini_cli.asyncio.sleep", return_value=None) @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=True) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") @patch("cli_agent_orchestrator.providers.gemini_cli.load_agent_profile") - def test_initialize_with_mcp_servers( - self, mock_load, mock_tmux, mock_wait_shell, mock_time, tmp_path + async def test_initialize_with_mcp_servers( + self, mock_load, mock_tmux, mock_wait_shell, mock_async_sleep, tmp_path ): """Test initialization with MCP servers writes to settings.json.""" - mock_time.time.side_effect = [0, 0, 0, 0, 0] - mock_time.sleep = MagicMock() - idle_output = " * Type your message or @path/to/file\n" - mock_tmux.get_history.side_effect = ["CAO_SHELL_READY", idle_output] + mock_tmux.get_history.return_value = "CAO_SHELL_READY" mock_profile = MagicMock() mock_profile.system_prompt = None mock_profile.mcpServers = { @@ -119,46 +126,50 @@ def test_initialize_with_mcp_servers( } mock_load.return_value = mock_profile - # Use tmp_path as fake home so we don't touch real ~/.gemini/settings.json settings_dir = tmp_path / ".gemini" settings_dir.mkdir() settings_file = settings_dir / "settings.json" - with patch("cli_agent_orchestrator.providers.gemini_cli.Path.home", return_value=tmp_path): + mock_monitor = MagicMock() + mock_monitor.get_status.return_value = TerminalStatus.IDLE + with ( + patch("cli_agent_orchestrator.providers.gemini_cli.Path.home", return_value=tmp_path), + patch("cli_agent_orchestrator.services.status_monitor.status_monitor", mock_monitor), + ): provider = GeminiCliProvider( "term-1", "session-1", "window-1", agent_profile="developer" ) - result = provider.initialize() + result = await provider.initialize() assert result is True - # MCP server should be registered in settings.json, not via gemini mcp add import json settings = json.loads(settings_file.read_text()) assert "cao-mcp-server" in settings["mcpServers"] assert settings["mcpServers"]["cao-mcp-server"]["command"] == "npx" assert settings["mcpServers"]["cao-mcp-server"]["env"]["CAO_TERMINAL_ID"] == "term-1" - # Command should be plain gemini launch (no chained mcp add) call_args = mock_tmux.send_keys.call_args_list[1] command = call_args[0][2] assert command == "gemini --yolo --sandbox false" assert "cao-mcp-server" in provider._mcp_server_names - @patch("cli_agent_orchestrator.providers.gemini_cli.time") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.gemini_cli.asyncio.sleep", return_value=None) @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=True) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_initialize_sends_gemini_command(self, mock_tmux, mock_wait_shell, mock_time): + async def test_initialize_sends_gemini_command( + self, mock_tmux, mock_wait_shell, mock_async_sleep + ): """Test that initialize sends warm-up echo then the correct gemini --yolo command.""" - mock_time.time.side_effect = [0, 0, 0, 0, 0] - mock_time.sleep = MagicMock() - idle_output = " * Type your message or @path/to/file\n" - mock_tmux.get_history.side_effect = ["CAO_SHELL_READY", idle_output] + mock_tmux.get_history.return_value = "CAO_SHELL_READY" provider = GeminiCliProvider("term-1", "session-1", "window-1") - provider.initialize() - # First call: warm-up echo + mock_monitor = MagicMock() + mock_monitor.get_status.return_value = TerminalStatus.IDLE + with patch("cli_agent_orchestrator.services.status_monitor.status_monitor", mock_monitor): + await provider.initialize() + assert mock_tmux.send_keys.call_args_list[0][0][2] == "echo CAO_SHELL_READY" - # Second call: gemini command assert mock_tmux.send_keys.call_args_list[1][0][2] == "gemini --yolo --sandbox false" @patch("cli_agent_orchestrator.providers.gemini_cli.load_agent_profile") @@ -170,48 +181,34 @@ def test_initialize_with_invalid_profile(self, mock_load): with pytest.raises(ProviderError, match="Failed to load agent profile"): provider._build_gemini_command() - @patch("cli_agent_orchestrator.providers.gemini_cli.time") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.gemini_cli.asyncio.sleep", return_value=None) @patch("cli_agent_orchestrator.providers.gemini_cli.wait_for_shell", return_value=True) @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") @patch("cli_agent_orchestrator.providers.gemini_cli.load_agent_profile") - def test_initialize_with_prompt_interactive_waits_for_completed( - self, mock_load, mock_tmux, mock_wait_shell, mock_time + async def test_initialize_with_prompt_interactive_waits_for_completed( + self, mock_load, mock_tmux, mock_wait_shell, mock_async_sleep ): - """Test that -i flag makes initialize() wait for COMPLETED, not IDLE. - - When -i is used, Gemini processes the system prompt as the first user - message and produces a response. IDLE alone is premature because the - Ink TUI shows the idle prompt before -i processing finishes (lesson #18). - """ - mock_time.time.side_effect = [0, 0, 0, 0, 0, 0, 0] - mock_time.sleep = MagicMock() + """Test that -i flag makes initialize() wait for COMPLETED, not IDLE.""" mock_profile = MagicMock() mock_profile.system_prompt = "You are a supervisor." mock_profile.mcpServers = {} mock_load.return_value = mock_profile - - # First get_history: warm-up marker. Second: idle prompt (should NOT - # be accepted when -i is used). Third: completed state (response + idle). - idle_output = " * Type your message or @path/to/file\n" - completed_output = ( - "> You are a supervisor.\n" - "✦ I understand. I am a supervisor.\n" - " * Type your message or @path/to/file\n" - ) - mock_tmux.get_history.side_effect = [ - "CAO_SHELL_READY", - idle_output, # 1st status check: IDLE — skipped because -i requires COMPLETED - completed_output, # 2nd status check: COMPLETED — accepted - ] + mock_tmux.get_history.return_value = "CAO_SHELL_READY" mock_tmux.get_pane_working_directory.return_value = None - provider = GeminiCliProvider("term-1", "session-1", "window-1", agent_profile="supervisor") - result = provider.initialize() + # First status check returns IDLE (should be skipped for -i), then COMPLETED + mock_monitor = MagicMock() + mock_monitor.get_status.side_effect = [TerminalStatus.IDLE, TerminalStatus.COMPLETED] + with patch("cli_agent_orchestrator.services.status_monitor.status_monitor", mock_monitor): + provider = GeminiCliProvider( + "term-1", "session-1", "window-1", agent_profile="supervisor" + ) + result = await provider.initialize() assert result is True assert provider._uses_prompt_interactive is True assert provider._initialized is True - # After init, no external input received yet assert provider._received_input_after_init is False def test_uses_prompt_interactive_flag_default(self): @@ -259,57 +256,51 @@ def test_build_command_no_prompt_interactive_without_system_prompt(self, mock_tm class TestGeminiCliProviderStatusDetection: """Tests for GeminiCliProvider.get_status().""" - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_idle(self, mock_tmux): + def test_get_status_idle(self): """Test IDLE detection from fresh startup output.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_idle_output.txt") provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert ( + provider.get_status(_read_fixture("gemini_cli_idle_output.txt")) == TerminalStatus.IDLE + ) - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_completed(self, mock_tmux): + def test_get_status_completed(self): """Test COMPLETED detection when response is present with prompt.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_completed_output.txt") provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED + assert ( + provider.get_status(_read_fixture("gemini_cli_completed_output.txt")) + == TerminalStatus.COMPLETED + ) - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_completed_complex(self, mock_tmux): + def test_get_status_completed_complex(self): """Test COMPLETED detection with tool call response.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_complex_response.txt") provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED + assert ( + provider.get_status(_read_fixture("gemini_cli_complex_response.txt")) + == TerminalStatus.COMPLETED + ) - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_processing(self, mock_tmux): + def test_get_status_processing(self): """Test PROCESSING detection when user query is in input box.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_processing_output.txt") provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING - - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_error_empty(self, mock_tmux): - """Test ERROR on empty output.""" - mock_tmux.get_history.return_value = "" - provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert ( + provider.get_status(_read_fixture("gemini_cli_processing_output.txt")) + == TerminalStatus.PROCESSING + ) - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_error_none(self, mock_tmux): - """Test ERROR on None output.""" - mock_tmux.get_history.return_value = None + def test_get_status_unknown_empty(self): + """Test UNKNOWN on empty output.""" provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert provider.get_status("") == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_error_pattern(self, mock_tmux): + def test_get_status_error_pattern(self): """Test ERROR detection from error output fixture.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_error_output.txt") provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert ( + provider.get_status(_read_fixture("gemini_cli_error_output.txt")) + == TerminalStatus.ERROR + ) - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_idle_with_ansi_codes(self, mock_tmux): + def test_get_status_idle_with_ansi_codes(self): """Test IDLE detection with ANSI escape codes in output.""" output = ( "\x1b[38;2;71;150;228m ███ GEMINI BANNER \x1b[0m\n" @@ -319,41 +310,24 @@ def test_get_status_idle_with_ansi_codes(self, mock_tmux): "\x1b[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" "\x1b[39m ~/dir (main) sandbox Auto\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - """Test status detection with tail_lines parameter passed through.""" - mock_tmux.get_history.return_value = _read_fixture("gemini_cli_idle_output.txt") - provider = GeminiCliProvider("term-1", "session-1", "window-1") - provider.get_status(tail_lines=20) - mock_tmux.get_history.assert_called_once_with("session-1", "window-1", tail_lines=20) - - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_idle_tall_terminal(self, mock_tmux): - """Test IDLE detection in tall terminals (46+ rows) where prompt is far from bottom. - - In a tall terminal, the welcome banner and input box may be far from the - bottom due to Ink's cursor-based rendering and empty padding lines. - IDLE_PROMPT_TAIL_LINES must be large enough to reach the prompt. - """ + def test_get_status_idle_tall_terminal(self): + """Test IDLE detection in tall terminals where prompt is far from bottom.""" output = ( " ███ GEMINI BANNER\n" "\n" "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " * Type your message or @path/to/file\n" "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" - + "\n" * 32 # 32 empty padding lines (typical for tall terminal) + + "\n" * 32 + " .../project (main*) sandbox Auto (Gemini 3) /model | 200 MB\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_processing_no_idle_prompt(self, mock_tmux): + def test_get_status_processing_no_idle_prompt(self): """Test PROCESSING when response is mid-stream (no idle prompt, no error).""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" @@ -363,18 +337,11 @@ def test_get_status_processing_no_idle_prompt(self, mock_tmux): "✦ Here's the function:\n" "\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_not_error_when_response_mentions_error(self, mock_tmux): - """Test COMPLETED (not ERROR) when response text discusses errors. - - The ✦ response may contain text like 'Error: you need to fix...' which - matches ERROR_PATTERN. Since the idle prompt is visible, the error check - is never reached — idle prompt detection takes priority. - """ + def test_get_status_not_error_when_response_mentions_error(self): + """Test COMPLETED (not ERROR) when response text discusses errors.""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " > how to fix this error\n" @@ -389,20 +356,11 @@ def test_get_status_not_error_when_response_mentions_error(self, mock_tmux): "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" " .../dir (main) no sandbox Auto (Gemini 3) /model | 100 MB\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_processing_spinner_with_idle_prompt(self, mock_tmux): - """Test PROCESSING when spinner is visible despite idle prompt being shown. - - Gemini's Ink TUI keeps the idle input box visible at the bottom at ALL - times, even during active processing (tool calls, model thinking). - The processing spinner (Braille dots + 'esc to cancel') appears above - the idle prompt. Without spinner detection, get_status() would return - COMPLETED prematurely (lesson #16). - """ + def test_get_status_processing_spinner_with_idle_prompt(self): + """Test PROCESSING when spinner is visible despite idle prompt being shown.""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " > Use the handoff tool to delegate this task\n" @@ -419,13 +377,11 @@ def test_get_status_processing_spinner_with_idle_prompt(self, mock_tmux): "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" " .../dir (main) no sandbox Auto (Gemini 3) /model | 234 MB\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_processing_spinner_retry(self, mock_tmux): - """Test PROCESSING when model is retrying API call (Attempt N/M spinner).""" + def test_get_status_processing_spinner_retry(self): + """Test PROCESSING when model is retrying API call.""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " > create a report\n" @@ -438,17 +394,11 @@ def test_get_status_processing_spinner_retry(self, mock_tmux): "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" " .../dir (main) no sandbox Auto (Gemini 3) /model | 100 MB\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING - - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_completed_no_spinner(self, mock_tmux): - """Test COMPLETED when response finished and no spinner is present. + assert provider.get_status(output) == TerminalStatus.PROCESSING - After the model finishes processing (no spinner), idle prompt visible, - and response with ✦ prefix visible → COMPLETED. - """ + def test_get_status_completed_no_spinner(self): + """Test COMPLETED when response finished and no spinner is present.""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " > Use the handoff tool to delegate this task\n" @@ -465,18 +415,11 @@ def test_get_status_completed_no_spinner(self, mock_tmux): "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" " .../dir (main) no sandbox Auto (Gemini 3) /model | 234 MB\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED - - @patch("cli_agent_orchestrator.providers.gemini_cli.tmux_client") - def test_get_status_processing_multi_turn_old_response(self, mock_tmux): - """Test PROCESSING on second query when old ✦ response is in scrollback. + assert provider.get_status(output) == TerminalStatus.COMPLETED - In a multi-turn conversation, the scrollback contains ✦ from the first - response. When the second query is processing (no idle prompt at bottom), - the status should be PROCESSING despite the old ✦ in scrollback. - """ + def test_get_status_processing_multi_turn_old_response(self): + """Test PROCESSING on second query when old response is in scrollback.""" output = ( "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" " > first question\n" @@ -489,9 +432,8 @@ def test_get_status_processing_multi_turn_old_response(self, mock_tmux): "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" " Responding with gemini-3-flash-preview\n" ) - mock_tmux.get_history.return_value = output provider = GeminiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING # ============================================================================= @@ -886,13 +828,6 @@ def test_exit_cli(self): provider = GeminiCliProvider("term-1", "session-1", "window-1") assert provider.exit_cli() == "C-d" - def test_get_idle_pattern_for_log(self): - """Test idle pattern for log monitoring matches idle prompt.""" - provider = GeminiCliProvider("term-1", "session-1", "window-1") - pattern = provider.get_idle_pattern_for_log() - assert pattern == IDLE_PROMPT_PATTERN_LOG - assert re.search(pattern, " * Type your message or @path/to/file") - def test_cleanup(self): """Test cleanup resets initialized state.""" provider = GeminiCliProvider("term-1", "session-1", "window-1") diff --git a/test/providers/test_kimi_cli_unit.py b/test/providers/test_kimi_cli_unit.py index 944eb951c..4d6c8b03e 100644 --- a/test/providers/test_kimi_cli_unit.py +++ b/test/providers/test_kimi_cli_unit.py @@ -17,7 +17,6 @@ ANSI_CODE_PATTERN, ERROR_PATTERN, IDLE_PROMPT_PATTERN, - IDLE_PROMPT_PATTERN_LOG, IDLE_PROMPT_TAIL_LINES, RESPONSE_BULLET_PATTERN, STATUS_BAR_PATTERN, @@ -45,13 +44,17 @@ def _read_fixture(name: str) -> str: class TestKimiCliProviderInitialization: """Tests for KimiCliProvider initialization flow.""" - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status", return_value=True) - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=True) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): """Test successful initialization sends kimi command and reaches IDLE.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True + provider = KimiCliProvider("term-1", "session-1", "window-1") - result = provider.initialize() + result = await provider.initialize() assert result is True assert provider._initialized is True @@ -59,38 +62,48 @@ def test_initialize_success(self, mock_tmux, mock_wait_shell, mock_wait_status): mock_wait_shell.assert_called_once() mock_wait_status.assert_called_once() - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=False) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): """Test shell init timeout raises TimeoutError.""" + mock_wait_shell.return_value = False + provider = KimiCliProvider("term-1", "session-1", "window-1") with pytest.raises(TimeoutError, match="Shell initialization"): - provider.initialize() + await provider.initialize() - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status", return_value=False) - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=True) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_initialize_kimi_timeout(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_kimi_timeout(self, mock_tmux, mock_wait_shell, mock_wait_status): """Test Kimi CLI init timeout raises TimeoutError.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = False + provider = KimiCliProvider("term-1", "session-1", "window-1") with pytest.raises(TimeoutError, match="Kimi CLI initialization"): - provider.initialize() + await provider.initialize() - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status", return_value=True) - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=True) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") @patch("cli_agent_orchestrator.providers.kimi_cli.load_agent_profile") - def test_initialize_with_agent_profile( + async def test_initialize_with_agent_profile( self, mock_load, mock_tmux, mock_wait_shell, mock_wait_status ): """Test initialization with agent profile creates temp files.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True mock_profile = MagicMock() mock_profile.system_prompt = "You are a helpful assistant" mock_profile.mcpServers = None mock_load.return_value = mock_profile provider = KimiCliProvider("term-1", "session-1", "window-1", agent_profile="developer") - result = provider.initialize() + result = await provider.initialize() assert result is True # Verify kimi command includes --agent-file @@ -111,14 +124,17 @@ def test_initialize_with_invalid_profile(self, mock_load): with pytest.raises(ProviderError, match="Failed to load agent profile"): provider._build_kimi_command() - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status", return_value=True) - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=True) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") @patch("cli_agent_orchestrator.providers.kimi_cli.load_agent_profile") - def test_initialize_with_mcp_servers( + async def test_initialize_with_mcp_servers( self, mock_load, mock_tmux, mock_wait_shell, mock_wait_status ): """Test initialization with MCP servers in profile adds --mcp-config and modifies config.toml.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True mock_profile = MagicMock() mock_profile.system_prompt = None mock_profile.mcpServers = { @@ -135,7 +151,7 @@ def test_initialize_with_mcp_servers( "cli_agent_orchestrator.providers.kimi_cli.Path.home", return_value=Path(tempfile.mkdtemp()), ): - result = provider.initialize() + result = await provider.initialize() assert result is True call_args = mock_tmux.send_keys.call_args @@ -144,13 +160,19 @@ def test_initialize_with_mcp_servers( # No --config flag in command (breaks OAuth authentication) assert "--config" not in command - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status", return_value=True) - @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell", return_value=True) + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kimi_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_initialize_sends_kimi_command(self, mock_tmux, mock_wait_shell, mock_wait_status): + async def test_initialize_sends_kimi_command( + self, mock_tmux, mock_wait_shell, mock_wait_status + ): """Test that initialize sends the kimi --yolo command with cd and TERM override.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True + provider = KimiCliProvider("term-1", "session-1", "window-1") - provider.initialize() + await provider.initialize() call_args = mock_tmux.send_keys.call_args command = call_args[0][2] @@ -168,15 +190,12 @@ def test_initialize_sends_kimi_command(self, mock_tmux, mock_wait_shell, mock_wa class TestKimiCliProviderStatusDetection: """Tests for KimiCliProvider.get_status().""" - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_idle(self, mock_tmux): + def test_get_status_idle(self): """Test IDLE detection from fresh startup output.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_idle_output.txt") provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(_read_fixture("kimi_cli_idle_output.txt")) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_idle_no_thinking(self, mock_tmux): + def test_get_status_idle_no_thinking(self): """Test IDLE detection with ✨ prompt (no-thinking mode).""" output = ( "Welcome to Kimi Code CLI!\n" @@ -184,54 +203,51 @@ def test_get_status_idle_no_thinking(self, mock_tmux): "\n\n" "23:14 yolo agent (kimi-for-coding) ctrl-x: toggle mode context: 0.0%" ) - mock_tmux.get_history.return_value = output provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_completed(self, mock_tmux): + def test_get_status_completed(self): """Test COMPLETED detection when response is present with prompt.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_completed_output.txt") provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED + assert ( + provider.get_status(_read_fixture("kimi_cli_completed_output.txt")) + == TerminalStatus.COMPLETED + ) - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_completed_complex(self, mock_tmux): + def test_get_status_completed_complex(self): """Test COMPLETED detection with multi-line code response.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_complex_response.txt") provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.COMPLETED + assert ( + provider.get_status(_read_fixture("kimi_cli_complex_response.txt")) + == TerminalStatus.COMPLETED + ) - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_processing(self, mock_tmux): + def test_get_status_processing(self): """Test PROCESSING detection when no prompt at bottom.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_processing_output.txt") provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING + assert ( + provider.get_status(_read_fixture("kimi_cli_processing_output.txt")) + == TerminalStatus.PROCESSING + ) - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_error_empty(self, mock_tmux): - """Test ERROR on empty output.""" - mock_tmux.get_history.return_value = "" + def test_get_status_unknown_empty(self): + """Test UNKNOWN on empty output.""" provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert provider.get_status("") == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_error_none(self, mock_tmux): - """Test ERROR on None output.""" - mock_tmux.get_history.return_value = None + def test_get_status_unknown_none(self): + """Test UNKNOWN on None output.""" provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert provider.get_status(None) == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_error_pattern(self, mock_tmux): + def test_get_status_error_pattern(self): """Test ERROR detection from error output fixture.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_error_output.txt") provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.ERROR + assert ( + provider.get_status(_read_fixture("kimi_cli_error_output.txt")) == TerminalStatus.ERROR + ) - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_idle_with_ansi_codes(self, mock_tmux): + def test_get_status_idle_with_ansi_codes(self): """Test IDLE detection with ANSI escape codes in output.""" # Simulate raw ANSI output: bold prompt with color codes output = ( @@ -240,20 +256,10 @@ def test_get_status_idle_with_ansi_codes(self, mock_tmux): "\n\n" "23:14 yolo agent (kimi-for-coding, thinking) ctrl-x: toggle mode context: 0.0%" ) - mock_tmux.get_history.return_value = output provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - """Test status detection with tail_lines parameter passed through.""" - mock_tmux.get_history.return_value = _read_fixture("kimi_cli_idle_output.txt") - provider = KimiCliProvider("term-1", "session-1", "window-1") - provider.get_status(tail_lines=20) - mock_tmux.get_history.assert_called_once_with("session-1", "window-1", tail_lines=20) - - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_idle_tall_terminal(self, mock_tmux): + def test_get_status_idle_tall_terminal(self): """Test IDLE detection in tall terminals (46+ rows) where prompt is far from bottom. In a 46-row terminal, the welcome banner takes ~12 lines, the prompt is at @@ -270,12 +276,10 @@ def test_get_status_idle_tall_terminal(self, mock_tmux): + "\n" * 32 # 32 empty padding lines (typical for 46-row terminal) + "00:05 yolo agent (kimi-for-coding, thinking) ctrl-x: toggle mode context: 0.0%\n" ) - mock_tmux.get_history.return_value = output provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_processing_streaming(self, mock_tmux): + def test_get_status_processing_streaming(self): """Test PROCESSING when response is mid-stream (no prompt, no error).""" output = ( "╭──────────────────╮\n" @@ -286,12 +290,10 @@ def test_get_status_processing_streaming(self, mock_tmux): "def foo():\n" " pass\n" ) - mock_tmux.get_history.return_value = output provider = KimiCliProvider("term-1", "session-1", "window-1") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_completed_long_response_no_bullets(self, mock_tmux): + def test_get_status_completed_long_response_no_bullets(self): """Test COMPLETED for long structured responses without • bullet markers. Kimi doesn't always use • bullets — report templates, tables, numbered lists @@ -309,8 +311,7 @@ def test_get_status_completed_long_response_no_bullets(self, mock_tmux): " ━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" " 1. Summary section...\n" ) - mock_tmux.get_history.return_value = processing_output - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(processing_output) == TerminalStatus.PROCESSING # Flag should now be latched assert provider._has_received_input is True @@ -324,11 +325,9 @@ def test_get_status_completed_long_response_no_bullets(self, mock_tmux): "\n\n" "19:12 yolo agent (kimi-for-coding, thinking) ctrl-x: toggle mode context: 2.9%" ) - mock_tmux.get_history.return_value = completed_output - assert provider.get_status() == TerminalStatus.COMPLETED + assert provider.get_status(completed_output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_latching_persists_after_scrollout(self, mock_tmux): + def test_get_status_latching_persists_after_scrollout(self): """Test that _has_received_input flag persists after user input box scrolls out.""" provider = KimiCliProvider("term-1", "session-1", "window-1") @@ -342,11 +341,9 @@ def test_get_status_latching_persists_after_scrollout(self, mock_tmux): "\n\n" "23:14 yolo agent (kimi-for-coding, thinking) ctrl-x: toggle mode context: 1.0%" ) - mock_tmux.get_history.return_value = output - assert provider.get_status() == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_idle_before_any_input(self, mock_tmux): + def test_get_status_idle_before_any_input(self): """Test IDLE when no user input has been received yet (fresh startup).""" provider = KimiCliProvider("term-1", "session-1", "window-1") assert provider._has_received_input is False @@ -357,12 +354,10 @@ def test_get_status_idle_before_any_input(self, mock_tmux): "\n\n" "23:14 yolo agent (kimi-for-coding, thinking) ctrl-x: toggle mode context: 0.0%" ) - mock_tmux.get_history.return_value = output - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE assert provider._has_received_input is False - @patch("cli_agent_orchestrator.providers.kimi_cli.tmux_client") - def test_get_status_processing_latches_flag(self, mock_tmux): + def test_get_status_processing_latches_flag(self): """Test that user input box detected during PROCESSING latches the flag.""" provider = KimiCliProvider("term-1", "session-1", "window-1") assert provider._has_received_input is False @@ -374,8 +369,7 @@ def test_get_status_processing_latches_flag(self, mock_tmux): "╰──────────────────╯\n" "Response content streaming...\n" ) - mock_tmux.get_history.return_value = output - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING assert provider._has_received_input is True @@ -881,15 +875,6 @@ def test_exit_cli(self): provider = KimiCliProvider("term-1", "session-1", "window-1") assert provider.exit_cli() == "/exit" - def test_get_idle_pattern_for_log(self): - """Test idle pattern for log monitoring matches both emoji markers.""" - provider = KimiCliProvider("term-1", "session-1", "window-1") - pattern = provider.get_idle_pattern_for_log() - assert pattern == IDLE_PROMPT_PATTERN_LOG - # Should match both emoji markers - assert re.search(pattern, "user@app✨") - assert re.search(pattern, "user@app💫") - def test_cleanup(self): """Test cleanup resets initialized state and latching flag.""" provider = KimiCliProvider("term-1", "session-1", "window-1") diff --git a/test/providers/test_kiro_cli_integration.py b/test/providers/test_kiro_cli_integration.py index 2e76348f5..8a42a8e10 100644 --- a/test/providers/test_kiro_cli_integration.py +++ b/test/providers/test_kiro_cli_integration.py @@ -1,6 +1,7 @@ """Integration tests for Kiro CLI provider with real kiro-cli. Tests permission prompt detection with real kiro-cli sessions. +Uses the real create_terminal() flow with FIFO pipeline and mocked DB. Usage: # Headless @@ -19,19 +20,23 @@ from pathlib import Path import pytest +import pytest_asyncio from cli_agent_orchestrator.clients.tmux import tmux_client from cli_agent_orchestrator.models.terminal import TerminalStatus -from cli_agent_orchestrator.providers.kiro_cli import KiroCliProvider -from cli_agent_orchestrator.utils.terminal import wait_for_shell +from cli_agent_orchestrator.providers.manager import provider_manager +from cli_agent_orchestrator.services.status_monitor import status_monitor +from cli_agent_orchestrator.services.terminal_service import ( + create_terminal, + delete_terminal, + send_input, +) pytestmark = [pytest.mark.integration, pytest.mark.slow] KIRO_AGENTS_DIR = Path.home() / ".kiro" / "agents" TEST_AGENT_NAME = "agent-kiro-cli-integration-test" WATCH_MODE = os.environ.get("CAO_TEST_WATCH", "") == "1" -WINDOW_NAME = "window-0" -TERMINAL_ID = "test1234" @pytest.fixture(scope="session") @@ -62,41 +67,34 @@ def ensure_test_agent(kiro_cli_available): return TEST_AGENT_NAME -@pytest.fixture -def test_session_name(): - import uuid - - return f"test-kiro-{uuid.uuid4().hex[:8]}" - - -@pytest.fixture -def cleanup_session(test_session_name): - yield +@pytest_asyncio.fixture +async def terminal(event_pipeline, mock_db, ensure_test_agent): + """Create a real terminal via create_terminal() with full FIFO pipeline.""" + t = await create_terminal( + provider="kiro_cli", + agent_profile=ensure_test_agent, + new_session=True, + ) + yield t try: - tmux_client.kill_session(test_session_name) + delete_terminal(t.id) + except Exception: + pass + try: + tmux_client.kill_session(t.session_name) except Exception: pass - - -@pytest.fixture -def provider(ensure_test_agent, test_session_name, cleanup_session): - """Create tmux session and provider, ready for use.""" - tmux_client.create_session(test_session_name, WINDOW_NAME, TERMINAL_ID) - return KiroCliProvider(TERMINAL_ID, test_session_name, WINDOW_NAME, ensure_test_agent) @pytest.fixture(autouse=True) -def dump_on_failure(request, test_session_name): +def dump_on_failure(request): """Dump terminal output when a test fails.""" yield if getattr(request.node, "rep_call", None) and request.node.rep_call.failed: try: - output = _clean(test_session_name) print(f"\n{'=' * 60}") print(f"TERMINAL DUMP for {request.node.name}") print(f"{'=' * 60}") - print(output[-1500:]) - print(f"{'=' * 60}") except Exception: pass @@ -110,7 +108,7 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True) -def watch_session(test_session_name, provider): +def watch_session(request, terminal): """Open Terminal.app attached to test tmux session. Opt-in: CAO_TEST_WATCH=1""" if not WATCH_MODE: yield @@ -119,7 +117,8 @@ def watch_session(test_session_name, provider): [ "osascript", "-e", - f'tell application "Terminal" to do script "tmux attach -t {test_session_name}"', + f'tell application "Terminal" to do script ' + f'"tmux attach -t {terminal.session_name}"', ], ) time.sleep(1) @@ -133,35 +132,39 @@ def watch_session(test_session_name, provider): PERM_RE = re.compile(r"Allow this action\?.*?\[.*?y.*?/.*?n.*?/.*?t.*?\]:", re.DOTALL) -def _clean(session): +def _get_output(terminal_id): + """Get terminal output from the status_monitor buffer.""" + return status_monitor.get_buffer(terminal_id) + + +def _clean(terminal_id): """Get terminal output with ANSI codes stripped.""" - raw = tmux_client.get_history(session, WINDOW_NAME) - return ANSI_RE.sub("", raw) + return ANSI_RE.sub("", _get_output(terminal_id)) -def _wait_for_permission(test_session_name, timeout=15): +def _wait_for_permission(terminal_id, timeout=15): elapsed = 0 while elapsed < timeout: - if PERM_RE.search(_clean(test_session_name)): + if PERM_RE.search(_clean(terminal_id)): return True time.sleep(1) elapsed += 1 return False -def _wait_for_status(provider, target, timeout=30): +def _wait_for_status(terminal_id, target, timeout=30): elapsed = 0 while elapsed < timeout: - s = provider.get_status() + s = status_monitor.get_status(terminal_id) if s == target: return s time.sleep(1) elapsed += 1 - return provider.get_status() + return status_monitor.get_status(terminal_id) -def _send(session, text): - tmux_client.send_keys(session, WINDOW_NAME, text) +def _send(terminal_id, text): + send_input(terminal_id, text) def _log(tag, msg): @@ -172,154 +175,92 @@ def _log(tag, msg): class TestKiroCliProviderIntegration: - """Basic integration tests with real kiro-cli. - - Also covers non-permission cases: - - N1/N2/N3 (idle states): test_real_kiro_initialization verifies IDLE after init - - N6 (completed response): test_real_kiro_simple_query verifies COMPLETED + message extraction - """ - - def test_real_kiro_initialization_and_idle(self, provider, test_session_name): - """Covers N1/N2/N3: IDLE status after initialization, with or without trailing text.""" - _log("INIT", "Initializing kiro-cli...") - assert provider.initialize() is True - time.sleep(2) - status = provider.get_status() + """Basic integration tests with real kiro-cli.""" + + @pytest.mark.asyncio + async def test_real_kiro_initialization_and_idle(self, terminal): + """Covers N1/N2/N3: IDLE status after initialization.""" + _log("INIT", f"Terminal {terminal.id} initialized in {terminal.session_name}") + status = status_monitor.get_status(terminal.id) _log("INIT", f"Status: {status}") - assert status == TerminalStatus.IDLE + assert status in {TerminalStatus.IDLE, TerminalStatus.COMPLETED} - def test_real_kiro_simple_query_and_completed(self, provider, test_session_name): - """Covers N6: COMPLETED status after response, message extraction, ANSI stripping.""" - _log("QUERY", "Initializing...") - provider.initialize() - time.sleep(2) + @pytest.mark.asyncio + async def test_real_kiro_simple_query_and_completed(self, terminal): + """Covers N6: COMPLETED status after response, message extraction.""" _log("QUERY", "Sending: Say 'Hello, integration test!'") - _send(test_session_name, "Say 'Hello, integration test!'") + _send(terminal.id, "Say 'Hello, integration test!'") _log("QUERY", "Waiting for COMPLETED...") - status = _wait_for_status(provider, TerminalStatus.COMPLETED) + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED) _log("QUERY", f"Status: {status}") assert status == TerminalStatus.COMPLETED - msg = provider.extract_last_message_from_script(_clean(test_session_name)) + + provider = provider_manager.get_provider(terminal.id) + output = _get_output(terminal.id) + msg = provider.extract_last_message_from_script(output) _log("QUERY", f"Extracted message length: {len(msg)}") assert len(msg) > 0 assert "\x1b[" not in msg class TestKiroCliPermissionPromptIntegration: - """Integration tests for permission prompt detection with real kiro-cli. + """Integration tests for permission prompt detection with real kiro-cli.""" - Case IDs reference the permission prompt analysis from 605 terminal logs - documented in ~/kb/cao/bugs/inbox_delivers_during_permission_prompt.md. - - P = permission prompt present, N = no permission prompt. - """ - - def test_p1_p2_active_permission_prompt(self, provider, test_session_name): - """P1/P2: Active permission prompt — must be WAITING_USER_ANSWER. - - Triggers execute_bash which requires permission. Verifies the - line-based counting detects the active prompt regardless of - trailing text on the idle prompt line below. - """ - _log("P1", "Initializing...") - provider.initialize() - time.sleep(2) + @pytest.mark.asyncio + async def test_p1_p2_active_permission_prompt(self, terminal): + """P1/P2: Active permission prompt — must be WAITING_USER_ANSWER.""" _log("P1", "Sending: Run this command: echo 'test'") - _send(test_session_name, "Run this command: echo 'test'") + _send(terminal.id, "Run this command: echo 'test'") _log("P1", "Waiting for permission prompt...") - if not _wait_for_permission(test_session_name, timeout=30): + if not _wait_for_permission(terminal.id, timeout=30): pytest.skip("Permission prompt not triggered (tool may be pre-approved)") - _log("P1", "Permission prompt found, checking status...") - status = provider.get_status() + status = status_monitor.get_status(terminal.id) _log("P1", f"Status: {status}") assert status == TerminalStatus.WAITING_USER_ANSWER - assert "Allow this action?" in _clean(test_session_name) - - def test_p3_p4_injection_during_active_prompt(self, provider, test_session_name): - """P3/P4: Invalid answer submitted during active prompt. - - Sends '[Test injection]' as answer to [y/n/t]: prompt. kiro-cli - rejects it (not y/n/t) and re-renders the prompt. Verifies status - remains WAITING_USER_ANSWER — the re-rendered prompt is still active. - - Note: send_keys includes Enter, so this submits the text rather than - typing without pressing Enter (P8 partial typing case would need - tmux send-keys without Enter, which the API doesn't support yet). - """ - _log("P3", "Initializing...") - provider.initialize() - time.sleep(2) + + @pytest.mark.asyncio + async def test_p3_p4_injection_during_active_prompt(self, terminal): + """P3/P4: Invalid answer submitted during active prompt.""" _log("P3", "Sending: Run: whoami") - _send(test_session_name, "Run: whoami") - _log("P3", "Waiting for permission prompt...") - if not _wait_for_permission(test_session_name): + _send(terminal.id, "Run: whoami") + if not _wait_for_permission(terminal.id): pytest.skip("Permission prompt not triggered") - _log("P3", "Permission prompt found, checking status...") - status = provider.get_status() + status = status_monitor.get_status(terminal.id) _log("P3", f"Status before injection: {status}") assert status == TerminalStatus.WAITING_USER_ANSWER - _log("P3", "Sending invalid answer: [Test injection]") - _send(test_session_name, "[Test injection]") + _send(terminal.id, "[Test injection]") time.sleep(1) - status = provider.get_status() + status = status_monitor.get_status(terminal.id) _log("P3", f"Status after injection: {status}") assert status == TerminalStatus.WAITING_USER_ANSWER - def test_p5_p6_stale_permission_after_answer(self, provider, test_session_name): - """P5/P6: Answered prompt — must NOT be WAITING_USER_ANSWER. - - Answers 'y' to permission prompt, waits for tool to complete. - Verifies the old [y/n/t]: in history is correctly identified as - stale (>1 idle prompt lines after it) and doesn't block status. - """ - _log("P5", "Initializing...") - provider.initialize() - time.sleep(2) - _log("P5", "Sending: Run: echo 'stale test'") - _send(test_session_name, "Run this bash command: echo 'stale test'") - _log("P5", "Waiting for permission prompt...") - if not _wait_for_permission(test_session_name): + @pytest.mark.asyncio + async def test_p5_p6_stale_permission_after_answer(self, terminal): + """P5/P6: Answered prompt — must NOT be WAITING_USER_ANSWER.""" + _send(terminal.id, "Run this bash command: echo 'stale test'") + if not _wait_for_permission(terminal.id): pytest.skip("Permission prompt not triggered") - _log("P5", "Answering 'y'...") - _send(test_session_name, "y") - _log("P5", "Waiting for COMPLETED...") - status = _wait_for_status(provider, TerminalStatus.COMPLETED) + _send(terminal.id, "y") + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED) _log("P5", f"Status after answer: {status}") assert status != TerminalStatus.WAITING_USER_ANSWER - assert PERM_RE.search(_clean(test_session_name)) - - def test_p7_multiple_permission_prompts(self, provider, test_session_name): - """P7: Second unanswered prompt after first answered. - - Answers first prompt, waits for completion, sends second command. - Counts permission prompts to detect a genuinely new one (not the - stale first). Verifies line-based counting uses the LAST prompt. - """ - _log("P7", "Initializing...") - provider.initialize() - time.sleep(2) - _log("P7", "Sending: Run: echo 'first'") - _send(test_session_name, "Run: echo 'first'") - _log("P7", "Waiting for first permission prompt...") - if not _wait_for_permission(test_session_name): + assert PERM_RE.search(_clean(terminal.id)) + + @pytest.mark.asyncio + async def test_p7_multiple_permission_prompts(self, terminal): + """P7: Second unanswered prompt after first answered.""" + _send(terminal.id, "Run: echo 'first'") + if not _wait_for_permission(terminal.id): pytest.skip("Permission prompt not triggered") - _log("P7", "Answering 'y'...") - _send(test_session_name, "y") - _log("P7", "Waiting for COMPLETED...") - status = _wait_for_status(provider, TerminalStatus.COMPLETED, timeout=30) - _log("P7", f"Status after first answer: {status}") - assert ( - status == TerminalStatus.COMPLETED - ), f"First command didn't complete (status={status}), can't test second prompt" - before_count = len(PERM_RE.findall(_clean(test_session_name))) - _log("P7", f"Permission prompts so far: {before_count}") - _log("P7", "Sending: Run: echo 'second'") - _send(test_session_name, "Run: echo 'second'") - _log("P7", "Waiting for NEW permission prompt...") + _send(terminal.id, "y") + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED, timeout=30) + assert status == TerminalStatus.COMPLETED, f"First command didn't complete ({status})" + before_count = len(PERM_RE.findall(_clean(terminal.id))) + _send(terminal.id, "Run: echo 'second'") elapsed = 0 found_new = False while elapsed < 20: - after_count = len(PERM_RE.findall(_clean(test_session_name))) + after_count = len(PERM_RE.findall(_clean(terminal.id))) if after_count > before_count: found_new = True break @@ -327,27 +268,18 @@ def test_p7_multiple_permission_prompts(self, provider, test_session_name): elapsed += 1 if not found_new: pytest.skip("Second permission prompt not triggered (tool may be session-approved)") - status = provider.get_status() - _log("P7", f"Status: {status}") + status = status_monitor.get_status(terminal.id) assert status == TerminalStatus.WAITING_USER_ANSWER - def test_n4_n5_processing_state(self, provider, test_session_name): - """N4/N5: No permission prompt during processing. - - Sends a query and polls until kiro-cli leaves IDLE. Verifies - status is PROCESSING or COMPLETED, never WAITING_USER_ANSWER. - """ - _log("N4", "Initializing...") - provider.initialize() - time.sleep(2) - _log("N4", "Sending: What is 2+2?") - _send(test_session_name, "What is 2+2?") - _log("N4", "Polling for non-IDLE status...") + @pytest.mark.asyncio + async def test_n4_n5_processing_state(self, terminal): + """N4/N5: No permission prompt during processing.""" + _send(terminal.id, "What is 2+2?") elapsed = 0 - status = provider.get_status() + status = status_monitor.get_status(terminal.id) while status == TerminalStatus.IDLE and elapsed < 10: time.sleep(0.5) elapsed += 0.5 - status = provider.get_status() + status = status_monitor.get_status(terminal.id) _log("N4", f"Status after {elapsed}s: {status}") assert status in [TerminalStatus.PROCESSING, TerminalStatus.COMPLETED] diff --git a/test/providers/test_kiro_cli_unit.py b/test/providers/test_kiro_cli_unit.py index d523e9e93..5b5312c81 100644 --- a/test/providers/test_kiro_cli_unit.py +++ b/test/providers/test_kiro_cli_unit.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import patch import pytest @@ -22,16 +22,17 @@ def load_fixture(filename: str) -> str: class TestKiroCliProviderInitialization: """Test Kiro CLI provider initialization.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test successful initialization.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - result = provider.initialize() + result = await provider.initialize() assert result is True mock_wait_shell.assert_called_once() @@ -40,21 +41,23 @@ def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): ) mock_wait_status.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): """Test initialization with shell timeout.""" mock_wait_shell.return_value = False provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") with pytest.raises(TimeoutError, match="Shell initialization timed out"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_kiro_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_kiro_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test initialization with Kiro CLI timeout.""" mock_wait_shell.return_value = True mock_wait_status.return_value = False @@ -62,7 +65,7 @@ def test_initialize_kiro_cli_timeout(self, mock_tmux, mock_wait_status, mock_wai provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") with pytest.raises(TimeoutError, match="Kiro CLI initialization timed out"): - provider.initialize() + await provider.initialize() def test_initialization_with_different_agent_profiles(self): """Test initialization with various agent profile names.""" @@ -78,131 +81,109 @@ def test_initialization_with_different_agent_profiles(self): class TestKiroCliProviderStatusDetection: """Test status detection logic.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_idle(self, mock_tmux): + def test_get_status_idle(self): """Test IDLE status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_idle_output.txt") + output = load_fixture("q_cli_idle_output.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_completed(self, mock_tmux): + def test_get_status_completed(self): """Test COMPLETED status detection.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_completed_output.txt") + output = load_fixture("kiro_cli_completed_output.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_processing(self, mock_tmux): + def test_get_status_processing(self): """Test PROCESSING status detection.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_processing_output.txt") + output = load_fixture("kiro_cli_processing_output.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_waiting_user_answer(self, mock_tmux): + def test_get_status_waiting_user_answer(self): """Test WAITING_USER_ANSWER status detection.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_permission_output.txt") + output = load_fixture("kiro_cli_permission_output.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_error(self, mock_tmux): + def test_get_status_error(self): """Test ERROR status detection.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_error_output.txt") + output = load_fixture("kiro_cli_error_output.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_with_empty_output(self, mock_tmux): + def test_get_status_with_empty_output(self): """Test status detection with empty output.""" - mock_tmux.get_history.return_value = "" + output = "" provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) - assert status == TerminalStatus.ERROR - - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - """Test status detection with tail_lines parameter.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_idle_output.txt") - - provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status(tail_lines=50) - - assert status == TerminalStatus.IDLE - mock_tmux.get_history.assert_called_once_with("test-session", "window-0", tail_lines=50) + assert status == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_status_processing_response_started_no_final_prompt(self, mock_tmux): + def test_status_processing_response_started_no_final_prompt(self): """Test status returns PROCESSING when response started but no final prompt.""" # Response started (green arrow) but no idle prompt after it - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mPartial response being generated..." ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_status_completed_prompt_after_response(self, mock_tmux): + def test_status_completed_prompt_after_response(self): """Test status returns COMPLETED when prompt appears after response.""" # Complete response with idle prompt after green arrow - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mComplete response here\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_extraction_succeeds_when_status_completed(self, mock_tmux): + def test_extraction_succeeds_when_status_completed(self): """Test extraction succeeds when status is COMPLETED.""" output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mComplete response here\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) - mock_tmux.get_history.return_value = output provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") # Verify status is COMPLETED - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Verify extraction succeeds message = provider.extract_last_message_from_script(output) assert "Complete response here" in message - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_multiple_prompts_in_buffer_edge_case(self, mock_tmux): + def test_multiple_prompts_in_buffer_edge_case(self): """Test with multiple prompts in buffer (edge case).""" # Multiple interactions in buffer - should use last response - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m first question\n" "\x1b[38;5;10m> \x1b[39mFirst response\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m second question\n" @@ -211,43 +192,40 @@ def test_multiple_prompts_in_buffer_edge_case(self, mock_tmux): ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Verify extraction gets the last response - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) assert "Second response" in message assert "First response" not in message - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_status_processing_multiple_green_arrows_no_final_prompt(self, mock_tmux): + def test_status_processing_multiple_green_arrows_no_final_prompt(self): """Test PROCESSING status with multiple green arrows but no final prompt.""" # Multiple responses but still processing (no final prompt after last arrow) - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m question\n" "\x1b[38;5;10m> \x1b[39mFirst part of response\n" "\x1b[38;5;10m> \x1b[39mSecond part still generating..." ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_status_idle_only_prompt_no_response(self, mock_tmux): + def test_status_idle_only_prompt_no_response(self): """Test IDLE status when only prompt present, no response.""" # Just the idle prompt, no green arrow response - mock_tmux.get_history.return_value = "\x1b[36m[developer]\x1b[35m>\x1b[39m" + output = "\x1b[36m[developer]\x1b[35m>\x1b[39m" provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_status_synchronization_guarantee(self, mock_tmux): + def test_status_synchronization_guarantee(self): """Test that COMPLETED status guarantees extraction will succeed.""" test_cases = [ # Case 1: Simple complete response @@ -276,10 +254,8 @@ def test_status_synchronization_guarantee(self, mock_tmux): provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") for output, expected_content in test_cases: - mock_tmux.get_history.return_value = output - # Status must be COMPLETED - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED, f"Status not COMPLETED for: {output}" # Extraction must succeed @@ -447,28 +423,26 @@ def test_permission_prompt_pattern(self): permission_text = "Allow this action? [y/n/t]: [developer]>" assert re.search(provider._permission_prompt_pattern, permission_text) - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_permission_prompt_no_match_stale_history(self, mock_tmux): + def test_permission_prompt_no_match_stale_history(self): """Test that stale permission prompts are not detected as active. The regex matches all [y/n/t]: occurrences; get_status() uses line-based counting to distinguish active from stale prompts. """ - stale = ( + output = ( "Allow this action? [y/n/t]:\n\n[developer] 29% > y\nsome output\n[developer] 29% > " ) - mock_tmux.get_history.return_value = stale provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER def test_ansi_code_cleaning(self): """Test ANSI code pattern cleaning.""" - from cli_agent_orchestrator.providers.kiro_cli import ANSI_CODE_PATTERN + from cli_agent_orchestrator.utils.text import strip_terminal_escapes text = "\x1b[36mColored text\x1b[39m normal text" - cleaned = re.sub(ANSI_CODE_PATTERN, "", text) + cleaned = strip_terminal_escapes(text) assert cleaned == "Colored text normal text" assert "\x1b[" not in cleaned @@ -477,33 +451,30 @@ def test_ansi_code_cleaning(self): class TestKiroCliProviderPromptPatterns: """Test various prompt pattern combinations.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_basic_prompt(self, mock_tmux): + def test_basic_prompt(self): """Test basic prompt without extras.""" - mock_tmux.get_history.return_value = "\x1b[36m[developer]\x1b[35m>\x1b[39m " + output = "\x1b[36m[developer]\x1b[35m>\x1b[39m " provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_prompt_with_percentage(self, mock_tmux): + def test_prompt_with_percentage(self): """Test prompt with usage percentage.""" - mock_tmux.get_history.return_value = "\x1b[36m[developer] \x1b[32m75%\x1b[35m>\x1b[39m " + output = "\x1b[36m[developer] \x1b[32m75%\x1b[35m>\x1b[39m " provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_prompt_with_special_profile_characters(self, mock_tmux): + def test_prompt_with_special_profile_characters(self): """Test prompt with special characters in profile name.""" - mock_tmux.get_history.return_value = "\x1b[36m[code-reviewer_v2]\x1b[35m>\x1b[39m " + output = "\x1b[36m[code-reviewer_v2]\x1b[35m>\x1b[39m " provider = KiroCliProvider("test1234", "test-session", "window-0", "code-reviewer_v2") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE @@ -511,21 +482,18 @@ def test_prompt_with_special_profile_characters(self, mock_tmux): class TestKiroCliProviderHandoffScenarios: """Test handoff scenarios between agents.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_successful_status(self, mock_tmux): + def test_handoff_successful_status(self): """Test COMPLETED status detection with successful handoff.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_handoff_successful.txt") + output = load_fixture("kiro_cli_handoff_successful.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_successful_message_extraction(self, mock_tmux): + def test_handoff_successful_message_extraction(self): """Test message extraction from successful handoff output.""" output = load_fixture("kiro_cli_handoff_successful.txt") - mock_tmux.get_history.return_value = output provider = KiroCliProvider("test1234", "test-session", "window-0", "supervisor") message = provider.extract_last_message_from_script(output) @@ -537,21 +505,18 @@ def test_handoff_successful_message_extraction(self, mock_tmux): assert "completed successfully" in message.lower() assert "developer agent" in message.lower() - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_error_status(self, mock_tmux): + def test_handoff_error_status(self): """Test ERROR status detection with failed handoff.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_handoff_error.txt") + output = load_fixture("kiro_cli_handoff_error.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_error_message_extraction(self, mock_tmux): + def test_handoff_error_message_extraction(self): """Test message extraction from failed handoff output.""" output = load_fixture("kiro_cli_handoff_error.txt") - mock_tmux.get_history.return_value = output provider = KiroCliProvider("test1234", "test-session", "window-0", "supervisor") @@ -562,18 +527,16 @@ def test_handoff_error_message_extraction(self, mock_tmux): assert "\x1b[" not in message assert "error" in message.lower() or "unable" in message.lower() - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_with_permission_prompt(self, mock_tmux): + def test_handoff_with_permission_prompt(self): """Test WAITING_USER_ANSWER status during handoff requiring permission.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_handoff_with_permission.txt") + output = load_fixture("kiro_cli_handoff_with_permission.txt") provider = KiroCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_message_preserves_content(self, mock_tmux): + def test_handoff_message_preserves_content(self): """Test that handoff message extraction preserves all content without truncation.""" output = load_fixture("kiro_cli_handoff_successful.txt") @@ -587,8 +550,7 @@ def test_handoff_message_preserves_content(self, mock_tmux): # Verify it's not truncated or corrupted assert len(message.split()) >= 8 # Should have multiple words - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_handoff_indices_not_corrupted(self, mock_tmux): + def test_handoff_indices_not_corrupted(self): """Test that ANSI code cleaning doesn't corrupt index-based extraction.""" output = load_fixture("kiro_cli_handoff_successful.txt") @@ -615,15 +577,6 @@ def test_exit_cli_command(self): assert exit_cmd == "/exit" - def test_get_idle_pattern_for_log(self): - """Test idle pattern for log files.""" - provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - pattern = provider.get_idle_pattern_for_log() - - from cli_agent_orchestrator.providers.kiro_cli import IDLE_PROMPT_PATTERN_LOG - - assert pattern == IDLE_PROMPT_PATTERN_LOG - def test_cleanup(self): """Test cleanup method.""" provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") @@ -633,62 +586,58 @@ def test_cleanup(self): assert provider._initialized is False - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_long_agent_profile_name(self, mock_tmux): + def test_long_agent_profile_name(self): """Test with very long agent profile name.""" long_profile = "very_long_agent_profile_name_that_exceeds_normal_length" - mock_tmux.get_history.return_value = f"\x1b[36m[{long_profile}]\x1b[35m>\x1b[39m " + output = f"\x1b[36m[{long_profile}]\x1b[35m>\x1b[39m " provider = KiroCliProvider("test1234", "test-session", "window-0", long_profile) - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_output_with_unicode_characters(self, mock_tmux): + def test_output_with_unicode_characters(self): """Test handling of unicode characters in output.""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;10m> \x1b[39mResponse with unicode: 日本語 café naïve 🚀\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Test message extraction - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) assert "日本語" in message assert "café" in message assert "🚀" in message - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_output_with_control_characters(self, mock_tmux): + def test_output_with_control_characters(self): """Test handling of control characters.""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;10m> \x1b[39mResponse\x07with\x1bcontrol\x00chars\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) # Control characters should be cleaned assert "\x07" not in message # Bell assert "\x00" not in message # Null - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_multiple_error_indicators(self, mock_tmux): + def test_multiple_error_indicators(self): """Test detection with multiple error indicators.""" - mock_tmux.get_history.return_value = ( + output = ( "Kiro is having trouble responding right now\n" "Kiro is having trouble responding right now\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR @@ -701,8 +650,7 @@ def test_terminal_attributes(self): assert provider.window_name == "window-0" assert provider._agent_profile == "developer" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_whitespace_variations_in_prompt(self, mock_tmux): + def test_whitespace_variations_in_prompt(self): """Test various whitespace scenarios in prompts.""" test_cases = [ "\x1b[36m[developer]\x1b[35m>\x1b[39m", @@ -714,6 +662,5 @@ def test_whitespace_variations_in_prompt(self, mock_tmux): provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") for test_output in test_cases: - mock_tmux.get_history.return_value = test_output - status = provider.get_status() + status = provider.get_status(test_output) assert status == TerminalStatus.IDLE diff --git a/test/providers/test_permission_prompt_detection.py b/test/providers/test_permission_prompt_detection.py index 78814130c..bf2d1d201 100644 --- a/test/providers/test_permission_prompt_detection.py +++ b/test/providers/test_permission_prompt_detection.py @@ -26,7 +26,6 @@ """ from pathlib import Path -from unittest.mock import patch import pytest @@ -48,88 +47,71 @@ def make_provider(agent_profile="developer"): class TestPermissionPromptActive: """Cases where permission prompt is active — should return WAITING_USER_ANSWER.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p1_active_empty_prompt(self, mock_tmux): + def test_p1_active_empty_prompt(self): """P1: Permission prompt shown, empty idle prompt on next line, unanswered.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_permission_active_empty.txt") + output = load_fixture("kiro_cli_permission_active_empty.txt") provider = make_provider("cao-internal-docs-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p2_active_trailing_text(self, mock_tmux): + def test_p2_active_trailing_text(self): """P2: Permission prompt + idle prompt with trailing text, unanswered.""" - mock_tmux.get_history.return_value = load_fixture( - "kiro_cli_permission_active_trailing_text.txt" - ) + output = load_fixture("kiro_cli_permission_active_trailing_text.txt") provider = make_provider("cao-jira-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p3_active_injection_delivered(self, mock_tmux): + def test_p3_active_injection_delivered(self): """P3: Permission prompt + CAO injection message delivered during prompt.""" - mock_tmux.get_history.return_value = load_fixture( - "kiro_cli_permission_active_injection.txt" - ) + output = load_fixture("kiro_cli_permission_active_injection.txt") provider = make_provider("cao-code-explorer-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p4_active_different_injection_text(self, mock_tmux): + def test_p4_active_different_injection_text(self): """P4: Permission prompt + different injected text on idle prompt.""" - mock_tmux.get_history.return_value = ( + output = ( "Allow this action? Use 't' to trust (always allow) this tool " "for the session. [y/n/t]:\n\n" "[cao-workspace-expert] 22% λ > don't you have the internal search?" ) provider = make_provider("cao-workspace-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p8_active_partial_typing(self, mock_tmux): + def test_p8_active_partial_typing(self): """P8: User typing partial text during permission prompt, no enter.""" - mock_tmux.get_history.return_value = load_fixture( - "kiro_cli_permission_active_partial_typing.txt" - ) + output = load_fixture("kiro_cli_permission_active_partial_typing.txt") provider = make_provider("cao-internal-docs-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p1_active_zero_idle_prompts_after(self, mock_tmux): + def test_p1_active_zero_idle_prompts_after(self): """Permission prompt with no idle prompt after it at all.""" - mock_tmux.get_history.return_value = ( + output = ( "Allow this action? Use 't' to trust (always allow) this tool " "for the session. [y/n/t]:\n" ) # No idle prompt → PROCESSING (no idle prompt detected at all) provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING class TestPermissionPromptStale: """Cases where permission prompt was answered — should NOT return WAITING_USER_ANSWER.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p5_answered_y_agent_idle(self, mock_tmux): + def test_p5_answered_y_agent_idle(self): """P5: User answered y, agent ran tool, now idle again.""" - mock_tmux.get_history.return_value = load_fixture("kiro_cli_permission_stale_answered.txt") + output = load_fixture("kiro_cli_permission_stale_answered.txt") provider = make_provider("cao-workspace-expert") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p6_long_response_instead_of_ynt(self, mock_tmux): + def test_p6_long_response_instead_of_ynt(self): """P6: User typed long response instead of y/n/t, agent continued.""" - mock_tmux.get_history.return_value = load_fixture( - "kiro_cli_permission_stale_long_response.txt" - ) + output = load_fixture("kiro_cli_permission_stale_long_response.txt") provider = make_provider("cao-query-decomposer-supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_p7_rerendered_prompts_then_answered(self, mock_tmux): + def test_p7_rerendered_prompts_then_answered(self): """P7: Multiple [y/n/t]: re-renders during typing, then answered.""" - mock_tmux.get_history.return_value = ( + output = ( "Allow this action? [y/n/t]:\n\n" "[developer] 16% λ > \n" "Allow this action? [y/n/t]:\n\n" @@ -141,13 +123,12 @@ def test_p7_rerendered_prompts_then_answered(self, mock_tmux): "[developer] 18% λ > " ) provider = make_provider("developer") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_stale_single_prompt_answered(self, mock_tmux): + def test_stale_single_prompt_answered(self): """Single permission prompt answered, 2 idle prompts after.""" - mock_tmux.get_history.return_value = ( + output = ( "Allow this action? [y/n/t]:\n\n" "[developer] 10% λ > y\n\n" " - Completed in 1.5s\n\n" @@ -155,85 +136,72 @@ def test_stale_single_prompt_answered(self, mock_tmux): "[developer] 12% λ > " ) provider = make_provider("developer") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER class TestNonPermissionCases: """Cases without permission prompts — existing detection should work.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n1_plain_idle(self, mock_tmux): + def test_n1_plain_idle(self): """N1: Plain idle, no permission prompt.""" - mock_tmux.get_history.return_value = "[developer] 22% λ > " + output = "[developer] 22% λ > " provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n2_idle_trailing_text(self, mock_tmux): + def test_n2_idle_trailing_text(self): """N2: Idle with trailing text after prompt.""" - mock_tmux.get_history.return_value = "[developer] 24% λ > send message back to supervisor?" + output = "[developer] 24% λ > send message back to supervisor?" provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n3_idle_what_would_you_like(self, mock_tmux): + def test_n3_idle_what_would_you_like(self): """N3: Idle with 'What would you like to do next?' trailing text.""" - mock_tmux.get_history.return_value = "[developer] 11% > What would you like to do next?" + output = "[developer] 11% > What would you like to do next?" provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n4_running_tool(self, mock_tmux): + def test_n4_running_tool(self): """N4: Tool is executing, no idle prompt.""" - mock_tmux.get_history.return_value = ( - "Searching for: system-privileges (*.toml) (using tool: grep)" - ) + output = "Searching for: system-privileges (*.toml) (using tool: grep)" provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.PROCESSING + assert provider.get_status(output) == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n6_completed_response(self, mock_tmux): + def test_n6_completed_response(self): """N6: Agent completed response, prompt shown after green arrow.""" - mock_tmux.get_history.return_value = ( + output = ( "[developer] 20% λ > user question\n" "> Complete response here\n" "[developer] 22% λ > " ) provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_n9_message_received(self, mock_tmux): + def test_n9_message_received(self): """N9: Inbox message delivered, agent idle.""" - mock_tmux.get_history.return_value = ( - "[developer] 12% > [Message from terminal 9445aa60] " "Hello from supervisor" - ) + output = "[developer] 12% > [Message from terminal 9445aa60] " "Hello from supervisor" provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.IDLE + assert provider.get_status(output) == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_empty_output(self, mock_tmux): - """Empty output returns ERROR.""" - mock_tmux.get_history.return_value = "" + def test_empty_output(self): + """Empty output returns UNKNOWN.""" + output = "" provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.ERROR + assert provider.get_status(output) == TerminalStatus.UNKNOWN class TestPermissionPromptEdgeCases: """Edge cases for permission prompt detection.""" - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_permission_same_line_as_idle(self, mock_tmux): + def test_permission_same_line_as_idle(self): """Original fixture format: [y/n/t]: and idle prompt on same line.""" - mock_tmux.get_history.return_value = "Allow this action? [y/n/t]: [developer] 10% λ > " + output = "Allow this action? [y/n/t]: [developer] 10% λ > " provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_multiple_active_prompts_last_unanswered(self, mock_tmux): + def test_multiple_active_prompts_last_unanswered(self): """Multiple permission prompts, last one unanswered.""" - mock_tmux.get_history.return_value = ( + output = ( "Allow this action? [y/n/t]:\n\n" "[developer] 10% λ > y\n\n" " - Completed in 1s\n\n" @@ -242,12 +210,11 @@ def test_multiple_active_prompts_last_unanswered(self, mock_tmux): "[developer] 12% λ > " ) provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_permission_with_ansi_codes(self, mock_tmux): + def test_permission_with_ansi_codes(self): """Permission prompt with ANSI color codes (real terminal output).""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;244mAllow this action? Use '\x1b[38;5;13mt\x1b[38;5;244m' " "to trust (always allow) this tool for the session. " "[\x1b[38;5;13my\x1b[38;5;244m/\x1b[38;5;13mn\x1b[38;5;244m/" @@ -255,17 +222,15 @@ def test_permission_with_ansi_codes(self, mock_tmux): "\x1b[38;5;6m[developer] \x1b[32m16% \x1b[38;5;39mλ \x1b[38;5;93m> \x1b[0m" ) provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_no_permission_prompt_in_output(self, mock_tmux): + def test_no_permission_prompt_in_output(self): """No permission prompt at all — should not affect idle detection.""" - mock_tmux.get_history.return_value = "> Here is my response\n\n" "[developer] 22% λ > " + output = "> Here is my response\n\n" "[developer] 22% λ > " provider = make_provider("developer") - assert provider.get_status() == TerminalStatus.COMPLETED + assert provider.get_status(output) == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_real_ansi_active_trailing_text(self, mock_tmux): + def test_real_ansi_active_trailing_text(self): """Real ANSI output: active prompt with trailing text and \\r redraw. From 00ce37f3.log: kiro-cli shows [y/n/t]: then redraws idle prompt @@ -273,7 +238,7 @@ def test_real_ansi_active_trailing_text(self, mock_tmux): The \\r redraw creates two idle prompt matches on the same line. Line-based counting correctly treats this as 1 line = active. """ - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;244mAllow this action? Use '\x1b[38;5;13mt\x1b[38;5;244m' " "to trust (always allow) this tool for the session. " "[\x1b[38;5;13my\x1b[38;5;244m/\x1b[38;5;13mn\x1b[38;5;244m/" @@ -287,16 +252,15 @@ def test_real_ansi_active_trailing_text(self, mock_tmux): "\x1b[38;5;93m> \x1b[0m" ) provider = make_provider("cao-jira-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_real_ansi_active_injection(self, mock_tmux): + def test_real_ansi_active_injection(self): """Real ANSI output: active prompt with CAO injection delivered. From 0895b67b.log: injection message delivered during permission prompt via \\r redraw on same line. """ - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;244mAllow this action? Use '\x1b[38;5;13mt\x1b[38;5;244m' " "to trust (always allow) this tool for the session. " "[\x1b[38;5;13my\x1b[38;5;244m/\x1b[38;5;13mn\x1b[38;5;244m/" @@ -313,16 +277,15 @@ def test_real_ansi_active_injection(self, mock_tmux): "terminal 63878fc7 using send_message]" ) provider = make_provider("cao-code-explorer-expert") - assert provider.get_status() == TerminalStatus.WAITING_USER_ANSWER + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_real_ansi_stale_answered_y(self, mock_tmux): + def test_real_ansi_stale_answered_y(self): """Real ANSI output: permission answered with y, agent continued. From 4d9d97cf.log: user typed y via \\r redraw, tool completed, new prompt on separate \\n line. """ - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;244mAllow this action? Use '\x1b[38;5;13mt\x1b[38;5;244m' " "to trust (always allow) this tool for the session. " "[\x1b[38;5;13my\x1b[38;5;244m/\x1b[38;5;13mn\x1b[38;5;244m/" @@ -342,5 +305,5 @@ def test_real_ansi_stale_answered_y(self, mock_tmux): "\x1b[38;5;39m\u03bb \x1b[0m\x1b[38;5;93m> \x1b[0m" ) provider = make_provider("cao-workspace-expert") - status = provider.get_status() + status = provider.get_status(output) assert status != TerminalStatus.WAITING_USER_ANSWER diff --git a/test/providers/test_q_cli_integration.py b/test/providers/test_q_cli_integration.py index 045b75600..843e225d4 100644 --- a/test/providers/test_q_cli_integration.py +++ b/test/providers/test_q_cli_integration.py @@ -1,4 +1,7 @@ -"""Integration tests for Q CLI provider with real Q CLI.""" +"""Integration tests for Q CLI provider with real Q CLI. + +Uses the real create_terminal() flow with FIFO pipeline and mocked DB. +""" import json import shutil @@ -7,11 +10,17 @@ from pathlib import Path import pytest +import pytest_asyncio from cli_agent_orchestrator.clients.tmux import tmux_client from cli_agent_orchestrator.models.terminal import TerminalStatus -from cli_agent_orchestrator.providers.q_cli import QCliProvider -from cli_agent_orchestrator.utils.terminal import wait_for_shell +from cli_agent_orchestrator.providers.manager import provider_manager +from cli_agent_orchestrator.services.status_monitor import status_monitor +from cli_agent_orchestrator.services.terminal_service import ( + create_terminal, + delete_terminal, + send_input, +) # Mark all tests in this module as integration and slow pytestmark = [pytest.mark.integration, pytest.mark.slow] @@ -32,14 +41,10 @@ def ensure_test_agent(q_cli_available): agent_dir = Path.home() / ".aws" / "amazonq" / "cli-agents" agent_file = agent_dir / f"{agent_name}.json" - # Check if agent already exists if agent_file.exists(): return agent_name - # Create agent directory if it doesn't exist agent_dir.mkdir(parents=True, exist_ok=True) - - # Create a minimal test agent configuration agent_config = { "name": agent_name, "description": "", @@ -48,463 +53,254 @@ def ensure_test_agent(q_cli_available): "useLegacyMcpJson": True, "model": None, } - - # Write agent configuration with open(agent_file, "w") as f: json.dump(agent_config, f, indent=2) - print(f"\nCreated test agent '{agent_name}' at {agent_file}") return agent_name -@pytest.fixture -def test_session_name(): - """Generate a unique test session name.""" - return f"test-q-cli-{uuid.uuid4().hex[:8]}" - - -@pytest.fixture -def cleanup_session(test_session_name): - """Cleanup fixture that ensures test session is terminated.""" - yield - # Cleanup after test +@pytest_asyncio.fixture +async def terminal(event_pipeline, mock_db, ensure_test_agent): + """Create a real terminal via create_terminal() with full FIFO pipeline.""" + t = await create_terminal( + provider="q_cli", + agent_profile=ensure_test_agent, + new_session=True, + ) + yield t try: - tmux_client.kill_session(test_session_name) + delete_terminal(t.id) except Exception: - pass # Session may already be cleaned up + pass + try: + tmux_client.kill_session(t.session_name) + except Exception: + pass -class TestQCliProviderIntegration: - """Integration tests with real Q CLI.""" +# --- Helpers --- - def test_real_q_chat_initialization( - self, ensure_test_agent, test_session_name, cleanup_session - ): - """Test real Q CLI initialization flow.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) - try: - # Create provider and initialize (using agent from ensure_test_agent fixture) - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - result = provider.initialize() +def _wait_for_status(terminal_id, target, timeout=30): + elapsed = 0 + while elapsed < timeout: + s = status_monitor.get_status(terminal_id) + if s == target: + return s + time.sleep(1) + elapsed += 1 + return status_monitor.get_status(terminal_id) - # Verify initialization succeeded - assert result is True - # Give Q CLI a moment to fully initialize - time.sleep(2) +def _send(terminal_id, text): + send_input(terminal_id, text) - # Verify status is IDLE after initialization - status = provider.get_status() - assert status == TerminalStatus.IDLE - finally: - # Cleanup - tmux_client.kill_session(test_session_name) +class TestQCliProviderIntegration: + """Integration tests with real Q CLI.""" - def test_real_q_chat_simple_query(self, ensure_test_agent, test_session_name, cleanup_session): - """Test real Q CLI with a simple query.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) + @pytest.mark.asyncio + async def test_real_q_chat_initialization(self, terminal): + """Test real Q CLI initialization flow.""" + status = status_monitor.get_status(terminal.id) + assert status in {TerminalStatus.IDLE, TerminalStatus.COMPLETED} - try: - # Initialize Q CLI (using agent from ensure_test_agent fixture) - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - provider.initialize() + @pytest.mark.asyncio + async def test_real_q_chat_simple_query(self, terminal): + """Test real Q CLI with a simple query.""" + _send(terminal.id, "Say 'Hello, integration test!'") - # Wait for IDLE status - time.sleep(2) - assert provider.get_status() == TerminalStatus.IDLE + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED) + assert status == TerminalStatus.COMPLETED - # Send a simple query - simple_query = "Say 'Hello, integration test!'" - tmux_client.send_keys(test_session_name, window_name, simple_query) + provider = provider_manager.get_provider(terminal.id) + output = status_monitor.get_buffer(terminal.id) + message = provider.extract_last_message_from_script(output) - # Wait for processing - time.sleep(1) - status = provider.get_status() - assert status in [TerminalStatus.PROCESSING, TerminalStatus.COMPLETED] - - # Wait for completion (max 30 seconds) - max_wait = 30 - elapsed = 0 - while elapsed < max_wait: - status = provider.get_status() - if status == TerminalStatus.COMPLETED: - break - time.sleep(1) - elapsed += 1 - - # Verify we got a completed response - assert status == TerminalStatus.COMPLETED - - # Extract and verify the message - output = tmux_client.get_history(test_session_name, window_name) - message = provider.extract_last_message_from_script(output) + assert len(message) > 0 + assert "\x1b[" not in message - # Message should contain something (not empty) - assert len(message) > 0 - assert "\x1b[" not in message # ANSI codes cleaned - - finally: - # Cleanup - tmux_client.kill_session(test_session_name) - - def test_real_q_chat_status_detection( - self, ensure_test_agent, test_session_name, cleanup_session - ): + @pytest.mark.asyncio + async def test_real_q_chat_status_detection(self, terminal): """Test status detection with real Q CLI output.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) + _send(terminal.id, "What is 2+2?") - try: - # Initialize Q CLI (using agent from ensure_test_agent fixture) - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - provider.initialize() - - # Test IDLE status - time.sleep(2) - assert provider.get_status() == TerminalStatus.IDLE - - # Send a query to trigger PROCESSING/COMPLETED states - tmux_client.send_keys(test_session_name, window_name, "What is 2+2?") - - # Should be PROCESSING or quickly move to COMPLETED - time.sleep(1) - status = provider.get_status() - assert status in [TerminalStatus.PROCESSING, TerminalStatus.COMPLETED] - - # Wait for completion - max_wait = 30 - elapsed = 0 - while elapsed < max_wait: - status = provider.get_status() - if status == TerminalStatus.COMPLETED: - break - time.sleep(1) - elapsed += 1 - - # Should be COMPLETED - assert status == TerminalStatus.COMPLETED - - # After some time, should return to IDLE (if we send Enter) - time.sleep(1) - tmux_client.send_keys(test_session_name, window_name, "") - time.sleep(1) + time.sleep(1) + status = status_monitor.get_status(terminal.id) + assert status in [TerminalStatus.PROCESSING, TerminalStatus.COMPLETED] - finally: - # Cleanup - tmux_client.kill_session(test_session_name) + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED) + assert status == TerminalStatus.COMPLETED - def test_real_q_chat_exit(self, ensure_test_agent, test_session_name, cleanup_session): + @pytest.mark.asyncio + async def test_real_q_chat_exit(self, terminal): """Test exiting Q CLI.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) - - try: - # Initialize Q CLI (using agent from ensure_test_agent fixture) - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - provider.initialize() - - time.sleep(2) - assert provider.get_status() == TerminalStatus.IDLE - - # Send exit command - exit_cmd = provider.exit_cli() - tmux_client.send_keys(test_session_name, window_name, exit_cmd) - - # Wait for exit - time.sleep(2) - - # Get the output to verify exit happened - output = tmux_client.get_history(test_session_name, window_name) - - # Should not have the Q CLI prompt anymore after exit - # (This test verifies the exit command works) - assert "/exit" in output or "exit" in output.lower() + provider = provider_manager.get_provider(terminal.id) + exit_cmd = provider.exit_cli() + _send(terminal.id, exit_cmd) - finally: - # Cleanup - tmux_client.kill_session(test_session_name) + time.sleep(2) + output = status_monitor.get_buffer(terminal.id) + assert "/exit" in output or "exit" in output.lower() - def test_real_q_chat_with_different_profile( - self, ensure_test_agent, test_session_name, cleanup_session + @pytest.mark.asyncio + async def test_real_q_chat_with_different_profile( + self, event_pipeline, mock_db, q_cli_available ): """Test Q CLI with a different agent profile if available.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) - try: - # Try with a different profile (may not exist, that's okay) - provider = QCliProvider(terminal_id, test_session_name, window_name, "test-agent") - - # Initialize - may fail if profile doesn't exist - try: - result = provider.initialize() - # If it succeeds, verify basic functionality - if result: - time.sleep(2) - status = provider.get_status() - # Status should be IDLE or ERROR (if profile doesn't exist) - assert status in [TerminalStatus.IDLE, TerminalStatus.ERROR] - except TimeoutError: - # Profile may not exist, that's acceptable - pytest.skip("Test profile not available") - - finally: - # Cleanup - tmux_client.kill_session(test_session_name) + t = await create_terminal( + provider="q_cli", + agent_profile="test-agent", + new_session=True, + ) + status = status_monitor.get_status(t.id) + assert status in [TerminalStatus.IDLE, TerminalStatus.ERROR] + delete_terminal(t.id) + tmux_client.kill_session(t.session_name) + except TimeoutError: + pytest.skip("Test profile not available") class TestQCliProviderHandoffIntegration: """Integration tests for handoff scenarios.""" - def test_real_handoff_status_transitions( - self, ensure_test_agent, test_session_name, cleanup_session - ): + @pytest.mark.asyncio + async def test_real_handoff_status_transitions(self, terminal): """Test status transitions during a real handoff scenario.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) - - try: - # Initialize Q CLI with supervisor agent - # Note: This assumes a supervisor agent exists. If not, will use developer. - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - provider.initialize() - - # Wait for IDLE status - time.sleep(2) - assert provider.get_status() == TerminalStatus.IDLE - - # Send a query that might trigger handoff-like behavior - # (Real handoff depends on agent configuration) - handoff_query = "Please help me with implementing a new feature" - tmux_client.send_keys(test_session_name, window_name, handoff_query) - - # Monitor status transitions - statuses = [] - max_wait = 30 - elapsed = 0 - - while elapsed < max_wait: - status = provider.get_status() - statuses.append(status) - - # Break if we reach COMPLETED or ERROR - if status in [TerminalStatus.COMPLETED, TerminalStatus.ERROR]: - break - - time.sleep(1) - elapsed += 1 - - # Verify we got through the expected states - assert TerminalStatus.PROCESSING in statuses or TerminalStatus.COMPLETED in statuses - - # Extract the message if completed - if statuses[-1] == TerminalStatus.COMPLETED: - output = tmux_client.get_history(test_session_name, window_name) - message = provider.extract_last_message_from_script(output) - - # Verify message extraction worked - assert len(message) > 0 - assert "\x1b[" not in message # ANSI codes cleaned - - finally: - # Cleanup - tmux_client.kill_session(test_session_name) - - def test_real_handoff_message_integrity( - self, ensure_test_agent, test_session_name, cleanup_session - ): - """Test that message extraction maintains integrity during handoff.""" - # Create a test tmux session - terminal_id = "test1234" - window_name = "window-0" - tmux_client.create_session(test_session_name, window_name, terminal_id) - - try: - # Initialize Q CLI - provider = QCliProvider(terminal_id, test_session_name, window_name, ensure_test_agent) - provider.initialize() - - time.sleep(2) - assert provider.get_status() == TerminalStatus.IDLE + _send(terminal.id, "Please help me with implementing a new feature") + + statuses = [] + max_wait = 30 + elapsed = 0 + while elapsed < max_wait: + status = status_monitor.get_status(terminal.id) + statuses.append(status) + if status in [TerminalStatus.COMPLETED, TerminalStatus.ERROR]: + break + time.sleep(1) + elapsed += 1 - # Send a simple query (shorter to avoid buffer truncation) - query = "Say 'Test message integrity'" - tmux_client.send_keys(test_session_name, window_name, query) + assert TerminalStatus.PROCESSING in statuses or TerminalStatus.COMPLETED in statuses - # Wait for processing to start - time.sleep(1) - initial_status = provider.get_status() - - # If already completed, we're done - if initial_status == TerminalStatus.COMPLETED: - status = initial_status - else: - # Otherwise wait for completion - if initial_status != TerminalStatus.PROCESSING: - # Debug: print terminal output if not in expected state - debug_output = tmux_client.get_history(test_session_name, window_name) - print(f"\n=== DEBUG: Unexpected initial status ===") - print(f"Status: {initial_status}") - print(f"Terminal output:\n{debug_output}") - print("=" * 50) - assert ( - initial_status == TerminalStatus.PROCESSING - ), f"Expected PROCESSING but got {initial_status}" - - max_wait = 30 - elapsed = 0 - status_history = [initial_status] - while elapsed < max_wait: - status = provider.get_status() - if status != status_history[-1]: - status_history.append(status) - if status == TerminalStatus.COMPLETED: - break - time.sleep(1) - elapsed += 1 - - if status != TerminalStatus.COMPLETED: - # Debug: print terminal output on failure - debug_output = tmux_client.get_history(test_session_name, window_name) - print(f"\n=== DEBUG: Test failed ===") - print(f"Final status: {status}") - print(f"Status history: {status_history}") - print(f"Terminal output:\n{debug_output}") - print("=" * 50) - - assert ( - status == TerminalStatus.COMPLETED - ), f"Expected COMPLETED but got {status} after {elapsed} seconds. Status history: {status_history}" - - # Get the output - output = tmux_client.get_history(test_session_name, window_name) - - # Extract message and verify indices weren't corrupted + if statuses[-1] == TerminalStatus.COMPLETED: + provider = provider_manager.get_provider(terminal.id) + output = status_monitor.get_buffer(terminal.id) message = provider.extract_last_message_from_script(output) - - # Verify message quality assert len(message) > 0 - assert "\x1b[" not in message # All ANSI codes removed - assert not message.startswith("[") # No partial ANSI codes - assert not message.endswith("\x1b") # No trailing escape chars + assert "\x1b[" not in message - # Message should be coherent (no index corruption) - # A corrupted extraction would have fragments or missing parts - assert len(message.split()) >= 3 # Should have multiple words - assert "Test message integrity" in message # Should contain our expected phrase + @pytest.mark.asyncio + async def test_real_handoff_message_integrity(self, terminal): + """Test that message extraction maintains integrity during handoff.""" + _send(terminal.id, "Say 'Test message integrity'") - finally: - # Cleanup - tmux_client.kill_session(test_session_name) + status = _wait_for_status(terminal.id, TerminalStatus.COMPLETED) + assert status == TerminalStatus.COMPLETED, f"Expected COMPLETED but got {status}" + + provider = provider_manager.get_provider(terminal.id) + output = status_monitor.get_buffer(terminal.id) + message = provider.extract_last_message_from_script(output) + + assert len(message) > 0 + assert "\x1b[" not in message + assert not message.startswith("[") + assert not message.endswith("\x1b") + assert len(message.split()) >= 3 class TestQCliProviderWorkingDirectory: - """Integration tests for working directory functionality.""" + """Integration tests for working directory functionality. + + These tests don't need Q CLI — just tmux. + """ @pytest.fixture def home_tmp_path(self): - """Create a temporary directory inside home directory to pass path validation.""" path = Path.home() / f".cao_test_tmp_{uuid.uuid4().hex[:8]}" path.mkdir(parents=True, exist_ok=True) yield path shutil.rmtree(path, ignore_errors=True) + @pytest.fixture + def test_session_name(self): + return f"test-q-cli-{uuid.uuid4().hex[:8]}" + + @pytest.fixture + def cleanup_session(self, test_session_name): + yield + try: + tmux_client.kill_session(test_session_name) + except Exception: + pass + def test_session_starts_in_custom_directory( self, test_session_name, cleanup_session, home_tmp_path ): """Test that terminal starts in specified working directory.""" - # Create session with custom working directory window_name = tmux_client.create_session( - test_session_name, "test-window", "test-term-id", working_directory=str(home_tmp_path) + test_session_name, + "test-window", + "test-term-id", + working_directory=str(home_tmp_path), ) - - # Query the working directory actual_dir = tmux_client.get_pane_working_directory(test_session_name, window_name) - assert actual_dir == str(home_tmp_path.resolve()) def test_working_directory_changes_are_detected( self, test_session_name, cleanup_session, home_tmp_path ): """Test that directory changes in terminal are detected.""" - # Create session window_name = tmux_client.create_session( - test_session_name, "test-window", "test-term-id", working_directory=str(home_tmp_path) + test_session_name, + "test-window", + "test-term-id", + working_directory=str(home_tmp_path), ) - - # Create subdirectory subdir = home_tmp_path / "subdir" subdir.mkdir() - # Change directory in tmux pane - # wait_for_shell ensures shell is initialized before sending commands - # (paste-buffer delivery is instant, so shell must be ready first) - wait_for_shell(tmux_client, test_session_name, window_name, timeout=10.0) + time.sleep(3) tmux_client.send_keys(test_session_name, window_name, f"cd {subdir}") - time.sleep(0.5) # Wait for command to execute + time.sleep(2) - # Query working directory actual_dir = tmux_client.get_pane_working_directory(test_session_name, window_name) - assert actual_dir == str(subdir.resolve()) def test_symlink_resolution(self, test_session_name, cleanup_session, home_tmp_path): """Test that symlinks are resolved to real paths.""" - # Create real directory and symlink real_dir = home_tmp_path / "real" real_dir.mkdir() link_dir = home_tmp_path / "link" link_dir.symlink_to(real_dir) - # Create session with symlink path window_name = tmux_client.create_session( - test_session_name, "test-window", "test-term-id", working_directory=str(link_dir) + test_session_name, + "test-window", + "test-term-id", + working_directory=str(link_dir), ) - - # Should resolve to real path actual_dir = tmux_client.get_pane_working_directory(test_session_name, window_name) - assert actual_dir == str(real_dir.resolve()) class TestQCliProviderIntegrationErrorHandling: """Integration tests for error scenarios.""" - def test_invalid_session_handling(self, q_cli_available): - """Test handling of invalid session.""" - provider = QCliProvider("test1234", "non-existent-session", "window-0", "developer") - - # Should raise an error or timeout when trying to initialize - # with a non-existent session + @pytest.mark.asyncio + async def test_invalid_session_handling(self, event_pipeline, mock_db, q_cli_available): + """Test handling of invalid agent profile.""" with pytest.raises((TimeoutError, Exception)): - provider.initialize() - - def test_get_status_with_nonexistent_session(self, q_cli_available): - """Test get_status with non-existent session.""" - provider = QCliProvider("test1234", "non-existent-session", "window-0", "developer") - - # Should handle gracefully (likely return ERROR status) - # The exact behavior depends on tmux_client implementation - try: - status = provider.get_status() - # If it doesn't raise an exception, it should return ERROR - assert status == TerminalStatus.ERROR - except Exception: - # It's also acceptable to raise an exception - pass + await create_terminal( + provider="q_cli", + agent_profile="non-existent-agent-profile-xyz", + new_session=True, + ) + + def test_get_status_with_empty_output(self, q_cli_available): + """Test get_status with empty output.""" + from cli_agent_orchestrator.providers.q_cli import QCliProvider + + provider = QCliProvider("test1234", "non-existent", "window-0", "developer") + status = provider.get_status("") + assert status == TerminalStatus.UNKNOWN diff --git a/test/providers/test_q_cli_unit.py b/test/providers/test_q_cli_unit.py index 2a7fd4620..98f965cde 100644 --- a/test/providers/test_q_cli_unit.py +++ b/test/providers/test_q_cli_unit.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import patch import pytest @@ -22,16 +22,17 @@ def load_fixture(filename: str) -> str: class TestQCliProviderInitialization: """Test Q CLI provider initialization.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.q_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.q_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test successful initialization.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True provider = QCliProvider("test1234", "test-session", "window-0", "developer") - result = provider.initialize() + result = await provider.initialize() assert result is True mock_wait_shell.assert_called_once() @@ -40,21 +41,23 @@ def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): ) mock_wait_status.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.q_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): + async def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): """Test initialization with shell timeout.""" mock_wait_shell.return_value = False provider = QCliProvider("test1234", "test-session", "window-0", "developer") with pytest.raises(TimeoutError, match="Shell initialization timed out"): - provider.initialize() + await provider.initialize() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.providers.q_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.q_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_initialize_q_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): + async def test_initialize_q_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): """Test initialization with Q CLI timeout.""" mock_wait_shell.return_value = True mock_wait_status.return_value = False @@ -62,7 +65,7 @@ def test_initialize_q_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_s provider = QCliProvider("test1234", "test-session", "window-0", "developer") with pytest.raises(TimeoutError, match="Q CLI initialization timed out"): - provider.initialize() + await provider.initialize() def test_initialization_with_different_agent_profiles(self): """Test initialization with various agent profile names.""" @@ -78,131 +81,109 @@ def test_initialization_with_different_agent_profiles(self): class TestQCliProviderStatusDetection: """Test status detection logic.""" - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_idle(self, mock_tmux): + def test_get_status_idle(self): """Test IDLE status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_idle_output.txt") + output = load_fixture("q_cli_idle_output.txt") provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_completed(self, mock_tmux): + def test_get_status_completed(self): """Test COMPLETED status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_completed_output.txt") + output = load_fixture("q_cli_completed_output.txt") provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_processing(self, mock_tmux): + def test_get_status_processing(self): """Test PROCESSING status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_processing_output.txt") + output = load_fixture("q_cli_processing_output.txt") provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_waiting_user_answer(self, mock_tmux): + def test_get_status_waiting_user_answer(self): """Test WAITING_USER_ANSWER status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_permission_output.txt") + output = load_fixture("q_cli_permission_output.txt") provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_error(self, mock_tmux): + def test_get_status_error(self): """Test ERROR status detection.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_error_output.txt") + output = load_fixture("q_cli_error_output.txt") provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_with_empty_output(self, mock_tmux): + def test_get_status_with_empty_output(self): """Test status detection with empty output.""" - mock_tmux.get_history.return_value = "" + output = "" provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) - assert status == TerminalStatus.ERROR - - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_get_status_with_tail_lines(self, mock_tmux): - """Test status detection with tail_lines parameter.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_idle_output.txt") - - provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status(tail_lines=50) - - assert status == TerminalStatus.IDLE - mock_tmux.get_history.assert_called_once_with("test-session", "window-0", tail_lines=50) + assert status == TerminalStatus.UNKNOWN - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_status_processing_response_started_no_final_prompt(self, mock_tmux): + def test_status_processing_response_started_no_final_prompt(self): """Test status returns PROCESSING when response started but no final prompt.""" # Response started (green arrow) but no idle prompt after it - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mPartial response being generated..." ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_status_completed_prompt_after_response(self, mock_tmux): + def test_status_completed_prompt_after_response(self): """Test status returns COMPLETED when prompt appears after response.""" # Complete response with idle prompt after green arrow - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mComplete response here\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_extraction_succeeds_when_status_completed(self, mock_tmux): + def test_extraction_succeeds_when_status_completed(self): """Test extraction succeeds when status is COMPLETED.""" output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m user question\n" "\x1b[38;5;10m> \x1b[39mComplete response here\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) - mock_tmux.get_history.return_value = output provider = QCliProvider("test1234", "test-session", "window-0", "developer") # Verify status is COMPLETED - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Verify extraction succeeds message = provider.extract_last_message_from_script(output) assert "Complete response here" in message - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_multiple_prompts_in_buffer_edge_case(self, mock_tmux): + def test_multiple_prompts_in_buffer_edge_case(self): """Test with multiple prompts in buffer (edge case).""" # Multiple interactions in buffer - should use last response - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m first question\n" "\x1b[38;5;10m> \x1b[39mFirst response\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m second question\n" @@ -211,43 +192,40 @@ def test_multiple_prompts_in_buffer_edge_case(self, mock_tmux): ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Verify extraction gets the last response - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) assert "Second response" in message assert "First response" not in message - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_status_processing_multiple_green_arrows_no_final_prompt(self, mock_tmux): + def test_status_processing_multiple_green_arrows_no_final_prompt(self): """Test PROCESSING status with multiple green arrows but no final prompt.""" # Multiple responses but still processing (no final prompt after last arrow) - mock_tmux.get_history.return_value = ( + output = ( "\x1b[36m[developer]\x1b[35m>\x1b[39m question\n" "\x1b[38;5;10m> \x1b[39mFirst part of response\n" "\x1b[38;5;10m> \x1b[39mSecond part still generating..." ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.PROCESSING - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_status_idle_only_prompt_no_response(self, mock_tmux): + def test_status_idle_only_prompt_no_response(self): """Test IDLE status when only prompt present, no response.""" # Just the idle prompt, no green arrow response - mock_tmux.get_history.return_value = "\x1b[36m[developer]\x1b[35m>\x1b[39m" + output = "\x1b[36m[developer]\x1b[35m>\x1b[39m" provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_status_synchronization_guarantee(self, mock_tmux): + def test_status_synchronization_guarantee(self): """Test that COMPLETED status guarantees extraction will succeed.""" test_cases = [ # Case 1: Simple complete response @@ -276,10 +254,8 @@ def test_status_synchronization_guarantee(self, mock_tmux): provider = QCliProvider("test1234", "test-session", "window-0", "developer") for output, expected_content in test_cases: - mock_tmux.get_history.return_value = output - # Status must be COMPLETED - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED, f"Status not COMPLETED for: {output}" # Extraction must succeed @@ -460,33 +436,30 @@ def test_ansi_code_cleaning(self): class TestQCliProviderPromptPatterns: """Test various prompt pattern combinations.""" - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_basic_prompt(self, mock_tmux): + def test_basic_prompt(self): """Test basic prompt without extras.""" - mock_tmux.get_history.return_value = "\x1b[36m[developer]\x1b[35m>\x1b[39m " + output = "\x1b[36m[developer]\x1b[35m>\x1b[39m " provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_prompt_with_percentage(self, mock_tmux): + def test_prompt_with_percentage(self): """Test prompt with usage percentage.""" - mock_tmux.get_history.return_value = "\x1b[36m[developer] \x1b[32m75%\x1b[35m>\x1b[39m " + output = "\x1b[36m[developer] \x1b[32m75%\x1b[35m>\x1b[39m " provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_prompt_with_special_profile_characters(self, mock_tmux): + def test_prompt_with_special_profile_characters(self): """Test prompt with special characters in profile name.""" - mock_tmux.get_history.return_value = "\x1b[36m[code-reviewer_v2]\x1b[35m>\x1b[39m " + output = "\x1b[36m[code-reviewer_v2]\x1b[35m>\x1b[39m " provider = QCliProvider("test1234", "test-session", "window-0", "code-reviewer_v2") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE @@ -494,21 +467,18 @@ def test_prompt_with_special_profile_characters(self, mock_tmux): class TestQCliProviderHandoffScenarios: """Test handoff scenarios between agents.""" - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_successful_status(self, mock_tmux): + def test_handoff_successful_status(self): """Test COMPLETED status detection with successful handoff.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_handoff_successful.txt") + output = load_fixture("q_cli_handoff_successful.txt") provider = QCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_successful_message_extraction(self, mock_tmux): + def test_handoff_successful_message_extraction(self): """Test message extraction from successful handoff output.""" output = load_fixture("q_cli_handoff_successful.txt") - mock_tmux.get_history.return_value = output provider = QCliProvider("test1234", "test-session", "window-0", "supervisor") message = provider.extract_last_message_from_script(output) @@ -520,21 +490,18 @@ def test_handoff_successful_message_extraction(self, mock_tmux): assert "completed successfully" in message.lower() assert "developer agent" in message.lower() - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_error_status(self, mock_tmux): + def test_handoff_error_status(self): """Test ERROR status detection with failed handoff.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_handoff_error.txt") + output = load_fixture("q_cli_handoff_error.txt") provider = QCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_error_message_extraction(self, mock_tmux): + def test_handoff_error_message_extraction(self): """Test message extraction from failed handoff output.""" output = load_fixture("q_cli_handoff_error.txt") - mock_tmux.get_history.return_value = output provider = QCliProvider("test1234", "test-session", "window-0", "supervisor") @@ -545,18 +512,16 @@ def test_handoff_error_message_extraction(self, mock_tmux): assert "\x1b[" not in message assert "error" in message.lower() or "unable" in message.lower() - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_with_permission_prompt(self, mock_tmux): + def test_handoff_with_permission_prompt(self): """Test WAITING_USER_ANSWER status during handoff requiring permission.""" - mock_tmux.get_history.return_value = load_fixture("q_cli_handoff_with_permission.txt") + output = load_fixture("q_cli_handoff_with_permission.txt") provider = QCliProvider("test1234", "test-session", "window-0", "supervisor") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.WAITING_USER_ANSWER - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_message_preserves_content(self, mock_tmux): + def test_handoff_message_preserves_content(self): """Test that handoff message extraction preserves all content without truncation.""" output = load_fixture("q_cli_handoff_successful.txt") @@ -570,8 +535,7 @@ def test_handoff_message_preserves_content(self, mock_tmux): # Verify it's not truncated or corrupted assert len(message.split()) >= 8 # Should have multiple words - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_handoff_indices_not_corrupted(self, mock_tmux): + def test_handoff_indices_not_corrupted(self): """Test that ANSI code cleaning doesn't corrupt index-based extraction.""" output = load_fixture("q_cli_handoff_successful.txt") @@ -598,15 +562,6 @@ def test_exit_cli_command(self): assert exit_cmd == "/exit" - def test_get_idle_pattern_for_log(self): - """Test idle pattern for log files.""" - provider = QCliProvider("test1234", "test-session", "window-0", "developer") - pattern = provider.get_idle_pattern_for_log() - - from cli_agent_orchestrator.providers.q_cli import IDLE_PROMPT_PATTERN_LOG - - assert pattern == IDLE_PROMPT_PATTERN_LOG - def test_cleanup(self): """Test cleanup method.""" provider = QCliProvider("test1234", "test-session", "window-0", "developer") @@ -616,62 +571,58 @@ def test_cleanup(self): assert provider._initialized is False - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_long_agent_profile_name(self, mock_tmux): + def test_long_agent_profile_name(self): """Test with very long agent profile name.""" long_profile = "very_long_agent_profile_name_that_exceeds_normal_length" - mock_tmux.get_history.return_value = f"\x1b[36m[{long_profile}]\x1b[35m>\x1b[39m " + output = f"\x1b[36m[{long_profile}]\x1b[35m>\x1b[39m " provider = QCliProvider("test1234", "test-session", "window-0", long_profile) - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.IDLE - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_output_with_unicode_characters(self, mock_tmux): + def test_output_with_unicode_characters(self): """Test handling of unicode characters in output.""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;10m> \x1b[39mResponse with unicode: 日本語 café naïve 🚀\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.COMPLETED # Test message extraction - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) assert "日本語" in message assert "café" in message assert "🚀" in message - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_output_with_control_characters(self, mock_tmux): + def test_output_with_control_characters(self): """Test handling of control characters.""" - mock_tmux.get_history.return_value = ( + output = ( "\x1b[38;5;10m> \x1b[39mResponse\x07with\x1bcontrol\x00chars\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - message = provider.extract_last_message_from_script(mock_tmux.get_history.return_value) + message = provider.extract_last_message_from_script(output) # Control characters should be cleaned assert "\x07" not in message # Bell assert "\x00" not in message # Null - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_multiple_error_indicators(self, mock_tmux): + def test_multiple_error_indicators(self): """Test detection with multiple error indicators.""" - mock_tmux.get_history.return_value = ( + output = ( "Amazon Q is having trouble responding right now\n" "Amazon Q is having trouble responding right now\n" "\x1b[36m[developer]\x1b[35m>\x1b[39m" ) provider = QCliProvider("test1234", "test-session", "window-0", "developer") - status = provider.get_status() + status = provider.get_status(output) assert status == TerminalStatus.ERROR @@ -684,8 +635,7 @@ def test_terminal_attributes(self): assert provider.window_name == "window-0" assert provider._agent_profile == "developer" - @patch("cli_agent_orchestrator.providers.q_cli.tmux_client") - def test_whitespace_variations_in_prompt(self, mock_tmux): + def test_whitespace_variations_in_prompt(self): """Test various whitespace scenarios in prompts.""" test_cases = [ "\x1b[36m[developer]\x1b[35m>\x1b[39m", @@ -697,6 +647,5 @@ def test_whitespace_variations_in_prompt(self, mock_tmux): provider = QCliProvider("test1234", "test-session", "window-0", "developer") for test_output in test_cases: - mock_tmux.get_history.return_value = test_output - status = provider.get_status() + status = provider.get_status(test_output) assert status == TerminalStatus.IDLE diff --git a/test/services/test_cleanup_service.py b/test/services/test_cleanup_service.py index f18c5570b..7bed0eea1 100644 --- a/test/services/test_cleanup_service.py +++ b/test/services/test_cleanup_service.py @@ -37,17 +37,25 @@ def test_cleanup_old_data_deletes_old_terminals( assert mock_db.query.called assert mock_db.commit.called + @patch("cli_agent_orchestrator.services.cleanup_service.status_monitor") + @patch("cli_agent_orchestrator.services.cleanup_service.fifo_manager") @patch("cli_agent_orchestrator.services.cleanup_service.SessionLocal") @patch("cli_agent_orchestrator.services.cleanup_service.TERMINAL_LOG_DIR") @patch("cli_agent_orchestrator.services.cleanup_service.LOG_DIR") @patch("cli_agent_orchestrator.services.cleanup_service.RETENTION_DAYS", 7) def test_cleanup_old_data_deletes_old_inbox_messages( - self, mock_log_dir, mock_terminal_log_dir, mock_session_local + self, + mock_log_dir, + mock_terminal_log_dir, + mock_session_local, + mock_fifo_manager, + mock_status_monitor, ): """Test that cleanup deletes old inbox messages from database.""" # Setup mock database session mock_db = MagicMock() mock_session_local.return_value.__enter__.return_value = mock_db + mock_db.query.return_value.filter.return_value.all.return_value = [] mock_db.query.return_value.filter.return_value.delete.return_value = 10 # Setup mock directories (non-existent) @@ -57,8 +65,10 @@ def test_cleanup_old_data_deletes_old_inbox_messages( # Execute cleanup_old_data() - # Verify inbox cleanup was called (query called twice - once for terminals, once for inbox) - assert mock_db.query.call_count == 2 + # Verify cleanup was called: + # Session 1: query.all() for terminal iteration + query.delete() for terminal deletion + # Session 2: query.delete() for inbox deletion + assert mock_db.query.call_count >= 2 assert mock_db.commit.call_count == 2 @patch("cli_agent_orchestrator.services.cleanup_service.SessionLocal") @@ -182,9 +192,13 @@ def test_cleanup_old_data_handles_empty_directories( # Verify database operations still occurred assert mock_db.query.called + @patch("cli_agent_orchestrator.services.cleanup_service.status_monitor") + @patch("cli_agent_orchestrator.services.cleanup_service.fifo_manager") @patch("cli_agent_orchestrator.services.cleanup_service.SessionLocal") @patch("cli_agent_orchestrator.services.cleanup_service.RETENTION_DAYS", 30) - def test_cleanup_uses_correct_retention_period(self, mock_session_local): + def test_cleanup_uses_correct_retention_period( + self, mock_session_local, mock_fifo_manager, mock_status_monitor + ): """Test that cleanup uses the configured retention period.""" mock_db = MagicMock() mock_session_local.return_value.__enter__.return_value = mock_db @@ -195,6 +209,7 @@ def test_cleanup_uses_correct_retention_period(self, mock_session_local): def capture_filter(condition): filter_calls.append(condition) mock_result = MagicMock() + mock_result.all.return_value = [] mock_result.delete.return_value = 0 return mock_result @@ -208,5 +223,5 @@ def capture_filter(condition): mock_log.exists.return_value = False cleanup_old_data() - # Verify filter was called (exact date comparison is tricky, just verify it was called) - assert len(filter_calls) == 2 # Once for terminals, once for inbox + # Verify filter was called (terminals: .all() + .delete(), inbox: .delete()) + assert len(filter_calls) >= 2 diff --git a/test/services/test_flow_service.py b/test/services/test_flow_service.py index f793d1639..bde4ee95a 100644 --- a/test/services/test_flow_service.py +++ b/test/services/test_flow_service.py @@ -353,12 +353,13 @@ def test_enable_flow_not_found(self, mock_db_get): class TestExecuteFlow: """Tests for execute_flow function.""" + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.send_input") @patch("cli_agent_orchestrator.services.flow_service.create_terminal") @patch("cli_agent_orchestrator.services.flow_service.generate_session_name") @patch("cli_agent_orchestrator.services.flow_service.db_update_flow_run_times") @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_without_script( + async def test_execute_flow_without_script( self, mock_db_get, mock_update_times, @@ -397,19 +398,20 @@ def test_execute_flow_without_script( mock_terminal.id = "terminal-123" mock_create_terminal.return_value = mock_terminal - result = execute_flow("simple-flow") + result = await execute_flow("simple-flow") assert result is True mock_create_terminal.assert_called_once() mock_send_input.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.subprocess.run") @patch("cli_agent_orchestrator.services.flow_service.send_input") @patch("cli_agent_orchestrator.services.flow_service.create_terminal") @patch("cli_agent_orchestrator.services.flow_service.generate_session_name") @patch("cli_agent_orchestrator.services.flow_service.db_update_flow_run_times") @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_with_script_execute_true( + async def test_execute_flow_with_script_execute_true( self, mock_db_get, mock_update_times, @@ -460,7 +462,7 @@ def test_execute_flow_with_script_execute_true( mock_terminal.id = "terminal-123" mock_create_terminal.return_value = mock_terminal - result = execute_flow("scripted-flow") + result = await execute_flow("scripted-flow") assert result is True mock_subprocess.assert_called_once() @@ -469,10 +471,11 @@ def test_execute_flow_with_script_execute_true( call_args = mock_send_input.call_args assert "42" in call_args[0][1] + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.subprocess.run") @patch("cli_agent_orchestrator.services.flow_service.db_update_flow_run_times") @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_with_script_execute_false( + async def test_execute_flow_with_script_execute_false( self, mock_db_get, mock_update_times, mock_subprocess ): """Test executing a flow with script that returns execute=false.""" @@ -511,21 +514,23 @@ def test_execute_flow_with_script_execute_false( stderr="", ) - result = execute_flow("skip-flow") + result = await execute_flow("skip-flow") assert result is False # Flow was skipped + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_not_found(self, mock_db_get): + async def test_execute_flow_not_found(self, mock_db_get): """Test executing a non-existent flow raises error.""" mock_db_get.return_value = None with pytest.raises(ValueError, match="Flow 'nonexistent' not found"): - execute_flow("nonexistent") + await execute_flow("nonexistent") + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.subprocess.run") @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_script_fails(self, mock_db_get, mock_subprocess): + async def test_execute_flow_script_fails(self, mock_db_get, mock_subprocess): """Test that script failure raises error.""" with tempfile.TemporaryDirectory() as tmpdir: flow_path = Path(tmpdir) / "flow.md" @@ -558,11 +563,12 @@ def test_execute_flow_script_fails(self, mock_db_get, mock_subprocess): mock_subprocess.return_value = MagicMock(returncode=1, stdout="", stderr="Script error") with pytest.raises(ValueError, match="Script failed"): - execute_flow("fail-flow") + await execute_flow("fail-flow") + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.flow_service.subprocess.run") @patch("cli_agent_orchestrator.services.flow_service.db_get_flow") - def test_execute_flow_script_invalid_json(self, mock_db_get, mock_subprocess): + async def test_execute_flow_script_invalid_json(self, mock_db_get, mock_subprocess): """Test that invalid JSON from script raises error.""" with tempfile.TemporaryDirectory() as tmpdir: flow_path = Path(tmpdir) / "flow.md" @@ -597,7 +603,7 @@ def test_execute_flow_script_invalid_json(self, mock_db_get, mock_subprocess): ) with pytest.raises(ValueError, match="not valid JSON"): - execute_flow("bad-json-flow") + await execute_flow("bad-json-flow") class TestGetFlowsToRun: diff --git a/test/services/test_inbox_service.py b/test/services/test_inbox_service.py index b242f83f1..e0573f7ef 100644 --- a/test/services/test_inbox_service.py +++ b/test/services/test_inbox_service.py @@ -1,252 +1,235 @@ -"""Tests for the inbox service.""" +"""Tests for the event-driven InboxService.""" -from pathlib import Path +import asyncio +from datetime import datetime from unittest.mock import MagicMock, patch import pytest -from cli_agent_orchestrator.models.inbox import MessageStatus +from cli_agent_orchestrator.models.inbox import InboxMessage, MessageStatus from cli_agent_orchestrator.models.terminal import TerminalStatus -from cli_agent_orchestrator.services.inbox_service import ( - LogFileHandler, - _get_log_tail, - _has_idle_pattern, - check_and_send_pending_messages, -) +from cli_agent_orchestrator.services.inbox_service import InboxService -class TestGetLogTail: - """Tests for _get_log_tail function.""" +def _make_message(id=1, receiver_id="term-1", message="hello", status=MessageStatus.PENDING): + return InboxMessage( + id=id, + sender_id="sender-1", + receiver_id=receiver_id, + message=message, + status=status, + created_at=datetime.now(), + ) - @patch("cli_agent_orchestrator.services.inbox_service.subprocess.run") - @patch("cli_agent_orchestrator.services.inbox_service.TERMINAL_LOG_DIR") - def test_get_log_tail_success(self, mock_log_dir, mock_run): - """Test getting log tail successfully.""" - mock_log_dir.__truediv__ = lambda self, x: Path("/tmp") / x - mock_run.return_value = MagicMock(stdout="last line\n") - result = _get_log_tail("test-terminal", lines=5) +class TestDeliverPending: + """Tests for InboxService.deliver_pending().""" - assert result == "last line\n" - mock_run.assert_called_once() - - @patch("cli_agent_orchestrator.services.inbox_service.subprocess.run") - @patch("cli_agent_orchestrator.services.inbox_service.TERMINAL_LOG_DIR") - def test_get_log_tail_exception(self, mock_log_dir, mock_run): - """Test getting log tail with exception.""" - mock_log_dir.__truediv__ = lambda self, x: Path("/tmp") / x - mock_run.side_effect = Exception("Subprocess error") - - result = _get_log_tail("test-terminal") - - assert result == "" - - -class TestHasIdlePattern: - """Tests for _has_idle_pattern function.""" - - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") - @patch("cli_agent_orchestrator.services.inbox_service._get_log_tail") - def test_has_idle_pattern_true(self, mock_tail, mock_provider_manager): - """Test idle pattern detection returns True.""" - mock_tail.return_value = "[developer]> " - mock_provider = MagicMock() - mock_provider.get_idle_pattern_for_log.return_value = r"\[developer\]>" - mock_provider_manager.get_provider.return_value = mock_provider - - result = _has_idle_pattern("test-terminal") - - assert result is True - - @patch("cli_agent_orchestrator.services.inbox_service._get_log_tail") - def test_has_idle_pattern_empty_tail(self, mock_tail): - """Test idle pattern detection with empty tail.""" - mock_tail.return_value = "" - - result = _has_idle_pattern("test-terminal") - - assert result is False - - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") - @patch("cli_agent_orchestrator.services.inbox_service._get_log_tail") - def test_has_idle_pattern_no_provider(self, mock_tail, mock_provider_manager): - """Test idle pattern detection with no provider.""" - mock_tail.return_value = "some content" - mock_provider_manager.get_provider.return_value = None - - result = _has_idle_pattern("test-terminal") - - assert result is False - - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") - @patch("cli_agent_orchestrator.services.inbox_service._get_log_tail") - def test_has_idle_pattern_exception(self, mock_tail, mock_provider_manager): - """Test idle pattern detection with exception.""" - mock_tail.return_value = "some content" - mock_provider_manager.get_provider.side_effect = Exception("Error") - - result = _has_idle_pattern("test-terminal") - - assert result is False - - -class TestCheckAndSendPendingMessages: - """Tests for check_and_send_pending_messages function.""" - - @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_no_pending_messages(self, mock_get_messages): - """Test when no pending messages exist.""" - mock_get_messages.return_value = [] - - result = check_and_send_pending_messages("test-terminal") - - assert result is False - - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") - @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_provider_not_found(self, mock_get_messages, mock_provider_manager): - """Test when provider not found.""" - mock_message = MagicMock() - mock_message.id = 1 - mock_message.message = "test message" - mock_get_messages.return_value = [mock_message] - mock_provider_manager.get_provider.return_value = None - - with pytest.raises(ValueError, match="Provider not found"): - check_and_send_pending_messages("test-terminal") - - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_terminal_not_ready(self, mock_get_messages, mock_provider_manager): - """Test when terminal not ready.""" - mock_message = MagicMock() - mock_get_messages.return_value = [mock_message] - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.PROCESSING - mock_provider_manager.get_provider.return_value = mock_provider + def test_delivers_message_when_idle(self, mock_get, mock_monitor, mock_term_svc, mock_update): + mock_get.return_value = [_make_message()] + mock_monitor.get_status.return_value = TerminalStatus.IDLE - result = check_and_send_pending_messages("test-terminal") + svc = InboxService() + svc.deliver_pending("term-1") - assert result is False + mock_term_svc.send_input.assert_called_once_with("term-1", "hello") + mock_update.assert_called_once_with(1, MessageStatus.DELIVERED) @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_message_sent_successfully( - self, mock_get_messages, mock_provider_manager, mock_terminal_service, mock_update_status + def test_delivers_message_when_completed( + self, mock_get, mock_monitor, mock_term_svc, mock_update ): - """Test successful message delivery.""" - mock_message = MagicMock() - mock_message.id = 1 - mock_message.message = "test message" - mock_get_messages.return_value = [mock_message] - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.IDLE - mock_provider_manager.get_provider.return_value = mock_provider + mock_get.return_value = [_make_message()] + mock_monitor.get_status.return_value = TerminalStatus.COMPLETED - result = check_and_send_pending_messages("test-terminal") + svc = InboxService() + svc.deliver_pending("term-1") - assert result is True - mock_terminal_service.send_input.assert_called_once_with("test-terminal", "test message") - mock_update_status.assert_called_once_with(1, MessageStatus.DELIVERED) + mock_term_svc.send_input.assert_called_once_with("term-1", "hello") + mock_update.assert_called_once_with(1, MessageStatus.DELIVERED) @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") - @patch("cli_agent_orchestrator.services.inbox_service.provider_manager") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_message_send_failure( - self, mock_get_messages, mock_provider_manager, mock_terminal_service, mock_update_status + def test_skips_when_no_pending_messages( + self, mock_get, mock_monitor, mock_term_svc, mock_update ): - """Test message delivery failure.""" - mock_message = MagicMock() - mock_message.id = 1 - mock_message.message = "test message" - mock_get_messages.return_value = [mock_message] - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.IDLE - mock_provider_manager.get_provider.return_value = mock_provider - mock_terminal_service.send_input.side_effect = Exception("Send failed") - - with pytest.raises(Exception, match="Send failed"): - check_and_send_pending_messages("test-terminal") - - mock_update_status.assert_called_once_with(1, MessageStatus.FAILED) + mock_get.return_value = [] + svc = InboxService() + svc.deliver_pending("term-1") -class TestLogFileHandler: - """Tests for LogFileHandler class.""" + mock_term_svc.send_input.assert_not_called() + mock_update.assert_not_called() - @patch("cli_agent_orchestrator.services.inbox_service.check_and_send_pending_messages") - @patch("cli_agent_orchestrator.services.inbox_service._has_idle_pattern") + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_on_modified_triggers_delivery(self, mock_get_messages, mock_has_idle, mock_check_send): - """Test on_modified triggers message delivery.""" - from watchdog.events import FileModifiedEvent - - mock_get_messages.return_value = [MagicMock()] - mock_has_idle.return_value = True + def test_skips_when_processing(self, mock_get, mock_monitor, mock_term_svc, mock_update): + mock_get.return_value = [_make_message()] + mock_monitor.get_status.return_value = TerminalStatus.PROCESSING - handler = LogFileHandler() - event = FileModifiedEvent("/path/to/test-terminal.log") + svc = InboxService() + svc.deliver_pending("term-1") - handler.on_modified(event) - - mock_check_send.assert_called_once_with("test-terminal") + mock_term_svc.send_input.assert_not_called() + mock_update.assert_not_called() + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_handle_log_change_no_pending_messages(self, mock_get_messages): - """Test _handle_log_change with no pending messages (covers lines 105-107).""" - mock_get_messages.return_value = [] + def test_skips_when_unknown(self, mock_get, mock_monitor, mock_term_svc, mock_update): + mock_get.return_value = [_make_message()] + mock_monitor.get_status.return_value = TerminalStatus.UNKNOWN - handler = LogFileHandler() + svc = InboxService() + svc.deliver_pending("term-1") - # Should return early - covers lines 105-107 - handler._handle_log_change("test-terminal") + mock_term_svc.send_input.assert_not_called() + mock_update.assert_not_called() - mock_get_messages.assert_called_once_with("test-terminal", limit=1) - - @patch("cli_agent_orchestrator.services.inbox_service._has_idle_pattern") + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_handle_log_change_not_idle(self, mock_get_messages, mock_has_idle): - """Test _handle_log_change when terminal not idle (covers lines 110-114).""" - mock_get_messages.return_value = [MagicMock()] - mock_has_idle.return_value = False - - handler = LogFileHandler() - - # Should return early - covers lines 110-114 - handler._handle_log_change("test-terminal") - - mock_has_idle.assert_called_once_with("test-terminal") + def test_delivers_multiple_messages_concatenated( + self, mock_get, mock_monitor, mock_term_svc, mock_update + ): + msgs = [_make_message(id=1, message="hello"), _make_message(id=2, message="world")] + mock_get.return_value = msgs + mock_monitor.get_status.return_value = TerminalStatus.IDLE - def test_on_modified_non_log_file(self): - """Test on_modified ignores non-log files.""" - from watchdog.events import FileModifiedEvent + svc = InboxService() + svc.deliver_pending("term-1", num_messages=2) - handler = LogFileHandler() - # Create a non-.log file event - event = MagicMock(spec=FileModifiedEvent) - event.src_path = "/path/to/test-terminal.txt" + mock_get.assert_called_once_with("term-1", limit=2) + mock_term_svc.send_input.assert_called_once_with("term-1", "hello\nworld") + assert mock_update.call_count == 2 - # Should not process non-log files - handler.on_modified(event) + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") + @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") + def test_delivers_all_when_num_messages_zero( + self, mock_get, mock_monitor, mock_term_svc, mock_update + ): + msgs = [_make_message(id=i, message=f"msg{i}") for i in range(3)] + mock_get.return_value = msgs + mock_monitor.get_status.return_value = TerminalStatus.IDLE - def test_on_modified_not_file_modified_event(self): - """Test on_modified ignores non-FileModifiedEvent.""" - handler = LogFileHandler() - event = MagicMock() # Not a FileModifiedEvent - event.src_path = "/path/to/test-terminal.log" + svc = InboxService() + svc.deliver_pending("term-1", num_messages=0) - # Should not process non-FileModifiedEvent - handler.on_modified(event) + mock_get.assert_called_once_with("term-1", limit=100) + mock_term_svc.send_input.assert_called_once_with("term-1", "msg0\nmsg1\nmsg2") + assert mock_update.call_count == 3 + @patch("cli_agent_orchestrator.services.inbox_service.update_message_status") + @patch("cli_agent_orchestrator.services.inbox_service.terminal_service") + @patch("cli_agent_orchestrator.services.inbox_service.status_monitor") @patch("cli_agent_orchestrator.services.inbox_service.get_pending_messages") - def test_handle_log_change_exception(self, mock_get_messages): - """Test _handle_log_change handles exceptions (covers line 119-120).""" - mock_get_messages.side_effect = Exception("Database error") - - handler = LogFileHandler() - - # Should not raise exception - handles it gracefully - handler._handle_log_change("test-terminal") + def test_marks_failed_on_send_error(self, mock_get, mock_monitor, mock_term_svc, mock_update): + mock_get.return_value = [_make_message()] + mock_monitor.get_status.return_value = TerminalStatus.IDLE + mock_term_svc.send_input.side_effect = RuntimeError("tmux error") + + svc = InboxService() + svc.deliver_pending("term-1") + + mock_update.assert_called_once_with(1, MessageStatus.FAILED) + + +class TestRun: + """Tests for InboxService.run() event loop.""" + + @pytest.mark.asyncio + async def test_processes_idle_status_event(self): + svc = InboxService() + svc.deliver_pending = MagicMock() + + queue = asyncio.Queue() + await queue.put( + { + "topic": "terminal.abc123.status", + "data": {"status": TerminalStatus.IDLE.value}, + } + ) + + with patch("cli_agent_orchestrator.services.inbox_service.bus") as mock_bus: + mock_bus.subscribe.return_value = queue + + # Run one iteration then cancel + async def run_one(): + task = asyncio.create_task(svc.run()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + await run_one() + + svc.deliver_pending.assert_called_once_with("abc123") + + @pytest.mark.asyncio + async def test_processes_completed_status_event(self): + svc = InboxService() + svc.deliver_pending = MagicMock() + + queue = asyncio.Queue() + await queue.put( + { + "topic": "terminal.xyz789.status", + "data": {"status": TerminalStatus.COMPLETED.value}, + } + ) + + with patch("cli_agent_orchestrator.services.inbox_service.bus") as mock_bus: + mock_bus.subscribe.return_value = queue + + task = asyncio.create_task(svc.run()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + svc.deliver_pending.assert_called_once_with("xyz789") + + @pytest.mark.asyncio + async def test_ignores_processing_status_event(self): + svc = InboxService() + svc.deliver_pending = MagicMock() + + queue = asyncio.Queue() + await queue.put( + { + "topic": "terminal.abc123.status", + "data": {"status": TerminalStatus.PROCESSING.value}, + } + ) + + with patch("cli_agent_orchestrator.services.inbox_service.bus") as mock_bus: + mock_bus.subscribe.return_value = queue + + task = asyncio.create_task(svc.run()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + svc.deliver_pending.assert_not_called() diff --git a/test/services/test_session_service.py b/test/services/test_session_service.py index 6a899b51b..afe90d778 100644 --- a/test/services/test_session_service.py +++ b/test/services/test_session_service.py @@ -105,12 +105,22 @@ def test_get_session_error(self, mock_tmux): class TestDeleteSession: """Tests for delete_session function.""" - @patch("cli_agent_orchestrator.services.session_service.delete_terminals_by_session") - @patch("cli_agent_orchestrator.services.session_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") + @patch("cli_agent_orchestrator.services.terminal_service.db_delete_terminal") + @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") @patch("cli_agent_orchestrator.services.session_service.list_terminals_by_session") @patch("cli_agent_orchestrator.services.session_service.tmux_client") def test_delete_session_success( - self, mock_tmux, mock_list_terminals, mock_provider_manager, mock_delete_terminals + self, + mock_tmux, + mock_list_terminals, + mock_get_metadata, + mock_provider_manager, + mock_db_delete, + mock_fifo_manager, + mock_status_monitor, ): """Test deleting session successfully.""" mock_tmux.session_exists.return_value = True @@ -118,13 +128,16 @@ def test_delete_session_success( {"id": "terminal1"}, {"id": "terminal2"}, ] + mock_get_metadata.return_value = { + "tmux_session": "cao-test", + "tmux_window": "window", + } + mock_db_delete.return_value = True result = delete_session("cao-test") assert result is True mock_tmux.kill_session.assert_called_once_with("cao-test") - mock_delete_terminals.assert_called_once_with("cao-test") - assert mock_provider_manager.cleanup_provider.call_count == 2 @patch("cli_agent_orchestrator.services.session_service.tmux_client") def test_delete_session_not_found(self, mock_tmux): @@ -134,13 +147,9 @@ def test_delete_session_not_found(self, mock_tmux): with pytest.raises(ValueError, match="Session 'cao-nonexistent' not found"): delete_session("cao-nonexistent") - @patch("cli_agent_orchestrator.services.session_service.delete_terminals_by_session") - @patch("cli_agent_orchestrator.services.session_service.provider_manager") @patch("cli_agent_orchestrator.services.session_service.list_terminals_by_session") @patch("cli_agent_orchestrator.services.session_service.tmux_client") - def test_delete_session_no_terminals( - self, mock_tmux, mock_list_terminals, mock_provider_manager, mock_delete_terminals - ): + def test_delete_session_no_terminals(self, mock_tmux, mock_list_terminals): """Test deleting session with no terminals.""" mock_tmux.session_exists.return_value = True mock_list_terminals.return_value = [] @@ -148,7 +157,7 @@ def test_delete_session_no_terminals( result = delete_session("cao-test") assert result is True - mock_provider_manager.cleanup_provider.assert_not_called() + mock_tmux.kill_session.assert_called_once_with("cao-test") @patch("cli_agent_orchestrator.services.session_service.list_terminals_by_session") @patch("cli_agent_orchestrator.services.session_service.tmux_client") diff --git a/test/services/test_terminal_service_full.py b/test/services/test_terminal_service_full.py index db96e9be8..c176899ba 100644 --- a/test/services/test_terminal_service_full.py +++ b/test/services/test_terminal_service_full.py @@ -1,7 +1,7 @@ """Full tests for terminal service.""" from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -20,14 +20,17 @@ class TestCreateTerminal: """Tests for create_terminal function.""" - @patch("cli_agent_orchestrator.services.terminal_service.TERMINAL_LOG_DIR") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") + @patch("cli_agent_orchestrator.services.terminal_service.FIFO_DIR") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") @patch("cli_agent_orchestrator.services.terminal_service.db_create_terminal") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.generate_window_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_session_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_terminal_id") - def test_create_terminal_new_session( + async def test_create_terminal_new_session( self, mock_gen_id, mock_gen_session, @@ -35,32 +38,37 @@ def test_create_terminal_new_session( mock_tmux, mock_db_create, mock_provider_manager, - mock_log_dir, + mock_fifo_dir, + mock_fifo_manager, + mock_status_monitor, ): """Test creating terminal with new session.""" mock_gen_id.return_value = "test1234" mock_gen_session.return_value = "cao-session" mock_gen_window.return_value = "developer-abcd" mock_tmux.session_exists.return_value = False - mock_provider = MagicMock() + mock_provider = AsyncMock() + mock_provider.initialize.return_value = True mock_provider_manager.create_provider.return_value = mock_provider - mock_log_path = MagicMock() - mock_log_dir.__truediv__.return_value = mock_log_path + mock_fifo_dir.__truediv__ = MagicMock(return_value="fake.fifo") - result = create_terminal("kiro_cli", "developer", new_session=True) + result = await create_terminal("kiro_cli", "developer", new_session=True) assert result.id == "test1234" mock_tmux.create_session.assert_called_once() mock_provider.initialize.assert_called_once() - @patch("cli_agent_orchestrator.services.terminal_service.TERMINAL_LOG_DIR") + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") + @patch("cli_agent_orchestrator.services.terminal_service.FIFO_DIR") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") @patch("cli_agent_orchestrator.services.terminal_service.db_create_terminal") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.generate_window_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_session_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_terminal_id") - def test_create_terminal_existing_session( + async def test_create_terminal_existing_session( self, mock_gen_id, mock_gen_session, @@ -68,7 +76,9 @@ def test_create_terminal_existing_session( mock_tmux, mock_db_create, mock_provider_manager, - mock_log_dir, + mock_fifo_dir, + mock_fifo_manager, + mock_status_monitor, ): """Test creating terminal in existing session.""" mock_gen_id.return_value = "test1234" @@ -76,21 +86,22 @@ def test_create_terminal_existing_session( mock_gen_window.return_value = "developer-abcd" mock_tmux.session_exists.return_value = True mock_tmux.create_window.return_value = "developer-abcd" - mock_provider = MagicMock() + mock_provider = AsyncMock() + mock_provider.initialize.return_value = True mock_provider_manager.create_provider.return_value = mock_provider - mock_log_path = MagicMock() - mock_log_dir.__truediv__.return_value = mock_log_path + mock_fifo_dir.__truediv__ = MagicMock(return_value="fake.fifo") - result = create_terminal("kiro_cli", "developer", session_name="cao-existing") + result = await create_terminal("kiro_cli", "developer", session_name="cao-existing") assert result.id == "test1234" mock_tmux.create_window.assert_called_once() + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.generate_window_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_session_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_terminal_id") - def test_create_terminal_session_not_found( + async def test_create_terminal_session_not_found( self, mock_gen_id, mock_gen_session, mock_gen_window, mock_tmux ): """Test creating terminal when session not found.""" @@ -100,13 +111,14 @@ def test_create_terminal_session_not_found( mock_tmux.session_exists.return_value = False with pytest.raises(ValueError, match="not found"): - create_terminal("kiro_cli", "developer", session_name="cao-nonexistent") + await create_terminal("kiro_cli", "developer", session_name="cao-nonexistent") + @pytest.mark.asyncio @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.generate_window_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_session_name") @patch("cli_agent_orchestrator.services.terminal_service.generate_terminal_id") - def test_create_terminal_session_already_exists( + async def test_create_terminal_session_already_exists( self, mock_gen_id, mock_gen_session, mock_gen_window, mock_tmux ): """Test creating terminal when session already exists.""" @@ -116,15 +128,17 @@ def test_create_terminal_session_already_exists( mock_tmux.session_exists.return_value = True with pytest.raises(ValueError, match="already exists"): - create_terminal("kiro_cli", "developer", session_name="cao-existing", new_session=True) + await create_terminal( + "kiro_cli", "developer", session_name="cao-existing", new_session=True + ) class TestGetTerminal: """Tests for get_terminal function.""" - @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") - def test_get_terminal_success(self, mock_get_metadata, mock_provider_manager): + def test_get_terminal_success(self, mock_get_metadata, mock_status_monitor): """Test getting terminal successfully.""" mock_get_metadata.return_value = { "id": "test1234", @@ -134,9 +148,7 @@ def test_get_terminal_success(self, mock_get_metadata, mock_provider_manager): "agent_profile": "developer", "last_active": datetime.now(), } - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.IDLE - mock_provider_manager.get_provider.return_value = mock_provider + mock_status_monitor.get_status.return_value = TerminalStatus.IDLE result = get_terminal("test1234") @@ -151,10 +163,10 @@ def test_get_terminal_not_found(self, mock_get_metadata): with pytest.raises(ValueError, match="not found"): get_terminal("nonexistent") - @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") - def test_get_terminal_no_provider(self, mock_get_metadata, mock_provider_manager): - """Test getting terminal when provider not found.""" + def test_get_terminal_no_provider(self, mock_get_metadata, mock_status_monitor): + """Test getting terminal returns status from status_monitor.""" mock_get_metadata.return_value = { "id": "test1234", "tmux_window": "developer-abcd", @@ -163,10 +175,11 @@ def test_get_terminal_no_provider(self, mock_get_metadata, mock_provider_manager "agent_profile": "developer", "last_active": datetime.now(), } - mock_provider_manager.get_provider.return_value = None + mock_status_monitor.get_status.return_value = TerminalStatus.UNKNOWN - with pytest.raises(ValueError, match="Provider not found"): - get_terminal("test1234") + result = get_terminal("test1234") + + assert result["status"] == TerminalStatus.UNKNOWN.value class TestGetWorkingDirectory: @@ -231,33 +244,35 @@ def test_send_input_not_found(self, mock_get_metadata): class TestGetOutput: """Tests for get_output function.""" + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") - def test_get_output_full(self, mock_get_metadata, mock_tmux): + def test_get_output_full(self, mock_get_metadata, mock_tmux, mock_status_monitor): """Test getting full output.""" mock_get_metadata.return_value = { "tmux_session": "cao-session", "tmux_window": "developer-abcd", } - mock_tmux.get_history.return_value = "full terminal output" + mock_status_monitor.get_buffer.return_value = "full terminal output" result = get_output("test1234", OutputMode.FULL) assert result == "full terminal output" @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") - def test_get_output_last(self, mock_get_metadata, mock_tmux, mock_provider_manager): + def test_get_output_last(self, mock_get_metadata, mock_tmux, mock_status_monitor, mock_pm): """Test getting last message.""" mock_get_metadata.return_value = { "tmux_session": "cao-session", "tmux_window": "developer-abcd", } - mock_tmux.get_history.return_value = "full terminal output" + mock_status_monitor.get_buffer.return_value = "full terminal output" mock_provider = MagicMock() mock_provider.extract_last_message_from_script.return_value = "last message" - mock_provider_manager.get_provider.return_value = mock_provider + mock_pm.get_provider.return_value = mock_provider result = get_output("test1234", OutputMode.LAST) @@ -272,16 +287,19 @@ def test_get_output_not_found(self, mock_get_metadata): get_output("nonexistent") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") - def test_get_output_last_no_provider(self, mock_get_metadata, mock_tmux, mock_provider_manager): + def test_get_output_last_no_provider( + self, mock_get_metadata, mock_tmux, mock_status_monitor, mock_pm + ): """Test getting last message when provider not found.""" mock_get_metadata.return_value = { "tmux_session": "cao-session", "tmux_window": "developer-abcd", } - mock_tmux.get_history.return_value = "full output" - mock_provider_manager.get_provider.return_value = None + mock_status_monitor.get_buffer.return_value = "full output" + mock_pm.get_provider.return_value = None with pytest.raises(ValueError, match="Provider not found"): get_output("test1234", OutputMode.LAST) @@ -290,12 +308,20 @@ def test_get_output_last_no_provider(self, mock_get_metadata, mock_tmux, mock_pr class TestDeleteTerminal: """Tests for delete_terminal function.""" + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") @patch("cli_agent_orchestrator.services.terminal_service.db_delete_terminal") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") def test_delete_terminal_success( - self, mock_get_metadata, mock_tmux, mock_provider_manager, mock_db_delete + self, + mock_get_metadata, + mock_tmux, + mock_provider_manager, + mock_db_delete, + mock_fifo_manager, + mock_status_monitor, ): """Test deleting terminal successfully.""" mock_get_metadata.return_value = { @@ -310,12 +336,20 @@ def test_delete_terminal_success( mock_tmux.stop_pipe_pane.assert_called_once() mock_provider_manager.cleanup_provider.assert_called_once_with("test1234") + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") @patch("cli_agent_orchestrator.services.terminal_service.db_delete_terminal") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") @patch("cli_agent_orchestrator.services.terminal_service.tmux_client") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") def test_delete_terminal_pipe_pane_error( - self, mock_get_metadata, mock_tmux, mock_provider_manager, mock_db_delete + self, + mock_get_metadata, + mock_tmux, + mock_provider_manager, + mock_db_delete, + mock_fifo_manager, + mock_status_monitor, ): """Test deleting terminal when stop_pipe_pane fails.""" mock_get_metadata.return_value = { @@ -330,11 +364,18 @@ def test_delete_terminal_pipe_pane_error( assert result is True + @patch("cli_agent_orchestrator.services.terminal_service.status_monitor") + @patch("cli_agent_orchestrator.services.terminal_service.fifo_manager") @patch("cli_agent_orchestrator.services.terminal_service.db_delete_terminal") @patch("cli_agent_orchestrator.services.terminal_service.provider_manager") @patch("cli_agent_orchestrator.services.terminal_service.get_terminal_metadata") def test_delete_terminal_no_metadata( - self, mock_get_metadata, mock_provider_manager, mock_db_delete + self, + mock_get_metadata, + mock_provider_manager, + mock_db_delete, + mock_fifo_manager, + mock_status_monitor, ): """Test deleting terminal when metadata not found.""" mock_get_metadata.return_value = None diff --git a/test/utils/test_terminal.py b/test/utils/test_terminal.py index ea7265bb2..10ede30ab 100644 --- a/test/utils/test_terminal.py +++ b/test/utils/test_terminal.py @@ -1,5 +1,6 @@ """Tests for terminal utilities.""" +import asyncio from unittest.mock import MagicMock, patch import pytest @@ -60,43 +61,44 @@ def test_generate_window_name_unique(self): class TestWaitForShell: """Tests for wait_for_shell function.""" - def test_wait_for_shell_success(self): - """Test successful shell wait.""" - mock_tmux = MagicMock() - # Return same output twice to indicate shell is ready - mock_tmux.get_history.side_effect = ["prompt $", "prompt $"] + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_for_shell_success(self, mock_monitor): + """Test successful shell wait - buffer is non-empty and stable.""" + mock_monitor.get_buffer.return_value = "prompt $" - result = wait_for_shell( - mock_tmux, "test-session", "window-0", timeout=2.0, polling_interval=0.1 + result = await wait_for_shell( + "test-terminal", timeout=2.0, stable_duration=0.3, polling_interval=0.1 ) assert result is True - def test_wait_for_shell_timeout(self): - """Test shell wait timeout.""" - mock_tmux = MagicMock() - # Return different outputs each time + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_for_shell_timeout(self, mock_monitor): + """Test shell wait timeout - buffer keeps changing.""" call_count = [0] - def get_history_side_effect(*args, **kwargs): + def get_buffer_side_effect(terminal_id): call_count[0] += 1 return f"output {call_count[0]}" - mock_tmux.get_history.side_effect = get_history_side_effect + mock_monitor.get_buffer.side_effect = get_buffer_side_effect - result = wait_for_shell( - mock_tmux, "test-session", "window-0", timeout=0.5, polling_interval=0.1 + result = await wait_for_shell( + "test-terminal", timeout=0.5, stable_duration=0.3, polling_interval=0.1 ) assert result is False - def test_wait_for_shell_empty_output(self): + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_for_shell_empty_output(self, mock_monitor): """Test shell wait with empty output.""" - mock_tmux = MagicMock() - mock_tmux.get_history.return_value = "" + mock_monitor.get_buffer.return_value = "" - result = wait_for_shell( - mock_tmux, "test-session", "window-0", timeout=0.5, polling_interval=0.1 + result = await wait_for_shell( + "test-terminal", timeout=0.5, stable_duration=0.3, polling_interval=0.1 ) assert result is False @@ -105,35 +107,38 @@ def test_wait_for_shell_empty_output(self): class TestWaitUntilStatus: """Tests for wait_until_status function.""" - def test_wait_until_status_success(self): + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_until_status_success(self, mock_monitor): """Test successful status wait.""" - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.IDLE + mock_monitor.get_status.return_value = TerminalStatus.IDLE - result = wait_until_status( - mock_provider, TerminalStatus.IDLE, timeout=1.0, polling_interval=0.1 + result = await wait_until_status( + "test-terminal", TerminalStatus.IDLE, timeout=1.0, polling_interval=0.1 ) assert result is True - def test_wait_until_status_timeout(self): + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_until_status_timeout(self, mock_monitor): """Test status wait timeout.""" - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.PROCESSING + mock_monitor.get_status.return_value = TerminalStatus.PROCESSING - result = wait_until_status( - mock_provider, TerminalStatus.IDLE, timeout=0.5, polling_interval=0.1 + result = await wait_until_status( + "test-terminal", TerminalStatus.IDLE, timeout=0.5, polling_interval=0.1 ) assert result is False - def test_wait_until_status_with_set(self): + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_until_status_with_set(self, mock_monitor): """Test status wait accepts a set of target statuses.""" - mock_provider = MagicMock() - mock_provider.get_status.return_value = TerminalStatus.COMPLETED + mock_monitor.get_status.return_value = TerminalStatus.COMPLETED - result = wait_until_status( - mock_provider, + result = await wait_until_status( + "test-terminal", {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=1.0, polling_interval=0.1, @@ -141,18 +146,18 @@ def test_wait_until_status_with_set(self): assert result is True - def test_wait_until_status_eventually_succeeds(self): + @pytest.mark.asyncio + @patch("cli_agent_orchestrator.services.status_monitor.status_monitor") + async def test_wait_until_status_eventually_succeeds(self, mock_monitor): """Test status wait that eventually succeeds.""" - mock_provider = MagicMock() - # First few calls return PROCESSING, then IDLE - mock_provider.get_status.side_effect = [ + mock_monitor.get_status.side_effect = [ TerminalStatus.PROCESSING, TerminalStatus.PROCESSING, TerminalStatus.IDLE, ] - result = wait_until_status( - mock_provider, TerminalStatus.IDLE, timeout=2.0, polling_interval=0.1 + result = await wait_until_status( + "test-terminal", TerminalStatus.IDLE, timeout=2.0, polling_interval=0.1 ) assert result is True @@ -161,7 +166,7 @@ def test_wait_until_status_eventually_succeeds(self): class TestWaitUntilTerminalStatus: """Tests for wait_until_terminal_status function.""" - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_success(self, mock_get): """Test successful terminal status wait.""" mock_response = MagicMock() @@ -175,7 +180,7 @@ def test_wait_until_terminal_status_success(self, mock_get): assert result is True - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_timeout(self, mock_get): """Test terminal status wait timeout.""" mock_response = MagicMock() @@ -189,7 +194,7 @@ def test_wait_until_terminal_status_timeout(self, mock_get): assert result is False - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_api_error(self, mock_get): """Test terminal status wait with API error.""" mock_get.side_effect = Exception("Connection error") @@ -200,7 +205,7 @@ def test_wait_until_terminal_status_api_error(self, mock_get): assert result is False - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_non_200(self, mock_get): """Test terminal status wait with non-200 response.""" mock_response = MagicMock() @@ -213,7 +218,7 @@ def test_wait_until_terminal_status_non_200(self, mock_get): assert result is False - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_multi_status_set(self, mock_get): """Test waiting for multiple target statuses (set).""" mock_response = MagicMock() @@ -230,7 +235,7 @@ def test_wait_until_terminal_status_multi_status_set(self, mock_get): assert result is True - @patch("cli_agent_orchestrator.utils.terminal.httpx.get") + @patch("cli_agent_orchestrator.utils.terminal.requests.get") def test_wait_until_terminal_status_multi_status_no_match(self, mock_get): """Test multi-status wait times out when status doesn't match any target.""" mock_response = MagicMock() diff --git a/uv.lock b/uv.lock index cf93fd3d4..a32469cd8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,16 +1,7 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" -[[package]] -name = "aiofiles" -version = "24.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -326,7 +317,6 @@ name = "cli-agent-orchestrator" version = "1.1.0" source = { editable = "." } dependencies = [ - { name = "aiofiles" }, { name = "apscheduler" }, { name = "click" }, { name = "fastapi" }, @@ -338,7 +328,6 @@ dependencies = [ { name = "requests" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, - { name = "watchdog" }, { name = "websockets" }, ] @@ -356,7 +345,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiofiles", specifier = ">=24.1.0" }, { name = "apscheduler", specifier = ">=3.10.4" }, { name = "click", specifier = ">=8.0.0" }, { name = "fastapi", specifier = ">=0.104.0" }, @@ -368,7 +356,6 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, - { name = "watchdog", specifier = "==6.0.0" }, { name = "websockets", specifier = ">=12.0" }, ] @@ -2300,38 +2287,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "watchfiles" version = "1.1.0"