diff --git a/features/lsp_actor_service_wiring.feature b/features/lsp_actor_service_wiring.feature new file mode 100644 index 000000000..b5aa762b4 --- /dev/null +++ b/features/lsp_actor_service_wiring.feature @@ -0,0 +1,95 @@ +Feature: LSP Actor Service — wire LspRuntime and LspToolAdapter into actor execution + + Background: + Given a clean LSP actor service registry + And a test workspace directory + + Scenario: LspActorService activates bindings and generates tool specs + Given an LSP server "local/pyright" is registered with capabilities: + | DIAGNOSTICS | + | HOVER | + | COMPLETIONS | + And an actor with LSP bindings: + | lsp_server_name | + | local/pyright | + When the actor bindings are activated with workspace path + Then the LSP runtime has started the "local/pyright" server + And tool specs are generated for the actor: + | local/pyright/diagnostics | + | local/pyright/hover | + | local/pyright/completions | + + Scenario: LspActorService handles multiple LSP servers + Given an LSP server "local/pyright" is registered with capabilities: + | CAPABILITIES | + | DIAGNOSTICS | + And an LSP server "local/eslint" is registered with capabilities: + | CAPABILITIES | + | DIAGNOSTICS | + And an actor with LSP bindings: + | lsp_server_name | + | local/pyright | + | local/eslint | + When the actor bindings are activated with workspace path + Then the LSP runtime has started the "local/pyright" server + And the LSP runtime has started the "local/eslint" server + And tool specs are generated for both servers + + Scenario: LspActorService deactivates bindings on shutdown + Given an LSP server "local/pyright" is registered with capabilities: + | DIAGNOSTICS | + And an actor with LSP bindings: + | lsp_server_name | + | local/pyright | + And the actor bindings are activated with workspace path + When the actor bindings are deactivated + Then the LSP runtime has released the "local/pyright" server + + Scenario: LspActorService handles missing servers gracefully + Given an LSP server "local/pyright" is registered with capabilities: + | DIAGNOSTICS | + And an actor with LSP bindings: + | lsp_server_name | + | local/missing | + When the actor bindings are activated with workspace path + Then the activation completes with warnings + And no tool specs are generated for the missing server + + Scenario: LspActorService validates input parameters + Given an LSP server "local/pyright" is registered with capabilities: + | DIAGNOSTICS | + And an actor with LSP bindings: + | lsp_server_name | + | local/pyright | + When activation is attempted with empty actor name + Then a ValueError is raised with message "actor_name must be a non-empty string" + When activation is attempted with empty workspace path + Then a ValueError is raised with message "workspace_path must be a non-empty string" + + Scenario: LspActorService integrates with actor execution + Given an LSP server "local/pyright" is registered with capabilities: + | DIAGNOSTICS | + And an actor "local/code-reviewer" with LSP bindings: + | lsp_server_name | + | local/pyright | + When the actor is loaded and activated + Then the actor has access to LSP tools: + | local/pyright/diagnostics | + And the LSP server is running in the background + + Scenario: LspActorService tool specs have correct schema + Given an LSP server "local/pyright" is registered with capabilities: + | CAPABILITIES | + | DIAGNOSTICS | + | HOVER | + And an actor with LSP bindings: + | lsp_server_name | + | local/pyright | + When the actor bindings are activated with workspace path + Then each tool spec has required fields: + | name | + | description | + | handler | + | input_schema | + And the input_schema for "local/pyright/diagnostics" requires "file_path" + And the input_schema for "local/pyright/hover" requires "file_path", "line", "column" diff --git a/features/steps/lsp_actor_service_steps.py b/features/steps/lsp_actor_service_steps.py index 6aeb9e11e..5a5932bfd 100644 --- a/features/steps/lsp_actor_service_steps.py +++ b/features/steps/lsp_actor_service_steps.py @@ -1,3 +1,308 @@ -"""Stub for LSP actor service steps.""" +"""Step definitions for LSP Actor Service wiring tests.""" -# This is a placeholder file to prevent import errors +from __future__ import annotations + +import tempfile +from typing import Any + +from behave import given, then, when + +from cleveragents.application.services.lsp_actor_service import LspActorService +from cleveragents.lsp.client import LspClient +from cleveragents.lsp.lifecycle import LspLifecycleManager +from cleveragents.lsp.models import LspBinding, LspCapability, LspServerConfig +from cleveragents.lsp.registry import LspRegistry +from cleveragents.lsp.runtime import LspRuntime + + +class _MockLifecycleManager(LspLifecycleManager): + """Stub lifecycle manager that does not spawn real processes.""" + + def __init__(self) -> None: + self._servers: dict[str, object] = {} + self._started: set[str] = set() + + def start_server(self, config: LspServerConfig, workspace_path: str) -> LspClient: + """Record the server as started without spawning a process.""" + self._started.add(config.name) + mock_client: LspClient = LspClient.__new__(LspClient) + return mock_client + + def stop_server(self, name: str) -> None: + """Record the server as stopped.""" + self._started.discard(name) + + def health_check(self, name: str) -> bool: + """Return True if the server was started.""" + return name in self._started + + def stop_all(self) -> None: + """Clear all started servers.""" + self._started.clear() + + +def _make_mock_lifecycle() -> LspLifecycleManager: + """Create a mock lifecycle manager that does not spawn real processes.""" + return _MockLifecycleManager() + + +@given("a clean LSP actor service registry") +def step_clean_lsp_registry(context: Any) -> None: + """Initialize a clean LSP registry.""" + context.registry = LspRegistry() + # Build the runtime with test doubles via the public LspRuntime + # constructor, then inject it through LspActorService's public + # constructor parameter — no private-attribute access required. + runtime = LspRuntime( + registry=context.registry, + lifecycle_manager=_make_mock_lifecycle(), + ) + context.service = LspActorService(runtime=runtime) + + +@given("a test workspace directory") +def step_test_workspace_directory(context: Any) -> None: + """Create a temporary workspace directory.""" + context.workspace_dir = tempfile.mkdtemp() + + +@given('an LSP server "{server_name}" is registered with capabilities:') +def step_register_lsp_server(context: Any, server_name: str) -> None: + """Register an LSP server with specified capabilities.""" + capabilities = [] + for row in context.table: + cap_name = row["CAPABILITIES"] if "CAPABILITIES" in row.headings else row[0] + try: + capabilities.append(LspCapability[cap_name]) + except KeyError: + raise ValueError(f"Unknown capability: {cap_name}") from None + + config = LspServerConfig( + name=server_name, + command="echo", + capabilities=capabilities, + ) + context.registry.register(config) + + +@given("an actor with LSP bindings:") +def step_actor_with_lsp_bindings(context: Any) -> None: + """Create an actor with LSP bindings.""" + bindings = [] + for row in context.table: + server_name = row["lsp_server_name"] + binding = LspBinding( + node_name="test_node", + lsp_server_name=server_name, + languages=[], + auto_detect=True, + ) + bindings.append(binding) + + context.actor_bindings = bindings + context.actor_name = "local/test-actor" + + +@given('an actor "{actor_name}" with LSP bindings:') +def step_named_actor_with_lsp_bindings(context: Any, actor_name: str) -> None: + """Create a named actor with LSP bindings.""" + bindings = [] + for row in context.table: + server_name = row["lsp_server_name"] + binding = LspBinding( + node_name="test_node", + lsp_server_name=server_name, + languages=[], + auto_detect=True, + ) + bindings.append(binding) + + context.actor_bindings = bindings + context.actor_name = actor_name + + +@given("the actor bindings are activated with workspace path") +@when("the actor bindings are activated with workspace path") +def step_activate_actor_bindings(context: Any) -> None: + """Activate the actor bindings.""" + try: + context.tool_specs = context.service.activate_actor_bindings( + context.actor_name, + context.actor_bindings, + context.workspace_dir, + ) + context.activation_error = None + except Exception as exc: + context.activation_error = exc + context.tool_specs = [] + + +@when("the actor bindings are deactivated") +def step_deactivate_actor_bindings(context: Any) -> None: + """Deactivate the actor bindings.""" + context.service.deactivate_actor_bindings(context.actor_name) + + +@when("activation is attempted with empty actor name") +def step_activate_empty_actor_name(context: Any) -> None: + """Attempt activation with empty actor name.""" + try: + context.service.activate_actor_bindings( + "", + context.actor_bindings, + context.workspace_dir, + ) + context.activation_error = None + except ValueError as exc: + context.activation_error = exc + + +@when("activation is attempted with empty workspace path") +def step_activate_empty_workspace_path(context: Any) -> None: + """Attempt activation with empty workspace path.""" + try: + context.service.activate_actor_bindings( + context.actor_name, + context.actor_bindings, + "", + ) + context.activation_error = None + except ValueError as exc: + context.activation_error = exc + + +@when("the actor is loaded and activated") +def step_actor_loaded_and_activated(context: Any) -> None: + """Load and activate the actor.""" + context.tool_specs = context.service.activate_actor_bindings( + context.actor_name, + context.actor_bindings, + context.workspace_dir, + ) + + +@then('the LSP runtime has started the "{server_name}" server') +def step_lsp_server_started(context: Any, server_name: str) -> None: + """Verify that the LSP server has been started.""" + # Check that the server is in the lifecycle manager + assert context.service.runtime.lifecycle.health_check(server_name), ( + f"Server {server_name} is not running" + ) + + +@then("tool specs are generated for the actor:") +def step_tool_specs_generated(context: Any) -> None: + """Verify that tool specs are generated.""" + expected_tools = [row[0] for row in context.table] + actual_tools = [spec["name"] for spec in context.tool_specs] + + for expected in expected_tools: + assert expected in actual_tools, ( + f"Expected tool {expected} not found in {actual_tools}" + ) + + +@then("tool specs are generated for both servers") +def step_tool_specs_both_servers(context: Any) -> None: + """Verify that tool specs are generated for both servers.""" + assert len(context.tool_specs) > 0, "No tool specs generated" + tool_names = [spec["name"] for spec in context.tool_specs] + assert any("pyright" in name for name in tool_names), "No pyright tools found" + assert any("eslint" in name for name in tool_names), "No eslint tools found" + + +@then('the LSP runtime has released the "{server_name}" server') +def step_lsp_server_released(context: Any, server_name: str) -> None: + """Verify that the LSP server has been released.""" + # After deactivation, the server should no longer be in the lifecycle manager + # (or at least the reference count should be decremented) + # This is a simplified check; in reality, the server might still be running + # if other actors reference it + # The lifecycle manager exposes a health_check method to verify server state + assert not context.service.runtime.lifecycle.health_check(server_name), ( + f"Server {server_name} was not released after deactivation" + ) + + +@then("the activation completes with warnings") +def step_activation_with_warnings(context: Any) -> None: + """Verify that activation completes despite warnings.""" + assert context.activation_error is None, ( + f"Activation failed: {context.activation_error}" + ) + + +@then("no tool specs are generated for the missing server") +def step_no_tool_specs_missing_server(context: Any) -> None: + """Verify that no tool specs are generated for missing servers.""" + # Tool specs should only be generated for servers that were successfully started + tool_names = [spec["name"] for spec in context.tool_specs] + assert not any("missing" in name for name in tool_names), ( + "Tool specs for missing server found" + ) + + +@then('a ValueError is raised with message "{message}"') +def step_value_error_raised(context: Any, message: str) -> None: + """Verify that a ValueError is raised with the expected message.""" + assert isinstance(context.activation_error, ValueError), ( + f"Expected ValueError, got {type(context.activation_error)}" + ) + assert str(context.activation_error) == message, ( + f"Expected message '{message}', got '{context.activation_error}'" + ) + + +@then("the actor has access to LSP tools:") +def step_actor_has_lsp_tools(context: Any) -> None: + """Verify that the actor has access to LSP tools.""" + expected_tools = [row[0] for row in context.table] + actual_tools = [spec["name"] for spec in context.tool_specs] + + for expected in expected_tools: + assert expected in actual_tools, ( + f"Expected tool {expected} not found in {actual_tools}" + ) + + +@then("the LSP server is running in the background") +def step_lsp_server_running(context: Any) -> None: + """Verify that the LSP server is running.""" + for binding in context.actor_bindings: + server_name = binding.lsp_server_name + assert context.service.runtime.lifecycle.health_check(server_name), ( + f"Server {server_name} is not running" + ) + + +@then("each tool spec has required fields:") +def step_tool_spec_required_fields(context: Any) -> None: + """Verify that each tool spec has required fields.""" + required_fields = [row[0] for row in context.table] + + for spec in context.tool_specs: + for field in required_fields: + assert field in spec, f"Tool spec {spec['name']} missing field {field}" + + +@then('the input_schema for "{tool_name}" requires "{fields}"') +def step_input_schema_requires_fields( + context: Any, tool_name: str, fields: str +) -> None: + """Verify that the input schema requires specific fields.""" + # The fields placeholder captures everything between the first and last + # quote on the line, so a list like '"file_path", "line", "column"' comes + # through as the literal string 'file_path", "line", "column'. Strip + # surrounding whitespace and quotes from each comma-separated entry. + field_list = [f.strip().strip('"') for f in fields.split(",")] + + spec = next((s for s in context.tool_specs if s["name"] == tool_name), None) + assert spec is not None, f"Tool spec {tool_name} not found" + + schema = spec.get("input_schema", {}) + required = schema.get("required", []) + + for field in field_list: + assert field in required, ( + f"Field {field} not required in {tool_name} schema. Required: {required}" + ) diff --git a/features/steps/tui_persona_cycle_steps.py b/features/steps/tui_persona_cycle_steps.py new file mode 100644 index 000000000..1d4ef6146 --- /dev/null +++ b/features/steps/tui_persona_cycle_steps.py @@ -0,0 +1,36 @@ +"""Behave steps for TUI persona cycling. + +Shared persona steps (registry setup, set-active-persona, active-persona +assertion) live in ``tui_persona_system_steps.py``; behave loads step +files into a single global registry so we only need to define each step +in one place. Only cycle-specific steps live here. +""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + + +@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, f"expected {persona_name!r}, got {last!r}" diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..3ec027814 100644 --- a/features/steps/tui_persona_state_coverage_steps.py +++ b/features/steps/tui_persona_state_coverage_steps.py @@ -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 set_last_persona should have been called with "{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..03410e3e1 100644 --- a/features/tui_persona_state_coverage.feature +++ b/features/tui_persona_state_coverage.feature @@ -33,7 +33,7 @@ Feature: TUI Persona State Coverage 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 set_last_persona should have been called with "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/application/services/lsp_actor_service.py b/src/cleveragents/application/services/lsp_actor_service.py new file mode 100644 index 000000000..5191b67f9 --- /dev/null +++ b/src/cleveragents/application/services/lsp_actor_service.py @@ -0,0 +1,173 @@ +"""LSP Actor Service — wires LSP runtime into actor execution pipeline. + +This service manages the lifecycle of LSP servers for actors with LSP bindings. +When an actor with LSP bindings is activated, this service: + +1. Creates an LspRuntime instance +2. Activates the required LSP servers via LspRuntime.activate_bindings() +3. Generates tool specs from LspToolAdapter +4. Injects LSP tools into the actor's tool set + +Based on docs/specification.md LSP Integration (Server Lifecycle). +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from cleveragents.lsp.runtime import LspRuntime +from cleveragents.lsp.tool_adapter import LspToolAdapter + +logger = structlog.get_logger(__name__) + + +class LspActorService: + """Manages LSP server lifecycle for actors with LSP bindings. + + This service bridges the gap between actor compilation metadata + (which extracts lsp_bindings) and actor execution (which needs + LSP servers running and tools available). + + Attributes: + _runtime: The functional LSP runtime instance. + _adapter: The LSP tool adapter for generating tool specs. + _active_bindings: Mapping of actor name -> activated bindings. + """ + + def __init__(self, runtime: LspRuntime | None = None) -> None: + """Initialize the LSP actor service. + + Args: + runtime: Optional LspRuntime to use. When omitted, a default + LspRuntime() is constructed. Pass a pre-built runtime + (e.g. with test-double registry/lifecycle) to inject + collaborators without reaching into private attributes. + """ + # NB: Use ``is None`` rather than ``or`` — LspRuntime holds an + # LspRegistry which is falsy when empty, so ``runtime or + # LspRuntime()`` would silently discard a caller-supplied + # runtime whose registry has not been populated yet. + self._runtime = runtime if runtime is not None else LspRuntime() + self._adapter = LspToolAdapter(self._runtime) + self._active_bindings: dict[str, list[Any]] = {} + + @property + def runtime(self) -> LspRuntime: + """The underlying LSP runtime.""" + return self._runtime + + @property + def adapter(self) -> LspToolAdapter: + """The underlying LSP tool adapter.""" + return self._adapter + + def activate_actor_bindings( + self, + actor_name: str, + bindings: list[Any], + workspace_path: str, + ) -> list[dict[str, Any]]: + """Activate LSP servers for an actor and generate tool specs. + + When an actor with LSP bindings is activated (for plan execution + or `agents actor run`), this method: + + 1. Calls LspRuntime.activate_bindings() to start servers + 2. Generates tool specs from the activated servers + 3. Returns the tool specs for injection into the actor + + Args: + actor_name: The namespaced actor name (e.g., "local/my-actor"). + bindings: List of LspBinding objects from CompilationMetadata. + workspace_path: Root directory for the language servers. + + Returns: + List of tool-spec dicts ready for injection into the actor. + Each dict has keys: name, description, handler, input_schema. + + Raises: + ValueError: If actor_name or workspace_path is empty. + """ + if not actor_name: + raise ValueError("actor_name must be a non-empty string") + if not workspace_path: + raise ValueError("workspace_path must be a non-empty string") + + logger.info( + "lsp_actor_service.activating_bindings", + actor=actor_name, + binding_count=len(bindings), + workspace=workspace_path, + ) + + # Activate the servers + started_servers = self._runtime.activate_bindings(bindings, workspace_path) + self._active_bindings[actor_name] = bindings + + # Generate tool specs from the activated servers + tool_specs: list[dict[str, Any]] = [] + for binding in bindings: + server_name = getattr(binding, "lsp_server_name", "") + if not server_name: + continue + + try: + config = self._runtime.registry.get_or_raise(server_name) + specs = self._adapter.generate_tool_specs(config) + tool_specs.extend(specs) + logger.debug( + "lsp_actor_service.generated_tool_specs", + actor=actor_name, + server=server_name, + spec_count=len(specs), + ) + except Exception as exc: + logger.warning( + "lsp_actor_service.tool_spec_generation_failed", + actor=actor_name, + server=server_name, + error=str(exc), + ) + + logger.info( + "lsp_actor_service.activation_complete", + actor=actor_name, + started_servers=started_servers, + total_tool_specs=len(tool_specs), + ) + + return tool_specs + + def deactivate_actor_bindings(self, actor_name: str) -> None: + """Deactivate LSP servers for an actor. + + Releases references to LSP servers acquired by the actor. + Servers are only terminated when all references are released. + + Args: + actor_name: The namespaced actor name. + """ + bindings = self._active_bindings.pop(actor_name, []) + if not bindings: + return + + logger.info( + "lsp_actor_service.deactivating_bindings", + actor=actor_name, + binding_count=len(bindings), + ) + + self._runtime.deactivate_bindings(bindings) + + def shutdown(self) -> None: + """Shut down all LSP servers.""" + logger.info("lsp_actor_service.shutting_down") + self._runtime.stop_all() + self._active_bindings.clear() + + +__all__ = [ + "LspActorService", +] diff --git a/src/cleveragents/lsp/runtime.py b/src/cleveragents/lsp/runtime.py index cdb37f3fd..4677a50aa 100644 --- a/src/cleveragents/lsp/runtime.py +++ b/src/cleveragents/lsp/runtime.py @@ -53,8 +53,16 @@ class LspRuntime: registry: LspRegistry | None = None, lifecycle_manager: LspLifecycleManager | None = None, ) -> None: - self._registry = registry or LspRegistry() - self._lifecycle = lifecycle_manager or LspLifecycleManager() + # NB: LspRegistry defines __len__, so an empty instance is + # falsy. A naive ``registry or LspRegistry()`` would silently + # discard a caller-supplied empty registry — preventing + # registrations added after construction from ever being + # visible. Use an explicit ``is None`` test instead. + self._registry = registry if registry is not None else LspRegistry() + if lifecycle_manager is not None: + self._lifecycle = lifecycle_manager + else: + self._lifecycle = LspLifecycleManager() # Maps server name to resolved workspace root for path containment checks. self._workspace_paths: dict[str, str] = {} diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 958867bb5..ab500439e 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 a relative export path inside the current working directory.""" 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 a relative import path inside the current working directory.""" 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..f423591d2 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -63,6 +63,32 @@ 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)