feat(tui): complete v3.7.0 TUI milestone with PersonaRegistry and web mode #10637

Open
HAL9000 wants to merge 7 commits from feat/v370/tui-web-mode into master
14 changed files with 343 additions and 46 deletions
@@ -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,
)
@@ -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
@@ -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
Review

Clean up: verify SimpleNamespace and AsyncMock are unused after moving helpers to a separate module. If confirmed, remove from imports.

Clean up: verify SimpleNamespace and AsyncMock are unused after moving helpers to a separate module. If confirmed, remove from imports.
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)
+42
View File
@@ -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}"')
Review

Suggestion: Add docstrings to step definition functions to match the project style.

Suggestion: Add docstrings to step definition functions to match the project style.
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)
Review

Suggestion: add docstring to step function.

Suggestion: add docstring to step function.
@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
@@ -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
Review

Suggestion: Add a docstring to step_verify_last_persona_set to match the style of other step functions.

Suggestion: Add a docstring to step_verify_last_persona_set to match the style of other step functions.
@then('the registry last persona should be set to "{expected}"')
@then('the mock registry last persona should be set to "{expected}"')
Review

Suggestion: add docstring to step function.

Suggestion: add docstring to step function.
def step_verify_last_persona_set(context, expected):
context.mock_registry.set_last_persona.assert_called_with(expected)
+51
View File
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+15 -1
View File
@@ -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))
+144 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import json
from collections import defaultdict
from collections.abc import Callable
1
@@ -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 """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CleverAgents TUI</title>
<style>
body {
margin: 0;
padding: 0;
font-family: monospace;
Review

Suggestion: move HTML to a separate .html template file.

Suggestion: move HTML to a separate .html template file.
background-color: #1e1e1e;
color: #f8f8f2;
}
Review

Suggestion: The HTML in _get_tui_web_html() is embedded as a large string literal. Consider moving this to a separate .html file in a templates/ directory and loading it with Path.read_text().

Suggestion: The HTML in _get_tui_web_html() is embedded as a large string literal. Consider moving this to a separate .html file in a templates/ directory and loading it with Path.read_text().
#tui-container {
width: 100%;
height: 100vh;
overflow: hidden;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 18px;
}
</style>
</head>
<body>
<div id="tui-container">
<div class="loading">Loading CleverAgents TUI...</div>
</div>
Review

Note: The HTML template includes a console.log noting WebSocket support is coming soon. The web mode is not fully functional without WebSocket integration.

Note: The HTML template includes a console.log noting WebSocket support is coming soon. The web mode is not fully functional without WebSocket integration.
<script>
// WebSocket connection to TUI app
// Note: This is a placeholder. Full implementation would require
// a WebSocket server in the TUI app to handle real-time rendering.
console.log("TUI Web mode loaded. WebSocket support coming soon.");
</script>
</body>
Review

Suggestion: validate web_port is in range 1-65535 before creating the HTTP server.

Suggestion: validate web_port is in range 1-65535 before creating the HTTP server.
</html>"""
Review

Suggestion: use a Protocol instead of Any for the app parameter.

Suggestion: use a Protocol instead of Any for the app parameter.
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.
Review

Suggestion: Consider using a more specific type than Any for the app parameter in _run_tui_web(). While CleverAgentsTuiApp caused a reportInvalidTypeForm error, the type could be captured via a Protocol defining only the .run(headless=True) interface used here.

Suggestion: Consider using a more specific type than Any for the app parameter in _run_tui_web(). While CleverAgentsTuiApp caused a reportInvalidTypeForm error, the type could be captured via a Protocol defining only the .run(headless=True) interface used here.
Returns
-------
Exit code (0 for success, non-zero for failure).
"""
try:
# Import web server dependencies
import threading
Review

Note: WebSocket support is still a placeholder.

Note: WebSocket support is still a placeholder.
import webbrowser
Review

Suggestion: Add port validation when web mode is activated. Check that web_port is in the valid range (1-65535) before binding the HTTP server.

Suggestion: Add port validation when web mode is activated. Check that web_port is in the valid range (1-65535) before binding the HTTP server.
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
+4 -2
View File
@@ -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."""
Outdated
Review

Note: This change now accepts absolute paths for import/export, only checking is_relative_to. This is a behavior change. Consider whether this is intended or if absolute paths should remain a security boundary.

Note: This change now accepts absolute paths for import/export, only checking is_relative_to. This is a behavior change. Consider whether this is intended or if absolute paths should remain a security boundary.
Outdated
Review

Suggestion: resolve_export_path and resolve_import_path now have nearly identical implementations. Consider extracting a shared helper method.

Suggestion: resolve_export_path and resolve_import_path now have nearly identical implementations. Consider extracting a shared helper method.
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):
Review

Note: Behavior change - absolute paths now accepted with traversal guard.

Note: Behavior change - absolute paths now accepted with traversal guard.
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
+27
View File
@@ -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.
Review

Suggestion: Consider caching the sorted cyclic persona list rather than re-sorting on every cycle_persona() call. Current complexity is O(n log n) per invocation.

Suggestion: Consider caching the sorted cyclic persona list rather than re-sorting on every cycle_persona() call. Current complexity is O(n log n) per invocation.
"""
personas = self.registry.list_personas()
cyclic = sorted(
[p for p in personas if p.cycle_order > 0],
key=lambda p: p.cycle_order,
)
Review

Suggestion: cache sorted cyclic persona list instead of calling sorted() on every call.

Suggestion: cache sorted cyclic persona list instead of calling sorted() on every call.
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)
Submodule
+1
Submodule work/repo added at 435e409df9