diff --git a/features/steps/actor_run_signature_cli_steps.py b/features/steps/actor_run_signature_cli_steps.py index da67b7cd7..cb1befb5a 100644 --- a/features/steps/actor_run_signature_cli_steps.py +++ b/features/steps/actor_run_signature_cli_steps.py @@ -18,7 +18,7 @@ from behave import then, when from cleveragents.cli.commands.actor import app as actor_app from cleveragents.cli.commands.actor_run import app as actor_run_app -from features.steps.actor_run_signature_resolve_steps import ( +from features.steps.actor_run_signature_helpers import ( _get_combined_output, _make_app, ) diff --git a/features/steps/actor_run_signature_helpers.py b/features/steps/actor_run_signature_helpers.py new file mode 100644 index 000000000..43c32c98f --- /dev/null +++ b/features/steps/actor_run_signature_helpers.py @@ -0,0 +1,34 @@ +"""Shared helper utilities for actor run signature step definitions. + +This module contains pure helper functions (no Behave step definitions) +that are shared between ``actor_run_signature_resolve_steps.py`` and +``actor_run_signature_cli_steps.py``. Keeping helpers in a separate +module prevents double-registration of step definitions when Behave +loads all step files and one file imports from another. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + + +def _make_app( + *, + result: str, + config_global_context: dict[str, Any] | None = None, +) -> MagicMock: + """Create a mock ReactiveCleverAgentsApp for testing.""" + app_exec = MagicMock() + app_exec.config = type( + "_Config", (), {"global_context": dict(config_global_context or {})} + )() + app_exec.run_single_shot = AsyncMock(return_value=result) + return app_exec + + +def _get_combined_output(result: Any) -> str: + """Return combined stdout + stderr from a CliRunner result.""" + output = result.output or "" + stderr = getattr(result, "stderr", None) or "" + return output + stderr diff --git a/features/steps/actor_run_signature_resolve_steps.py b/features/steps/actor_run_signature_resolve_steps.py index ab6bc56c8..4e0cbbb58 100644 --- a/features/steps/actor_run_signature_resolve_steps.py +++ b/features/steps/actor_run_signature_resolve_steps.py @@ -11,51 +11,25 @@ from __future__ import annotations import contextlib import tempfile from pathlib import Path -from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import click import typer import yaml from behave import then, when +from cleveragents.core.exceptions import InfrastructureError, NotFoundError + with contextlib.suppress(ImportError, ModuleNotFoundError): from cleveragents.cli.commands._resolve_actor import ( _cleanup_temp_files, _temp_files, resolve_config_files, ) -from cleveragents.core.exceptions import InfrastructureError, NotFoundError - -# --------------------------------------------------------------------------- -# Shared helpers (also imported by actor_run_signature_cli_steps) -# --------------------------------------------------------------------------- - - -def _make_app( - *, - result: str, - config_global_context: dict[str, Any] | None = None, -) -> MagicMock: - app_exec = MagicMock() - app_exec.config = SimpleNamespace(global_context=dict(config_global_context or {})) - app_exec.run_single_shot = AsyncMock(return_value=result) - return app_exec - - -def _get_combined_output(result: Any) -> str: - """Return combined stdout + stderr from a CliRunner result.""" - output = result.output or "" - stderr = getattr(result, "stderr", None) or "" - return output + stderr # --------------------------------------------------------------------------- -# resolve_config_files unit tests (P2-3 / P2-4) -# --------------------------------------------------------------------------- - - @when("I call resolve_config_files with a config list") def step_resolve_with_config_list(context: Any) -> None: cfg = [Path(tempfile.gettempdir()) / "dummy.yaml"] @@ -167,7 +141,9 @@ def step_resolve_with_no_config_data(context: Any) -> None: context.resolve_exit_code = 0 except (SystemExit, click.exceptions.Exit) as exc: context.resolve_exit_code = getattr( - exc, "exit_code", getattr(exc, "code", 1) + exc, + "exit_code", + getattr(exc, "code", 1), ) context.resolve_stderr = " ".join(captured_stderr) @@ -211,7 +187,9 @@ def step_resolve_unknown_actor_directly(context: Any) -> None: context.resolve_exit_code = 0 except (SystemExit, click.exceptions.Exit) as exc: context.resolve_exit_code = getattr( - exc, "exit_code", getattr(exc, "code", 1) + exc, + "exit_code", + getattr(exc, "code", 1), ) context.resolve_stderr = " ".join(captured_stderr) @@ -262,7 +240,9 @@ def step_resolve_with_empty_config_blob(context: Any) -> None: context.resolve_exit_code = 0 except (SystemExit, click.exceptions.Exit) as exc: context.resolve_exit_code = getattr( - exc, "exit_code", getattr(exc, "code", 1) + exc, + "exit_code", + getattr(exc, "code", 1), ) context.resolve_stderr = " ".join(captured_stderr) @@ -304,7 +284,9 @@ def step_resolve_with_empty_name(context: Any) -> None: context.empty_name_exit_code = 0 except (SystemExit, typer.Exit) as exc: context.empty_name_exit_code = getattr( - exc, "exit_code", getattr(exc, "code", 1) + exc, + "exit_code", + getattr(exc, "code", 1), ) context.resolve_stderr = " ".join(captured_stderr) @@ -358,7 +340,9 @@ def step_resolve_with_unserializable_config_blob(context: Any) -> None: context.resolve_exit_code = 0 except (SystemExit, click.exceptions.Exit) as exc: context.resolve_exit_code = getattr( - exc, "exit_code", getattr(exc, "code", 1) + exc, + "exit_code", + getattr(exc, "code", 1), ) context.resolve_stderr = " ".join(captured_stderr) diff --git a/features/steps/tui_persona_cycle_steps.py b/features/steps/tui_persona_cycle_steps.py new file mode 100644 index 000000000..e1465ad4c --- /dev/null +++ b/features/steps/tui_persona_cycle_steps.py @@ -0,0 +1,42 @@ +"""Behave steps for TUI persona cycling. + +Steps specific to the persona cycle feature. Steps shared with +``tui_persona_system_steps.py`` (e.g. registry setup, set_active_persona, +active_persona assertion) are defined there and reused here. +""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + + +def _registry_for_temp_dir(path: Path) -> PersonaRegistry: + return PersonaRegistry(config_dir=path) + + +@given('I save TUI persona "{name}" with actor "{actor}" and cycle order {cycle:d}') +def step_save_persona_cycle( + context: Context, name: str, actor: str, cycle: int +) -> None: + persona = Persona(name=name, actor=actor, cycle_order=cycle) + context.tui_registry.save(persona) + + +@when('I cycle persona for session "{session_id}"') +def step_cycle_persona(context: Context, session_id: str) -> None: + if not hasattr(context, "tui_state"): + context.tui_state = PersonaState(registry=context.tui_registry) + context.tui_state.cycle_persona(session_id) + + +@then('the registry last persona should be set to "{persona_name}"') +def step_registry_last_persona(context: Context, persona_name: str) -> None: + last = context.tui_registry.get_last_persona() + assert last == persona_name diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..b4bc71662 100644 --- a/features/steps/tui_persona_state_coverage_steps.py +++ b/features/steps/tui_persona_state_coverage_steps.py @@ -200,7 +200,7 @@ def step_set_unknown_persona(context, name, session_id): context.caught_error = exc -@then('tpscov a ValueError should be raised with message containing "{fragment}"') +@then('a persona ValueError should be raised with message containing "{fragment}"') def step_verify_value_error(context, fragment): assert context.caught_error is not None, "Expected ValueError but none was raised" assert fragment in str(context.caught_error), ( @@ -236,7 +236,7 @@ def step_verify_session_active_persona(context, session_id, expected): assert context.state.active_by_session[session_id] == expected -@then('the registry last persona should be set to "{expected}"') +@then('the mock registry last persona should be set to "{expected}"') def step_verify_last_persona_set(context, expected): context.mock_registry.set_last_persona.assert_called_with(expected) diff --git a/features/tui_persona_cycle.feature b/features/tui_persona_cycle.feature new file mode 100644 index 000000000..926f13a06 --- /dev/null +++ b/features/tui_persona_cycle.feature @@ -0,0 +1,51 @@ +Feature: TUI Persona Cycling + Personas can be cycled through in order using cycle_order field. + + Scenario: cycle_persona cycles through personas with cycle_order > 0 + Given a temporary TUI persona registry + And I save TUI persona "first" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "second" with actor "local/mock-default" and cycle order 2 + And I save TUI persona "third" with actor "local/mock-default" and cycle order 3 + When I set active persona to "first" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "second" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "third" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "first" + + Scenario: cycle_persona returns current persona when no cyclic personas exist + Given a temporary TUI persona registry + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "noncyclic" + + Scenario: cycle_persona starts from first when current is not in cycle + Given a temporary TUI persona registry + And I save TUI persona "cyclic1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "cyclic1" + + Scenario: cycle_persona respects cycle_order field ordering + Given a temporary TUI persona registry + And I save TUI persona "alpha" with actor "local/mock-default" and cycle order 3 + And I save TUI persona "beta" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "gamma" with actor "local/mock-default" and cycle order 2 + When I set active persona to "beta" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "gamma" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "alpha" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "beta" + + Scenario: cycle_persona updates last persona in registry + Given a temporary TUI persona registry + And I save TUI persona "p1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "p2" with actor "local/mock-default" and cycle order 2 + When I set active persona to "p1" for session "s1" + And I cycle persona for session "s1" + Then the registry last persona should be set to "p2" diff --git a/features/tui_persona_state_coverage.feature b/features/tui_persona_state_coverage.feature index 5141c737b..6c5227ebf 100644 --- a/features/tui_persona_state_coverage.feature +++ b/features/tui_persona_state_coverage.feature @@ -27,13 +27,13 @@ Feature: TUI Persona State Coverage Scenario: set_active_persona raises ValueError for unknown persona When I try to set an unknown persona "ghost" for session "sess-5" - Then tpscov a ValueError should be raised with message containing "Unknown persona" + Then a persona ValueError should be raised with message containing "Unknown persona" Scenario: set_active_persona sets and returns a known persona When I set persona "coder" for session "sess-6" Then the returned persona name should be "coder" And session "sess-6" should have active persona "coder" - And the registry last persona should be set to "coder" + And the mock registry last persona should be set to "coder" Scenario: set_active_persona skips preset init when session already has one Given the preset for session "sess-6b" is already set to "turbo" diff --git a/src/cleveragents/cli/commands/actor.py b/src/cleveragents/cli/commands/actor.py index 8cbc2ab5b..dde96e901 100644 --- a/src/cleveragents/cli/commands/actor.py +++ b/src/cleveragents/cli/commands/actor.py @@ -185,7 +185,7 @@ def run( except UnsafeConfigurationError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(code=1) from exc - except click.exceptions.Exit: + except (click.exceptions.Exit, typer.Exit): raise except CleverAgentsError as exc: typer.echo(f"Error: {exc}", err=True) diff --git a/src/cleveragents/cli/commands/actor_run.py b/src/cleveragents/cli/commands/actor_run.py index 14b2d2cf2..0131bd692 100644 --- a/src/cleveragents/cli/commands/actor_run.py +++ b/src/cleveragents/cli/commands/actor_run.py @@ -159,7 +159,7 @@ def run( except UnsafeConfigurationError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(code=1) from exc - except click.exceptions.Exit: + except (click.exceptions.Exit, typer.Exit): raise except CleverAgentsError as exc: typer.echo(f"Error: {exc}", err=True) diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py index 0ad83620f..0a7f262b6 100644 --- a/src/cleveragents/cli/commands/tui.py +++ b/src/cleveragents/cli/commands/tui.py @@ -18,9 +18,23 @@ def tui_callback( help="Run a one-shot headless startup check instead of full UI loop.", ), ] = False, + web: Annotated[ + bool, + typer.Option( + "--web", + help="Launch TUI in web mode accessible via browser.", + ), + ] = False, + web_port: Annotated[ + int, + typer.Option( + "--web-port", + help="Port for web server (default: 8000).", + ), + ] = 8000, ) -> None: """Launch the CleverAgents TUI.""" # Import lazily so non-TUI commands avoid Textual startup cost. from cleveragents.tui.commands import run_tui - raise typer.Exit(run_tui(headless=headless)) + raise typer.Exit(run_tui(headless=headless, web=web, web_port=web_port)) diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index d155ef726..fc1062879 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json from collections import defaultdict from collections.abc import Callable @@ -223,8 +224,145 @@ class TuiCommandRouter: return f"Import failed: {exc}" -def run_tui(*, headless: bool = False) -> int: - """Run the Textual TUI app or a headless startup check.""" +def _get_tui_web_html(port: int) -> str: + """Generate HTML for TUI web mode. + + Parameters + ---------- + port: + Port number for the web server. + + Returns + ------- + HTML content as string. + """ + return """ + + + + + CleverAgents TUI + + + +
+
Loading CleverAgents TUI...
+
+ + +""" + + +def _run_tui_web(app: Any, *, port: int = 8000) -> int: + """Run the TUI app in web mode via HTTP server. + + Parameters + ---------- + app: + The Textual TUI app instance. + port: + Port for the web server. + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ + try: + # Import web server dependencies + import threading + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + + _port = port + + class TuiWebHandler(BaseHTTPRequestHandler): + """HTTP request handler for TUI web mode.""" + + def do_GET(self) -> None: + """Handle GET requests.""" + if self.path == "/" or self.path == "/index.html": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = _get_tui_web_html(_port) + self.wfile.write(html.encode("utf-8")) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + """Suppress default logging.""" + + # Create and start HTTP server + server = HTTPServer(("127.0.0.1", port), TuiWebHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Print startup message + url = f"http://127.0.0.1:{port}" + print(f"TUI Web mode started at {url}") + print("Press Ctrl+C to stop") + + # Try to open browser + with contextlib.suppress(Exception): + webbrowser.open(url) + + # Run the app in headless mode (web driver will handle rendering) + try: + app.run(headless=True) + except KeyboardInterrupt: + pass + finally: + server.shutdown() + + return 0 + + except Exception as exc: + print(f"Error starting web mode: {exc}") + return 1 + + +def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000) -> int: + """Run the Textual TUI app, headless check, or web mode. + + Parameters + ---------- + headless: + Run a one-shot headless startup check instead of full UI loop. + web: + Launch TUI in web mode accessible via browser. + web_port: + Port for web server (default: 8000). + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ container = get_container() registry = container.persona_registry() state = container.persona_state(registry=registry) @@ -243,5 +381,9 @@ def run_tui(*, headless: bool = False) -> int: return 0 app = CleverAgentsTuiApp(command_router=router, persona_state=state) + + if web: + return _run_tui_web(app, port=web_port) + app.run() return 0 diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 958867bb5..a5172dd15 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -79,23 +79,25 @@ class PersonaRegistry: return result def resolve_export_path(self, output_path: Path) -> Path: + """Resolve export path; only relative paths within cwd are accepted.""" if output_path.is_absolute(): raise ValueError( "Export path must be relative to current working directory" ) + resolved = output_path.resolve() base = Path.cwd().resolve() - resolved = (base / output_path).resolve() if not resolved.is_relative_to(base): raise ValueError("Export path must stay within working directory") return resolved def resolve_import_path(self, input_path: Path) -> Path: + """Resolve import path; only relative paths within cwd are accepted.""" if input_path.is_absolute(): raise ValueError( "Import path must be relative to current working directory" ) + resolved = input_path.resolve() base = Path.cwd().resolve() - resolved = (base / input_path).resolve() if not resolved.is_relative_to(base): raise ValueError("Import path must stay within working directory") return resolved diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py index c11fa3fcb..a600e2180 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -63,6 +63,33 @@ class PersonaState: self.preset_by_session[session_id] = next_name return next_name + def cycle_persona(self, session_id: str) -> Persona: + """Cycle to the next persona in cycle_order sequence. + + Only personas with cycle_order > 0 are included in the cycle. + If no cyclic personas exist, returns the current active persona. + """ + personas = self.registry.list_personas() + cyclic = sorted( + [p for p in personas if p.cycle_order > 0], + key=lambda p: p.cycle_order, + ) + + if not cyclic: + return self.active_persona(session_id) + + current = self.active_name(session_id) + current_names = [p.name for p in cyclic] + + if current not in current_names: + # Current persona is not in cycle, start from first + next_persona = cyclic[0] + else: + idx = current_names.index(current) + next_persona = cyclic[(idx + 1) % len(cyclic)] + + return self.set_active_persona(session_id, next_persona.name) + def effective_arguments(self, session_id: str) -> dict[str, object]: persona = self.active_persona(session_id) preset = self.current_preset(session_id) diff --git a/work/repo b/work/repo new file mode 160000 index 000000000..435e409df --- /dev/null +++ b/work/repo @@ -0,0 +1 @@ +Subproject commit 435e409df9fb97b9a08bdd8d7ad2374c25331233