d9e51d98f8
- 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
216 lines
7.4 KiB
Python
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 == []
|