feat(tui): complete v3.7.0 TUI milestone with PersonaRegistry and web mode #10637
@@ -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
|
||||
|
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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}"')
|
||||
|
HAL9001
commented
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)
|
||||
|
||||
|
||||
|
HAL9001
commented
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
|
||||
|
||||
|
||||
|
HAL9001
commented
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}"')
|
||||
|
HAL9001
commented
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 """<!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;
|
||||
|
HAL9001
commented
Suggestion: move HTML to a separate .html template file. Suggestion: move HTML to a separate .html template file.
|
||||
background-color: #1e1e1e;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
HAL9001
commented
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>
|
||||
|
HAL9001
commented
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>
|
||||
|
HAL9001
commented
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>"""
|
||||
|
HAL9001
commented
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.
|
||||
|
||||
|
HAL9001
commented
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
|
||||
|
HAL9001
commented
Note: WebSocket support is still a placeholder. Note: WebSocket support is still a placeholder.
|
||||
import webbrowser
|
||||
|
HAL9001
commented
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
|
||||
|
||||
@@ -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."""
|
||||
|
HAL9001
commented
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.
HAL9001
commented
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):
|
||||
|
HAL9001
commented
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
|
||||
|
||||
@@ -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.
|
||||
|
HAL9001
commented
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,
|
||||
)
|
||||
|
HAL9001
commented
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)
|
||||
|
||||
Clean up: verify SimpleNamespace and AsyncMock are unused after moving helpers to a separate module. If confirmed, remove from imports.