Files
cleveragents-core/features/steps/tui_persona_system_steps.py
T
aditya d9e51d98f8 fix(tui,cli,tests): harden persona/input modes and stabilize parallel test execution
- Add shell safety controls for REPL/TUI (`looks_dangerous`, confirmation gate, timeout handling, and env-based shell disable guard).
- Secure persona workflows with strict name/path validation, safe import/export resolution, atomic+locked registry writes, and malformed YAML resilience.
- Unify persona models and wiring by reusing canonical TUI schema/registry, adding DI providers, and lazy-loading TUI exports to avoid circular imports.
- Improve reference discovery with ignored-directory filtering, symlink-safe walking, and TTL caching for CLI/TUI reference catalogs.
- Expand Behave/Robot coverage for safety/error paths and parallel-isolation behavior; add shared `features/mocks/fake_repl_input.py` helper.
- Fix parallel-run flakiness via deterministic cleanup/reload patterns and watchdog polling fallback when inotify limits are reached.

ISSUES CLOSED: #695
2026-03-18 10:09:06 +00:00

216 lines
7.4 KiB
Python

"""Behave steps for TUI persona system."""
from __future__ import annotations
import shutil
import tempfile
import threading
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, PersonaPreset
from cleveragents.tui.persona.state import PersonaState
def _registry_for_temp_dir(path: Path) -> PersonaRegistry:
return PersonaRegistry(config_dir=path)
@given("a temporary TUI persona registry")
def step_temp_registry(context: Context) -> None:
temp_dir = Path(tempfile.mkdtemp())
context.tui_persona_dir = temp_dir
context.tui_registry = _registry_for_temp_dir(temp_dir)
context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True))
@when('I build a TUI persona named "{name}" with actor "{actor}"')
def step_build_persona(context: Context, name: str, actor: str) -> None:
context.tui_persona = Persona(name=name, actor=actor)
@then("the persona should include exactly one default preset")
def step_default_preset(context: Context) -> None:
defaults = [
item for item in context.tui_persona.argument_presets if item.name == "default"
]
assert len(defaults) == 1
@when('I save a TUI persona named "{name}" with actor "{actor}"')
def step_save_persona(context: Context, name: str, actor: str) -> None:
persona = Persona(name=name, actor=actor)
context.tui_registry.save(persona)
@given('I save a TUI persona named "{name}" with actor "{actor}"')
def step_given_save_persona(context: Context, name: str, actor: str) -> None:
step_save_persona(context, name, actor)
@given(
'I save TUI persona "{name}" with actor "{actor}" and extra preset "{preset_name}"'
)
def step_save_persona_with_extra_preset(
context: Context, name: str, actor: str, preset_name: str
) -> None:
persona = Persona(
name=name,
actor=actor,
argument_presets=[
PersonaPreset(name="default", display="default", overrides={}),
PersonaPreset(
name=preset_name, display=preset_name, overrides={"mode": "x"}
),
],
)
context.tui_registry.save(persona)
@given(
'I save a TUI persona named "{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 try to save a TUI persona named "{name}" with actor "{actor}" and cycle order {cycle:d}'
)
def step_save_persona_cycle_fail(
context: Context, name: str, actor: str, cycle: int
) -> None:
context.tui_cycle_error = None
try:
context.tui_registry.save(Persona(name=name, actor=actor, cycle_order=cycle))
except Exception as exc:
context.tui_cycle_error = exc
@then('loading persona "{name}" should succeed')
def step_loading_persona(context: Context, name: str) -> None:
context.tui_loaded_persona = context.tui_registry.get(name)
assert context.tui_loaded_persona is not None
@then('the loaded persona actor should be "{actor}"')
def step_loaded_actor(context: Context, actor: str) -> None:
assert context.tui_loaded_persona.actor == actor
@then("saving the second persona should fail with cycle order error")
def step_cycle_error(context: Context) -> None:
assert context.tui_cycle_error is not None
assert "cycle_order" in str(context.tui_cycle_error)
@when('I set active persona to "{persona_name}" for session "{session_id}"')
def step_set_active_persona(
context: Context, persona_name: str, session_id: str
) -> None:
if not hasattr(context, "tui_state"):
context.tui_state = PersonaState(registry=context.tui_registry)
context.tui_state.set_active_persona(session_id, persona_name)
@then('active persona for session "{session_id}" should be "{persona_name}"')
def step_active_persona(context: Context, session_id: str, persona_name: str) -> None:
persona = context.tui_state.active_persona(session_id)
assert persona.name == persona_name
@when('I cycle persona preset for session "{session_id}"')
def step_cycle_preset(context: Context, session_id: str) -> None:
if not hasattr(context, "tui_state"):
context.tui_state = PersonaState(registry=context.tui_registry)
context.tui_state.cycle_preset(session_id)
@then('current preset for session "{session_id}" should be "{preset_name}"')
def step_current_preset(context: Context, session_id: str, preset_name: str) -> None:
assert context.tui_state.current_preset(session_id) == preset_name
@given('I create malformed TUI persona file "{name}"')
def step_create_malformed_persona_file(context: Context, name: str) -> None:
malformed = context.tui_registry.personas_dir / f"{name}.yaml"
context.tui_registry.ensure_dirs()
malformed.write_text("name: broken\nactor:\n- not-a-string\n", encoding="utf-8")
@when("I list TUI personas")
def step_list_tui_personas(context: Context) -> None:
context.tui_list_error = None
context.tui_persona_names = []
try:
personas = context.tui_registry.list_personas()
context.tui_persona_names = [persona.name for persona in personas]
except Exception as exc:
context.tui_list_error = exc
@then('TUI persona list should include "{name}"')
def step_tui_persona_list_includes(context: Context, name: str) -> None:
assert context.tui_list_error is None
assert name in context.tui_persona_names
@when('I export TUI persona "{name}" to "{output_path}"')
def step_export_tui_persona(context: Context, name: str, output_path: str) -> None:
context.tui_export_error = None
try:
context.tui_registry.export_persona(name, Path(output_path))
except Exception as exc:
context.tui_export_error = exc
@then('TUI export should fail with "{message}"')
def step_tui_export_fails(context: Context, message: str) -> None:
assert context.tui_export_error is not None
assert message in str(context.tui_export_error)
@when('I import TUI persona from "{input_path}"')
def step_import_tui_persona(context: Context, input_path: str) -> None:
context.tui_import_error = None
try:
context.tui_registry.import_persona(Path(input_path))
except Exception as exc:
context.tui_import_error = exc
@then('TUI import should fail with "{message}"')
def step_tui_import_fails(context: Context, message: str) -> None:
assert context.tui_import_error is not None
assert message in str(context.tui_import_error)
@when("I concurrently set TUI last persona names")
def step_concurrent_set_last_persona_names(context: Context) -> None:
context.tui_concurrency_errors = []
def _worker(persona_name: str) -> None:
try:
context.tui_registry.set_last_persona(persona_name)
except Exception as exc: # pragma: no cover - assertion handled after join
context.tui_concurrency_errors.append(exc)
threads: list[threading.Thread] = []
for row in context.table:
thread = threading.Thread(target=_worker, args=(row["name"],))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
@then("TUI concurrent update should complete without errors")
def step_tui_concurrent_update_no_errors(context: Context) -> None:
assert context.tui_concurrency_errors == []