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
This commit is contained in:
Xi Zhang
2026-06-01 12:01:08 +01:00
committed by GitHub
parent 3ce6523faf
commit fbd1d709ca
9 changed files with 531 additions and 13 deletions
+37 -4
View File
@@ -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)
+5 -1
View File
@@ -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"}
+1 -1
View File
@@ -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"})
+70 -3
View File
@@ -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.
+6
View File
@@ -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:
+10 -1
View File
@@ -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",
+325
View File
@@ -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 <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 <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 <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
+74
View File
@@ -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 <id>` → 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", <ui_backend>) 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 <id>` 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] = {}
+3 -3
View File
@@ -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