From fbd1d709ca2c4177a0cafb00b19535c3eb82158e Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:01:08 +0100 Subject: [PATCH] feat: add WebUI mode support with related configuration and onboarding (#252) * feat: add WebUI mode support with related configuration and onboarding steps * feat: enhance WebUI port configuration to prevent conflicts with backend port * feat: add support for fresh interactive session detection in WebUI --- EvoScientist/cli/commands.py | 41 ++- EvoScientist/cli/tui_runtime.py | 6 +- EvoScientist/config/onboard/constants.py | 2 +- EvoScientist/config/onboard/steps.py | 73 ++++- EvoScientist/config/onboard/wizard.py | 6 + EvoScientist/config/settings.py | 11 +- EvoScientist/deploy/webui.py | 325 +++++++++++++++++++++++ tests/test_cli_tui_dispatch.py | 74 ++++++ tests/test_onboard.py | 6 +- 9 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 EvoScientist/deploy/webui.py diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 172eff1..e10466e 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -81,7 +81,9 @@ def onboard( "--show-thinking/--no-show-thinking", help="Pre-set thinking-panel visibility", ), - ui: str | None = typer.Option(None, "--ui", help="Pre-set UI backend (tui | cli)"), + ui: str | None = typer.Option( + None, "--ui", help="Pre-set UI backend (tui | cli | webui)" + ), port: int | None = typer.Option( None, "--port", help="Pre-set langgraph dev server port" ), @@ -1740,6 +1742,17 @@ def _version_callback(value: bool): raise typer.Exit() +def _is_fresh_interactive_session(prompt: str | None, thread_id: str | None) -> bool: + """True for a brand-new interactive session — no one-shot ``-p`` prompt and + no ``--resume`` / ``--thread-id`` to continue. + + This is the only case where a WebUI-configured ``EvoSci`` opens the browser + app: a one-shot or a resume has a concrete conversation to render in the + terminal, so it falls back to the Rich CLI instead. + """ + return not prompt and not thread_id + + @app.callback(invoke_without_command=True) def _main_callback( ctx: typer.Context, @@ -1804,7 +1817,7 @@ def _main_callback( ui: str | None = typer.Option( None, "--ui", - help="UI backend: tui (default) or cli.", + help="UI backend: tui (default), cli, or webui.", ), ): """EvoScientist Agent - AI-powered research & code execution CLI""" @@ -1871,8 +1884,8 @@ def _main_callback( if mode and mode not in ("run", "daemon"): raise typer.BadParameter("--mode must be 'run' or 'daemon'") - if ui and ui.lower() not in ("cli", "tui"): - raise typer.BadParameter("--ui must be 'tui' or 'cli'") + if ui and ui.lower() not in ("cli", "tui", "webui"): + raise typer.BadParameter("--ui must be 'tui', 'cli', or 'webui'") # --name only makes sense in run mode if name and not ( @@ -1956,6 +1969,26 @@ def _main_callback( # Ensure memory and skills subdirs exist in workspace ensure_dirs() + # WebUI mode: instead of the in-terminal CLI/TUI, run a deploy-style + # langgraph server (full MCP + async) + the published @evoscientist/webui + # front-end (npx) in THIS terminal, then block. Reuses start_langgraph_dev + # but leaves `EvoSci deploy` untouched (it stays a clean server for external + # UIs / SDK clients). + # + # The browser app is only launched for a FRESH interactive session. With + # `-p` (one-shot) or `--resume`/`--thread-id` (continue a specific + # conversation), there is concrete terminal output to render, so fall back + # to the Rich CLI instead of opening the browser UI. + from .tui_runtime import normalize_ui_backend + + if normalize_ui_backend(config.ui_backend) == "webui": + if _is_fresh_interactive_session(prompt, thread_id): + from ..deploy.webui import run_webui + + run_webui(config, workspace_dir=workspace_dir) + return + config.ui_backend = "cli" + # Auto-start langgraph dev (after workspace resolution, so deployed # async sub-agents inherit the CLI's workspace via EVOSCIENTIST_WORKSPACE_DIR). _ensure_async_subagent_server(config, workspace_dir=workspace_dir) diff --git a/EvoScientist/cli/tui_runtime.py b/EvoScientist/cli/tui_runtime.py index 2553317..8a95d05 100644 --- a/EvoScientist/cli/tui_runtime.py +++ b/EvoScientist/cli/tui_runtime.py @@ -9,7 +9,11 @@ from ..stream.console import console from .tui_backends import RichStreamingBackend, StreamingTUIBackend DEFAULT_UI_BACKEND = "cli" -SUPPORTED_UI_BACKENDS = ("cli", "tui") +# "webui" launches the browser front-end instead of an in-terminal UI; it is +# intercepted earlier (cli/commands.py:_main_callback) and never reaches the +# streaming backends, but is listed here so normalize/resolve preserve it +# rather than falling back to "cli". +SUPPORTED_UI_BACKENDS = ("cli", "tui", "webui") _LEGACY_BACKEND_MAP = {"textual": "tui", "rich": "cli"} diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py index c574d98..924287e 100644 --- a/EvoScientist/config/onboard/constants.py +++ b/EvoScientist/config/onboard/constants.py @@ -35,7 +35,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset( } ) -VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli"}) +VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli", "webui"}) VALID_WORKSPACE_MODES: frozenset[str] = frozenset({"daemon", "run"}) diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 3528a7a..248084c 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -43,23 +43,24 @@ from .validators import validate_tavily_key def _step_ui_backend(config: EvoScientistConfig) -> str: - """Step 0: Select UI backend (Rich CLI or Textual TUI). + """Step 0: Select UI backend (Textual TUI, Rich CLI, or browser WebUI). Args: config: Current configuration. Returns: - Selected backend name ("tui" or "cli"). + Selected backend name ("tui", "cli", or "webui"). """ choices = [ Choice(title="TUI (full-screen interface, recommended)", value="tui"), Choice(title="CLI (classic terminal, lightweight)", value="cli"), + Choice(title="WebUI (browser interface, beta)", value="webui"), ] # Map legacy values to current ones _legacy_map = {"textual": "tui", "rich": "cli"} default_backend = _legacy_map.get(config.ui_backend, config.ui_backend) - if default_backend not in ("tui", "cli"): + if default_backend not in ("tui", "cli", "webui"): default_backend = "tui" backend = questionary.select( @@ -161,6 +162,72 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int: return port +def _step_webui_port(config: EvoScientistConfig) -> int: + """Step 0.6: Choose the local TCP port for the WebUI front-end. + + Only asked when ``ui_backend == "webui"``. This is the Next.js server port + the browser opens (``@evoscientist/webui``); the backend keeps its own + ``langgraph_dev_port``. Mirrors the langgraph-dev port prompt's UX. + + Returns the chosen port; caller assigns it to ``config.webui_port``. + """ + from ...langgraph_dev.manager import _is_port_occupied + + current_port = getattr(config, "webui_port", 4716) + backend_port = getattr(config, "langgraph_dev_port", 6174) + occupied = _is_port_occupied(current_port) + conflicts_backend = current_port == backend_port + + # Bake live availability into the label (same single-paren style as the + # langgraph-dev / ccproxy prompts) so the status shows WITH the question. + # The WebUI port must also differ from the backend (langgraph dev) port — + # run_webui refuses equal ports at startup, so reject them here too instead + # of saving a config that fails to launch later. + if occupied or conflicts_backend: + reason = "occupied" if occupied else f"= backend port {backend_port}" + prompt_label = ( + f"Enter port for WebUI server " + f"(Current: {current_port}, {reason}, pick another):" + ) + else: + prompt_label = ( + f"Enter port for WebUI server " + f"(Current: {current_port}, available, Enter to keep):" + ) + + def valid_port(value: str) -> bool: + if not value: + # Keep the default only if it's free AND not the backend port. + return not occupied and not conflicts_backend + try: + port = int(value) + except (ValueError, TypeError): + return False + if not (1024 < port < 65536): + return False + if port == backend_port: + return False + return not _is_port_occupied(port) + + raw = questionary.text( + prompt_label, + validate=valid_port, + style=WIZARD_STYLE, + qmark=QMARK, + ).ask() + + if raw is None: + raise KeyboardInterrupt() + + port = int(raw) if raw else current_port + console.print(f" [green]✓ WebUI will open at http://localhost:{port}[/green]") + console.print( + " [yellow]⚠️ Beta: the WebUI won't show your CLI/TUI chat history " + "yet.[/yellow]" + ) + return port + + def _step_provider(config: EvoScientistConfig) -> str: """Step 1: Select LLM provider. diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 3132c18..8e74efe 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -33,6 +33,7 @@ from .steps import ( _step_thinking, _step_tinytex, _step_ui_backend, + _step_webui_port, _step_workspace, ) from .style import ( @@ -421,6 +422,11 @@ def run_onboard( ) else: config.ui_backend = _step_ui_backend(config) + # WebUI mode needs a front-end port; ask right after the mode + # choice (only when chosen interactively — non-interactive / + # preset runs keep the config default). + if config.ui_backend == "webui" and not strict and preset_ui is None: + config.webui_port = _step_webui_port(config) _autosave(config) if "port" in sections_to_run: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index b421c6c..39cdf42 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -114,6 +114,12 @@ class EvoScientistConfig: # 2024. Override if it conflicts with another local service. langgraph_dev_port: int = 6174 + # Port for the WebUI front-end (Next.js server from @evoscientist/webui), + # used only when ui_backend == "webui". 4716 is 6174 reversed — a memorable + # pairing with the langgraph dev port that it connects to. The backend keeps + # its own port (langgraph_dev_port); this is just the browser server. + webui_port: int = 4716 + # Whether langgraph dev persists its runtime state to .langgraph_api/ next # to the subprocess cwd. True (default) keeps async-task, scheduler, and # Store API state across subprocess restarts — useful for future @@ -149,7 +155,9 @@ class EvoScientistConfig: # UI Settings show_thinking: bool = True - ui_backend: Literal["cli", "tui"] = "tui" + # "webui" launches the browser front-end (@evoscientist/webui via npx) + + # a deploy-style langgraph server instead of the in-terminal CLI/TUI. + ui_backend: Literal["cli", "tui", "webui"] = "tui" log_level: str = "warning" reasoning_effort: str = "high" @@ -503,6 +511,7 @@ _ENV_MAPPINGS = { "checkpoint_keep_per_thread": "EVOSCIENTIST_CHECKPOINT_KEEP_PER_THREAD", "enable_async_subagents": "EVOSCIENTIST_ENABLE_ASYNC_SUBAGENTS", "langgraph_dev_port": "EVOSCIENTIST_LANGGRAPH_DEV_PORT", + "webui_port": "EVOSCIENTIST_WEBUI_PORT", "code_interpreter_timeout": "EVOSCIENTIST_CODE_INTERPRETER_TIMEOUT", "code_interpreter_max_result_chars": "EVOSCIENTIST_CODE_INTERPRETER_MAX_RESULT_CHARS", "sandbox_execute_timeout": "EVOSCIENTIST_SANDBOX_EXECUTE_TIMEOUT", diff --git a/EvoScientist/deploy/webui.py b/EvoScientist/deploy/webui.py new file mode 100644 index 0000000..a3d4c83 --- /dev/null +++ b/EvoScientist/deploy/webui.py @@ -0,0 +1,325 @@ +"""``EvoSci`` WebUI mode — deploy-style LangGraph server + browser front-end. + +Selected via ``ui_backend = "webui"`` (onboard → "Select UI mode" → WebUI). +Running ``EvoSci`` then becomes, in ONE terminal: + + EvoSci deploy + npx @evoscientist/webui + +i.e. start a *full* langgraph dev server (MCP + async sub-agents, exactly like +``EvoSci deploy``) AND launch the published ``@evoscientist/webui`` Next.js +front-end via ``npx``, so the user never needs two terminals. + +Design boundary: this module deliberately REUSES the low-level +``start_langgraph_dev`` primitive but does **not** import, call, or modify the +``deploy`` command. ``EvoSci deploy`` stays a clean, opinionated standalone +server for *external* consumers (deep-agents-ui, agent-chat-ui, LangSmith +Studio, SDK clients); WebUI mode is a separate, parallel launcher. + +``npx @evoscientist/webui@latest`` is used (not a pinned version) so each launch +transparently pulls the newest published UI — front-end fixes ship to users +without touching the EvoScientist install. The trade-off: the first launch (and +the first launch after a new release) downloads the package and needs network; +subsequent launches reuse the npm cache. +""" + +from __future__ import annotations + +import atexit +import os +import shutil +import signal +import subprocess +import threading +from pathlib import Path +from typing import Any + +import typer # type: ignore[import-untyped] +from rich.panel import Panel +from rich.text import Text + +from ..stream.console import console + +# Front-end npm package + spec. ``@latest`` → always the newest published UI. +_WEBUI_PACKAGE = "@evoscientist/webui@latest" +_DEFAULT_WEBUI_PORT = 4716 + + +def run_webui(config: Any, workspace_dir: str | None = None) -> None: + """Start the deploy-style backend + the WebUI front-end, then block. + + Args: + config: Effective ``EvoScientistConfig`` (already env-applied upstream, + but re-applied here so this is safe to call standalone). + workspace_dir: Resolved workspace path; falls back to + ``config.default_workdir`` then cwd. + + Blocks until Ctrl+C / SIGTERM, or until the front-end process exits, then + tears down both subprocesses. Never returns a value. + """ + from ..config import apply_config_to_env + from ..langgraph_dev.manager import ( + _DEFAULT_PORT, + _LOG_FILE, + _is_port_occupied, + _read_workspace_sidecar, + is_langgraph_dev_running, + start_langgraph_dev, + stop_langgraph_dev, + ) + + apply_config_to_env(config) + + # 1. Resolve workspace (CLI-resolved value > config.default_workdir > cwd), + # mirroring `EvoSci deploy`. The langgraph dev subprocess inherits this via + # EVOSCIENTIST_WORKSPACE_DIR (set inside start_langgraph_dev). + if workspace_dir: + ws = os.path.abspath(os.path.expanduser(workspace_dir)) + elif getattr(config, "default_workdir", ""): + ws = os.path.abspath(os.path.expanduser(config.default_workdir)) + else: + ws = os.getcwd() + os.makedirs(ws, exist_ok=True) + + # 2. Resolve ports: backend = langgraph dev (browser connects here), + # webui_port = the local Next.js server the browser actually opens. + backend_port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT)) + webui_port = int(getattr(config, "webui_port", _DEFAULT_WEBUI_PORT)) + for label, p in (("langgraph dev", backend_port), ("WebUI", webui_port)): + if not (1 <= p <= 65535): + console.print( + f"[red]Invalid {label} port {p}. Use an integer in [1, 65535].[/red]" + ) + raise typer.Exit(1) + if webui_port == backend_port: + # Same port → the backend would claim it first and npx would fail to + # bind. Catch it here with a clear message instead of a cryptic error. + console.print( + f"[red]WebUI port and langgraph dev port must differ " + f"(both are {webui_port}).[/red]" + ) + console.print( + "[dim]Change one with [bold]EvoSci config set webui_port " + "[/bold].[/dim]" + ) + raise typer.Exit(1) + + # 3. Pre-flight the npx front-end requirement BEFORE starting the server, + # so a missing Node toolchain fails fast with actionable guidance. + npx = shutil.which("npx") + if not npx: + console.print( + Panel( + Text.from_markup( + "[bold]Node.js / npx was not found on PATH.[/bold]\n\n" + "The WebUI front-end ships as the npm package " + "[cyan]@evoscientist/webui[/cyan] and is launched with " + "[bold]npx[/bold].\n\n" + "Install [bold]Node.js 24 LTS[/bold] (which includes npx), " + "then re-run [bold]EvoSci[/bold] — or switch UI modes with " + "[bold]EvoSci config set ui_backend tui[/bold]." + ), + title="[bold red]WebUI unavailable[/bold red]", + border_style="red", + ) + ) + raise typer.Exit(1) + + # 4. Backend (langgraph dev): reuse an EvoSci server already on the port, + # else start a fresh deploy-mode one (full MCP + async). Refuse a foreign + # occupant — that's a configuration error, not something to silently share. + started_proc = None + if _is_port_occupied(backend_port): + if is_langgraph_dev_running(port=backend_port): + # Reuse an existing EvoSci server only when it serves THIS workspace + # — mirror the sidecar guard in ensure_langgraph_dev so WebUI started + # from workspace B never silently binds to a server pinned to + # workspace A. No sidecar (older subprocess) → reuse, as before. + sidecar = _read_workspace_sidecar() + if ( + sidecar is not None + and Path(sidecar["workspace"]).resolve() != Path(ws).resolve() + ): + console.print( + f"[red]Port {backend_port} is already serving a langgraph " + f"dev for a different workspace " + f"({_shorten(sidecar['workspace'])}).[/red]" + ) + console.print( + f"[dim]Stop that EvoSci session, or launch from that " + f"workspace ([bold]--workdir {sidecar['workspace']}[/bold])." + f"[/dim]" + ) + raise typer.Exit(1) + console.print( + f"[green]✓[/green] Reusing langgraph dev already serving " + f"port {backend_port}" + ) + else: + console.print( + f"[red]Port {backend_port} is occupied by another process.[/red]" + ) + console.print( + f"[dim]Free it (lsof -i :{backend_port}) or change it with " + f"[bold]EvoSci config set langgraph_dev_port [/bold].[/dim]" + ) + raise typer.Exit(1) + else: + jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10)) + file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True)) + try: + with console.status( + "[dim]Starting langgraph dev (deploy mode: MCP + async)...[/dim]", + spinner="dots", + ): + started_proc = start_langgraph_dev( + workspace_dir=Path(ws), + port=backend_port, + file_persistence=file_persistence, + jobs_per_worker=jobs_per_worker, + deploy_mode=True, + ) + atexit.register(stop_langgraph_dev, started_proc) + except Exception as exc: + console.print(f"[red]langgraph dev startup failed:[/red] {exc}") + raise typer.Exit(1) from exc + console.print("[green]✓[/green] langgraph dev ready") + + if _is_port_occupied(webui_port): + console.print( + f"[yellow]⚠ Port {webui_port} is already in use; the WebUI server " + f"may fail to start. Change it with " + f"[bold]EvoSci config set webui_port [/bold].[/yellow]" + ) + + # 5. Launch the front-end via npx in its own process group so the whole + # tree (npx → node → next server) tears down cleanly on shutdown. The + # package's own launcher prints progress and opens the browser; stdio is + # inherited so it all shows in THIS terminal. EVOSCIENTIST_LANGGRAPH_DEV_PORT + # lets the UI's config prefill point at our backend automatically. Secrets + # are scrubbed — the browser UI never needs LLM provider API keys. + webui_env = _scrubbed_env( + { + "EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port), + "PORT": str(webui_port), + } + ) + console.print( + Panel( + Text.from_markup( + f"[bold]Backend:[/bold] http://localhost:{backend_port} " + f"[dim](langgraph dev — Assistant: EvoScientist)[/dim]\n" + f"[bold]WebUI:[/bold] http://localhost:{webui_port} " + f"[dim](opens in your browser)[/dim]\n" + f"[bold]Logs:[/bold] {_shorten(str(_LOG_FILE))}\n\n" + f"[dim]Fetching {_WEBUI_PACKAGE} via npx (first run may take a " + f"moment)… Press Ctrl+C to stop.[/dim]" + ), + title="[bold green]✓ EvoScientist WebUI[/bold green]", + border_style="green", + ) + ) + + popen_kwargs: dict[str, Any] = {"env": webui_env} + if os.name == "posix": + popen_kwargs["start_new_session"] = True + elif os.name == "nt": + # New process group so the npx → node → next subtree can be killed as a + # unit by taskkill /T in _stop_webui. + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + try: + webui_proc = subprocess.Popen( + [npx, "--yes", _WEBUI_PACKAGE, "--port", str(webui_port)], + **popen_kwargs, + ) + except Exception as exc: + console.print(f"[red]Failed to launch WebUI via npx:[/red] {exc}") + raise typer.Exit(1) from exc + atexit.register(_stop_webui, webui_proc) + + # 6. Block on signal — same dual-gate as `EvoSci deploy` (threading.Event + + # explicit SIGINT/SIGTERM handlers). Also exit if the front-end dies on its + # own (e.g. the user closes it), so we don't leave the backend orphaned. + shutdown_event = threading.Event() + + def _handle_shutdown(signum: int, _frame: Any) -> None: + shutdown_event.set() + if signum == signal.SIGINT: + signal.default_int_handler(signum, _frame) + + _orig_sigint = signal.signal(signal.SIGINT, _handle_shutdown) + _orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown) + + try: + while not shutdown_event.is_set(): + if webui_proc.poll() is not None: + console.print("\n[dim]WebUI server exited.[/dim]") + break + shutdown_event.wait(timeout=0.5) + except KeyboardInterrupt: + shutdown_event.set() + finally: + signal.signal(signal.SIGINT, _orig_sigint) + signal.signal(signal.SIGTERM, _orig_sigterm) + _stop_webui(webui_proc) + # stop_langgraph_dev (if we started it) runs via atexit during + # interpreter shutdown — don't claim "Stopped." before that fires. + console.print( + "\n[dim]Shutting down (background cleanup may take a few seconds)...[/dim]" + ) + + +def _stop_webui(proc: subprocess.Popen) -> None: + """Terminate the WebUI process tree (idempotent).""" + if proc.poll() is not None: + return + try: + if os.name == "posix": + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + elif os.name == "nt": + # taskkill /T terminates the whole child tree (node + next server). + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + proc.terminate() + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def _scrubbed_env(extra: dict[str, str]) -> dict[str, str]: + """Inherit the parent environment minus secrets, then apply ``extra``. + + The WebUI is a browser client that only talks to the local langgraph server + — it has no use for LLM provider API keys. Stripping credential-bearing + variables keeps them out of the npx-fetched front-end package and its + transitive npm dependencies (defence-in-depth, especially with ``@latest``). + Names are matched loosely (``*_KEY`` / ``*API_KEY*`` / ``*TOKEN*`` / + ``*SECRET*`` / ``*PASSWORD*``); node/npm essentials (PATH, HOME, NODE_*, + npm_*, proxies, CA certs) carry none of these and pass through untouched. + """ + secret_hints = ("API_KEY", "TOKEN", "SECRET", "PASSWORD") + env = { + k: v + for k, v in os.environ.items() + if not ( + k.upper().endswith("_KEY") + or any(hint in k.upper() for hint in secret_hints) + ) + } + env.update(extra) + return env + + +def _shorten(path: str) -> str: + """Replace ``$HOME`` prefix with ``~`` for compact display.""" + home = os.path.expanduser("~") + if path.startswith(home): + return "~" + path[len(home) :] + return path diff --git a/tests/test_cli_tui_dispatch.py b/tests/test_cli_tui_dispatch.py index 862bf4d..5351fa6 100644 --- a/tests/test_cli_tui_dispatch.py +++ b/tests/test_cli_tui_dispatch.py @@ -1,8 +1,82 @@ """Tests for CLI interactive UI backend dispatch.""" +import pytest + +from EvoScientist.cli.commands import _is_fresh_interactive_session from EvoScientist.cli.interactive import cmd_interactive +@pytest.mark.parametrize( + ("prompt", "thread_id", "expected"), + [ + (None, None, True), # bare `EvoSci` → fresh → WebUI launches + ("what is 1+1", None, False), # `-p` one-shot → terminal (Rich CLI) + (None, "47bcffcd", False), # `--resume ` → terminal (Rich CLI) + ("hi", "47bcffcd", False), # both → terminal + ("", None, True), # empty `-p` is falsy → treated as fresh + ], +) +def test_is_fresh_interactive_session(prompt, thread_id, expected): + """WebUI only launches for a fresh interactive session; `-p` / `--resume` + fall back to the terminal.""" + assert _is_fresh_interactive_session(prompt, thread_id) is expected + + +def _invoke_main(monkeypatch, argv): + """Invoke the EvoSci main callback with ui_backend=webui and all heavy setup + mocked. Returns (calls, result): calls["dispatch"] is "webui" if run_webui + ran, or ("cli", ) if cmd_interactive ran.""" + from typer.testing import CliRunner + + import EvoScientist.cli.commands as cmds + import EvoScientist.cli.interactive as interactive_mod + import EvoScientist.config as cfg_mod + import EvoScientist.deploy.webui as webui_mod + from EvoScientist.cli._app import app + from EvoScientist.config.settings import EvoScientistConfig + + calls: dict[str, object] = {} + + def _fake_config(overrides): + cfg = EvoScientistConfig() + # Mirror the real --ui override; default to webui for this test. + cfg.ui_backend = overrides.get("ui_backend") or "webui" + return cfg + + monkeypatch.setattr(cfg_mod, "get_effective_config", _fake_config) + monkeypatch.setattr(cfg_mod, "apply_config_to_env", lambda cfg: None) + monkeypatch.setattr(cmds, "ensure_dirs", lambda: None) + monkeypatch.setattr(cmds, "_ensure_async_subagent_server", lambda *a, **k: None) + monkeypatch.setattr( + webui_mod, + "run_webui", + lambda *a, **k: calls.__setitem__("dispatch", "webui"), + ) + monkeypatch.setattr( + interactive_mod, + "cmd_interactive", + lambda **kw: calls.__setitem__("dispatch", ("cli", kw.get("ui_backend"))), + ) + + result = CliRunner().invoke(app, argv, catch_exceptions=False) + return calls, result + + +def test_main_callback_launches_webui_for_fresh_session(monkeypatch): + """Bare `EvoSci` with ui_backend=webui opens the browser app.""" + calls, result = _invoke_main(monkeypatch, []) + assert result.exit_code == 0 + assert calls.get("dispatch") == "webui" + + +def test_main_callback_resume_falls_back_to_cli(monkeypatch): + """`EvoSci --resume ` with ui_backend=webui does NOT open the browser; + it resumes the conversation in the Rich CLI (ui_backend forced to 'cli').""" + calls, result = _invoke_main(monkeypatch, ["--resume", "abc123"]) + assert result.exit_code == 0 + assert calls.get("dispatch") == ("cli", "cli") + + def test_cmd_interactive_dispatches_to_textual(monkeypatch): captured: dict[str, object] = {} diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 8eebc32..0e99a15 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -126,9 +126,9 @@ class TestSharedConstantsAlignment: def test_ui_constants_match_step_choices(self): from EvoScientist.config.onboard.constants import VALID_UI_BACKENDS - # _step_ui_backend hard-codes "tui" and "cli" — small enough to - # check by direct lookup against the canonical set. - assert VALID_UI_BACKENDS == frozenset({"tui", "cli"}) + # _step_ui_backend hard-codes "tui", "cli", and "webui" — small enough + # to check by direct lookup against the canonical set. + assert VALID_UI_BACKENDS == frozenset({"tui", "cli", "webui"}) def test_workspace_mode_constants_match_step_choices(self): from EvoScientist.config.onboard.constants import VALID_WORKSPACE_MODES