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 """ + +
+ + +