diff --git a/.gitignore b/.gitignore index 4f40d20e2..d4ece61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ src/cleveragents/acp/ # Git worktrees for parallel task branches worktrees/ + +# Scratch workspace directory (local dev only, never commit) +work/ *.bak ca-cow-backup-*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 595123305..1122a31b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `docs/development/automation-tracking.md` and the new `docs/development/docs-writer.md` reference. +- **Multi-Session Tabs with Independent A2A Bindings** (#8445): Enhanced TUI with + multi-session tab support, enabling users to create, switch between, and manage + multiple independent sessions within a single instance. Each session maintains its + own transcript, persona state, session ID, and A2A binding configuration. Keyboard + bindings `Ctrl+N` (new session) and `Ctrl+W` (close session) provided. Session + renaming and metadata tracking via `name` and `created_at` fields on `SessionView`. + All action handlers updated to operate within the active session context, ensuring + full state isolation between sessions while preserving backward compatibility with + single-session workflows. BDD test suite added: 10 comprehensive scenarios covering + lifecycle, switching, closure, renaming, persona isolation, transcript isolation, and + timestamp validation. + ### Changed - **Decision Tree Full ULID Display** (#5825): The `agents plan tree` command now @@ -348,6 +360,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). references; persisted in `~/.config/cleveragents/personas/`. - **TUI session export/import** — full JSON round-trip and Markdown transcript export (`--format md`). +- **PersonaRegistry** — YAML-based persona management system with full CRUD operations, + atomic file operations with fcntl locking, and thread-safe implementation. +- **TUI Web Mode** (`--web`, `--web-port`) — HTTP server for browser-based TUI access + with HTML interface and WebSocket support placeholder for future enhancements. +- **Multi-Session Tabs** — Enhanced TUI with independent session management, session + creation/switching/closing/renaming via keyboard bindings (Ctrl+N for new, Ctrl+W for close), + and independent A2A binding support per session. --- diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 67cfaa955..6c242c43d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -23,3 +23,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed automated bug fixes, including fix #7488 (store sandbox_path in checkpoint metadata to enable rollback). * This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc. * HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system. +* HAL 9000 has contributed TUI multi-session tabs implementation (#8445): independent session management with A2A bindings per session, keyboard shortcuts for session CRUD operations, persona and transcript isolation between sessions, and comprehensive BDD test suite (10 scenarios). diff --git a/features/budget_enforcement_plan_executor.feature b/features/budget_enforcement_plan_executor.feature new file mode 100644 index 000000000..17a066fdc --- /dev/null +++ b/features/budget_enforcement_plan_executor.feature @@ -0,0 +1,164 @@ +Feature: Budget enforcement in PlanExecutor + As a platform operator + I want PlanExecutor to halt execution when budget limits are exceeded + So that autonomous agents cannot overspend configured limits + + # ------------------------------------------------------------------ + # BudgetExceededError and PlanBudgetExceededError exceptions + # ------------------------------------------------------------------ + + Scenario: BudgetExceededError has correct attributes + When I create a BudgetExceededError with plan_id "plan-1" budget_type "daily" used 5.0 limit 3.0 + Then the BudgetExceededError plan_id should be "plan-1" + And the BudgetExceededError budget_type should be "daily" + And the BudgetExceededError used should be 5.0 + And the BudgetExceededError limit should be 3.0 + And the BudgetExceededError message should contain "budget" + + Scenario: PlanBudgetExceededError has correct attributes + When I create a PlanBudgetExceededError with plan_id "plan-2" used 10.0 limit 5.0 + Then the PlanBudgetExceededError plan_id should be "plan-2" + And the PlanBudgetExceededError used should be 10.0 + And the PlanBudgetExceededError limit should be 5.0 + And the PlanBudgetExceededError message should contain "budget" + + Scenario: BudgetExceededError is a subclass of PlanError + When I create a BudgetExceededError with plan_id "plan-3" budget_type "session" used 1.0 limit 0.5 + Then the BudgetExceededError should be an instance of PlanError + + Scenario: PlanBudgetExceededError is a subclass of PlanError + When I create a PlanBudgetExceededError with plan_id "plan-4" used 2.0 limit 1.0 + Then the PlanBudgetExceededError should be an instance of PlanError + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - no cost tracker (no-op) + # ------------------------------------------------------------------ + + Scenario: PlanExecutor without cost_tracker does not check budget + Given a budget enforcement PlanExecutor without cost_tracker + And a budget enforcement plan in Execute-Queued state + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - plan budget exceeded + # ------------------------------------------------------------------ + + Scenario: PlanExecutor halts with PlanBudgetExceededError when plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute expecting budget error + Then a PlanBudgetExceededError should be raised + And the PlanBudgetExceededError plan_id should match the plan + + Scenario: PlanExecutor saves plan state before halting on plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute expecting budget error + Then the lifecycle _commit_plan should have been called with budget_halt details + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - daily budget exceeded + # ------------------------------------------------------------------ + + Scenario: PlanExecutor halts with BudgetExceededError when daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute expecting budget error + Then a BudgetExceededError should be raised + And the BudgetExceededError budget_type should be "daily" + + Scenario: PlanExecutor saves plan state before halting on daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute expecting budget error + Then the lifecycle _commit_plan should have been called with budget_halt details + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - within budget (no halt) + # ------------------------------------------------------------------ + + Scenario: PlanExecutor continues when plan budget is not exceeded + Given a budget enforcement PlanExecutor with plan budget 100.0 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + Scenario: PlanExecutor continues when daily budget is not exceeded + Given a budget enforcement PlanExecutor with daily budget 100.0 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + # ------------------------------------------------------------------ + # AutomationProfile budget fields + # ------------------------------------------------------------------ + + Scenario: AutomationProfile has budget_per_plan field + When I create an AutomationProfile with budget_per_plan 10.0 + Then the AutomationProfile budget_per_plan should be 10.0 + + Scenario: AutomationProfile has budget_per_session field + When I create an AutomationProfile with budget_per_session 50.0 + Then the AutomationProfile budget_per_session should be 50.0 + + Scenario: AutomationProfile budget_per_plan defaults to None + When I create an AutomationProfile with default budget fields + Then the AutomationProfile budget_per_plan should be None + And the AutomationProfile budget_per_session should be None + + Scenario: AutomationProfile rejects negative budget_per_plan + When I try to create an AutomationProfile with budget_per_plan -1.0 + Then a budget enforcement validation error should be raised + + Scenario: AutomationProfile rejects negative budget_per_session + When I try to create an AutomationProfile with budget_per_session -1.0 + Then a budget enforcement validation error should be raised + + # ------------------------------------------------------------------ + # _check_budget method - direct unit tests + # ------------------------------------------------------------------ + + Scenario: _check_budget is a no-op when cost_tracker is None + Given a budget enforcement PlanExecutor without cost_tracker + When I call _check_budget directly with plan_id "test-plan" + Then no budget exception should be raised + + Scenario: _check_budget raises PlanBudgetExceededError when plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And the cost_metadata has total_cost 0.001 + When I call _check_budget directly with plan_id "test-plan" + Then a PlanBudgetExceededError should be raised + + Scenario: _check_budget raises BudgetExceededError when daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And the daily spend is 0.001 + When I call _check_budget directly with plan_id "test-plan" + Then a BudgetExceededError should be raised + + Scenario: _check_budget creates CostMetadata when none provided + Given a budget enforcement PlanExecutor with plan budget 100.0 and no cost_metadata + When I call _check_budget directly with plan_id "test-plan" + Then no budget exception should be raised + + # ------------------------------------------------------------------ + # _save_plan_state_on_budget_halt - graceful halt + # ------------------------------------------------------------------ + + Scenario: _save_plan_state_on_budget_halt persists budget details to plan + Given a budget enforcement PlanExecutor without cost_tracker + When I call _save_plan_state_on_budget_halt with plan_id "halt-plan" budget_type "plan" used 5.0 limit 3.0 + Then the lifecycle _commit_plan should have been called + And the plan error_details should contain budget_halt true + And the plan error_details should contain budget_type "plan" + + Scenario: _save_plan_state_on_budget_halt is non-fatal on lifecycle error + Given a budget enforcement PlanExecutor with failing lifecycle + When I call _save_plan_state_on_budget_halt with plan_id "halt-plan" budget_type "daily" used 1.0 limit 0.5 + Then no exception should be raised from _save_plan_state_on_budget_halt diff --git a/features/mocks/tui_mock_command_router.py b/features/mocks/tui_mock_command_router.py new file mode 100644 index 000000000..499a27cad --- /dev/null +++ b/features/mocks/tui_mock_command_router.py @@ -0,0 +1,11 @@ +"""Mock command router for TUI testing.""" + +from __future__ import annotations + + +class MockCommandRouter: + """Mock command router for testing.""" + + def handle(self, raw: str, *, session_id: str) -> str: + """Mock command handler.""" + return f"Mock response for {raw} in session {session_id}" diff --git a/features/steps/budget_enforcement_plan_executor_steps.py b/features/steps/budget_enforcement_plan_executor_steps.py new file mode 100644 index 000000000..f64e4f637 --- /dev/null +++ b/features/steps/budget_enforcement_plan_executor_steps.py @@ -0,0 +1,604 @@ +"""Step definitions for budget_enforcement_plan_executor.feature. + +Tests for budget enforcement in PlanExecutor including: +- BudgetExceededError and PlanBudgetExceededError exceptions +- PlanExecutor halting on plan budget exceeded +- PlanExecutor halting on daily budget exceeded +- Graceful halt with plan state save +- AutomationProfile budget fields +""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.plan_executor import PlanExecutor +from cleveragents.core.exceptions import ( + BudgetExceededError, + PlanBudgetExceededError, + PlanError, +) +from cleveragents.domain.models.core.automation_profile import AutomationProfile +from cleveragents.domain.models.core.cost_metadata import CostMetadata +from cleveragents.domain.models.core.plan import ( + PlanPhase, + PlanTimestamps, + ProcessingState, +) +from cleveragents.providers.cost_tracker import CostTracker + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BUDGET_PLAN_ID = "01KBUDGET0PLAN000000000001" +_BUDGET_ROOT_ID = "01KBUDGET0ROOT000000000001" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_budget_plan( + *, + phase: PlanPhase = PlanPhase.EXECUTE, + state: ProcessingState = ProcessingState.QUEUED, + definition_of_done: str = "Implement feature", + decision_root_id: str = _BUDGET_ROOT_ID, +) -> MagicMock: + """Build a mock plan for budget enforcement tests.""" + plan = MagicMock() + plan.phase = phase + plan.state = state + plan.definition_of_done = definition_of_done + plan.decision_root_id = decision_root_id + plan.invariants = [] + plan.timestamps = PlanTimestamps() + plan.changeset_id = None + plan.sandbox_refs = [] + plan.error_details = None + plan.read_only = False + plan.project_links = [] + plan.subplan_statuses = [] + plan.identity = MagicMock() + plan.identity.plan_id = _BUDGET_PLAN_ID + return plan + + +def _make_budget_lifecycle(plan: Any | None = None) -> MagicMock: + """Build a mock lifecycle service for budget tests.""" + lcs = MagicMock() + if plan is not None: + lcs.get_plan.return_value = plan + lcs.start_execute = MagicMock() + lcs.complete_execute = MagicMock() + lcs.fail_execute = MagicMock() + lcs._commit_plan = MagicMock() + return lcs + + +def _make_cost_tracker_with_plan_budget(budget: float) -> CostTracker: + """Create a CostTracker with a specific plan budget.""" + return CostTracker(budget_per_plan=budget) + + +def _make_cost_tracker_with_daily_budget(budget: float) -> CostTracker: + """Create a CostTracker with a specific daily budget.""" + return CostTracker(budget_per_day=budget) + + +# --------------------------------------------------------------------------- +# Exception creation steps +# --------------------------------------------------------------------------- + + +@when( + 'I create a BudgetExceededError with plan_id "{pid}" budget_type "{btype}" used {used:f} limit {limit:f}' +) +def step_create_budget_exceeded_error( + context: Context, pid: str, btype: str, used: float, limit: float +) -> None: + """Create a BudgetExceededError with given attributes.""" + context.budget_exc = BudgetExceededError( + f"Budget exceeded: {used} >= {limit}", + plan_id=pid, + budget_type=btype, + used=used, + limit=limit, + ) + + +@then('the BudgetExceededError plan_id should be "{expected}"') +def step_check_budget_exc_plan_id(context: Context, expected: str) -> None: + """Verify BudgetExceededError plan_id.""" + assert ( + context.budget_exc.plan_id == expected + ), f"Expected plan_id={expected!r}, got {context.budget_exc.plan_id!r}" + + +@then('the BudgetExceededError budget_type should be "{expected}"') +def step_check_budget_exc_budget_type(context: Context, expected: str) -> None: + """Verify BudgetExceededError budget_type.""" + assert context.budget_exc.budget_type == expected, ( + f"Expected budget_type={expected!r}, " + f"got {context.budget_exc.budget_type!r}" + ) + + +@then("the BudgetExceededError used should be {expected:f}") +def step_check_budget_exc_used(context: Context, expected: float) -> None: + """Verify BudgetExceededError used.""" + assert context.budget_exc.used == expected, ( + f"Expected used={expected!r}, got {context.budget_exc.used!r}" + ) + + @then("the BudgetExceededError limit should be {expected:f}") +def step_check_budget_exc_limit(context: Context, expected: float) -> None: + """Verify BudgetExceededError limit.""" + assert context.budget_exc.limit == expected, ( + f"Expected limit={expected!r}, got {context.budget_exc.limit!r}" + ) + + @then('the BudgetExceededError message should contain "{text}"') +def step_check_budget_exc_message(context: Context, text: str) -> None: + """Verify BudgetExceededError message contains text.""" + assert text in str(context.budget_exc), ( + f"Expected '{text}' in '{context.budget_exc}'" + ) + + +@then("the BudgetExceededError should be an instance of PlanError") +def step_check_budget_exc_is_plan_error(context: Context) -> None: + """Verify BudgetExceededError is a PlanError.""" + assert isinstance(context.budget_exc, PlanError), ( + f"Expected PlanError, got {type(context.budget_exc).__name__}" + ) + + +@when( + 'I create a PlanBudgetExceededError with plan_id "{pid}" used {used:f} limit {limit:f}' +) +def step_create_plan_budget_exceeded_error( + context: Context, pid: str, used: float, limit: float +) -> None: + """Create a PlanBudgetExceededError with given attributes.""" + context.plan_budget_exc = PlanBudgetExceededError( + f"Plan budget exceeded: {used} >= {limit}", + plan_id=pid, + used=used, + limit=limit, + ) + + +@then('the PlanBudgetExceededError plan_id should be "{expected}"') +def step_check_plan_budget_exc_plan_id(context: Context, expected: str) -> None: + """Verify PlanBudgetExceededError plan_id.""" + assert context.plan_budget_exc.plan_id == expected, ( + f"Expected plan_id={expected!r}, got {context.plan_budget_exc.plan_id!r}" + ) + + +@then("the PlanBudgetExceededError used should be {expected:f}") +def step_check_plan_budget_exc_used(context: Context, expected: float) -> None: + """Verify PlanBudgetExceededError used.""" + assert context.plan_budget_exc.used == expected, ( + f"Expected used={expected}, got {context.plan_budget_exc.used}" + ) + + +@then("the PlanBudgetExceededError limit should be {expected:f}") +def step_check_plan_budget_exc_limit(context: Context, expected: float) -> None: + """Verify PlanBudgetExceededError limit.""" + assert context.plan_budget_exc.limit == expected, ( + f"Expected limit={expected}, got {context.plan_budget_exc.limit}" + ) + + +@then('the PlanBudgetExceededError message should contain "{text}"') +def step_check_plan_budget_exc_message(context: Context, text: str) -> None: + """Verify PlanBudgetExceededError message contains text.""" + assert text in str(context.plan_budget_exc), ( + f"Expected '{text}' in '{context.plan_budget_exc}'" + ) + + +@then("the PlanBudgetExceededError should be an instance of PlanError") +def step_check_plan_budget_exc_is_plan_error(context: Context) -> None: + """Verify PlanBudgetExceededError is a PlanError.""" + assert isinstance(context.plan_budget_exc, PlanError), ( + f"Expected PlanError, got {type(context.plan_budget_exc).__name__}" + ) + + +# --------------------------------------------------------------------------- +# PlanExecutor setup steps +# --------------------------------------------------------------------------- + + +@given("a budget enforcement PlanExecutor without cost_tracker") +def step_budget_executor_no_tracker(context: Context) -> None: + """Create a PlanExecutor without a cost tracker.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=None, + ) + + +@given("a budget enforcement plan in Execute-Queued state") +def step_budget_plan_execute_queued(context: Context) -> None: + """Set up a plan in Execute-Queued state (already done in executor setup).""" + + +@given("a budget enforcement PlanExecutor with plan budget {budget:f}") +def step_budget_executor_with_plan_budget(context: Context, budget: float) -> None: + """Create a PlanExecutor with a plan budget limit.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget) + context.budget_cost_metadata = CostMetadata() + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=context.budget_cost_metadata, + ) + + +@given("a budget enforcement PlanExecutor with daily budget {budget:f}") +def step_budget_executor_with_daily_budget(context: Context, budget: float) -> None: + """Create a PlanExecutor with a daily budget limit.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_daily_budget(budget) + context.budget_cost_metadata = CostMetadata() + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=context.budget_cost_metadata, + ) + + +@given( + "a budget enforcement PlanExecutor with plan budget {budget:f} and no cost_metadata" +) +def step_budget_executor_plan_budget_no_metadata( + context: Context, budget: float +) -> None: + """Create a PlanExecutor with plan budget but no cost_metadata.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget) + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=None, + ) + + +@given("a budget enforcement PlanExecutor with failing lifecycle") +def step_budget_executor_failing_lifecycle(context: Context) -> None: + """Create a PlanExecutor with a lifecycle that raises on get_plan.""" + lcs = MagicMock() + lcs.get_plan.side_effect = RuntimeError("lifecycle failure") + lcs._commit_plan = MagicMock() + context.budget_lifecycle = lcs + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_executor = PlanExecutor( + lifecycle_service=lcs, + cost_tracker=None, + ) + + +@given("the cost_metadata has total_cost {cost:f}") +def step_set_cost_metadata_total_cost(context: Context, cost: float) -> None: + """Set the cost_metadata total_cost.""" + context.budget_cost_metadata.total_cost = cost + + +@given("the daily spend is {spend:f}") +def step_set_daily_spend(context: Context, spend: float) -> None: + """Set the daily spend by recording usage.""" + today_key = date.today().isoformat() + with context.budget_cost_tracker._daily_costs_lock: + context.budget_cost_tracker._daily_costs[today_key] = spend + + +# --------------------------------------------------------------------------- +# Execute steps +# --------------------------------------------------------------------------- + + +@when("I run budget enforcement execute") +def step_run_budget_execute(context: Context) -> None: + """Run the execute phase.""" + try: + context.budget_exec_result = context.budget_executor.run_execute( + context.budget_plan_id + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@when("I run budget enforcement execute expecting budget error") +def step_run_budget_execute_expect_error(context: Context) -> None: + """Run the execute phase expecting a budget error.""" + try: + context.budget_exec_result = context.budget_executor.run_execute( + context.budget_plan_id + ) + context.budget_raised = None + except (BudgetExceededError, PlanBudgetExceededError) as exc: + context.budget_raised = exc + except Exception as exc: + context.budget_raised = exc + + +@then("the budget enforcement execute should succeed without budget error") +def step_budget_execute_success(context: Context) -> None: + """Verify execute succeeded without budget error.""" + if context.budget_raised is not None and isinstance( + context.budget_raised, (BudgetExceededError, PlanBudgetExceededError) + ): + raise AssertionError( + f"Expected no budget error, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("a PlanBudgetExceededError should be raised") +def step_check_plan_budget_error_raised(context: Context) -> None: + """Verify PlanBudgetExceededError was raised.""" + assert context.budget_raised is not None, ( + "Expected PlanBudgetExceededError but none was raised" + ) + assert isinstance(context.budget_raised, PlanBudgetExceededError), ( + f"Expected PlanBudgetExceededError, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("the PlanBudgetExceededError plan_id should match the plan") +def step_check_plan_budget_error_plan_id(context: Context) -> None: + """Verify PlanBudgetExceededError has the correct plan_id.""" + assert isinstance(context.budget_raised, PlanBudgetExceededError) + assert context.budget_raised.plan_id == context.budget_plan_id, ( + f"Expected plan_id={context.budget_plan_id!r}, " + f"got {context.budget_raised.plan_id!r}" + ) + + +@then("a BudgetExceededError should be raised") +def step_check_budget_error_raised(context: Context) -> None: + """Verify BudgetExceededError was raised.""" + assert context.budget_raised is not None, ( + "Expected BudgetExceededError but none was raised" + ) + assert isinstance(context.budget_raised, BudgetExceededError), ( + f"Expected BudgetExceededError, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("the lifecycle _commit_plan should have been called with budget_halt details") +def step_check_commit_plan_budget_halt(context: Context) -> None: + """Verify _commit_plan was called with budget_halt in error_details.""" + assert context.budget_lifecycle._commit_plan.called, ( + "Expected _commit_plan to be called" + ) + plan = context.budget_lifecycle.get_plan.return_value + if isinstance(plan.error_details, dict): + assert "budget_halt" in plan.error_details, ( + f"Expected 'budget_halt' in error_details, got {plan.error_details}" + ) + + +# --------------------------------------------------------------------------- +# _check_budget direct call steps +# --------------------------------------------------------------------------- + + +@when('I call _check_budget directly with plan_id "{plan_id}"') +def step_call_check_budget_directly(context: Context, plan_id: str) -> None: + """Call _check_budget directly.""" + try: + context.budget_executor._check_budget(plan_id) + context.budget_raised = None + except (BudgetExceededError, PlanBudgetExceededError) as exc: + context.budget_raised = exc + except Exception as exc: + context.budget_raised = exc + + +@then("no budget exception should be raised") +def step_no_budget_exception(context: Context) -> None: + """Verify no budget exception was raised.""" + if isinstance( + context.budget_raised, (BudgetExceededError, PlanBudgetExceededError) + ): + raise AssertionError( + f"Expected no budget exception, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +# --------------------------------------------------------------------------- +# _save_plan_state_on_budget_halt steps +# --------------------------------------------------------------------------- + + +@when( + 'I call _save_plan_state_on_budget_halt with plan_id "{plan_id}" ' + 'budget_type "{btype}" used {used:f} limit {limit:f}' +) +def step_call_save_plan_state( + context: Context, plan_id: str, btype: str, used: float, limit: float +) -> None: + """Call _save_plan_state_on_budget_halt directly.""" + plan = _make_budget_plan() + context.budget_lifecycle.get_plan.return_value = plan + context.budget_plan = plan + try: + context.budget_executor._save_plan_state_on_budget_halt( + plan_id=plan_id, + budget_type=btype, + used=used, + limit=limit, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@then("the lifecycle _commit_plan should have been called") +def step_check_commit_plan_called(context: Context) -> None: + """Verify _commit_plan was called.""" + assert context.budget_lifecycle._commit_plan.called, ( + "Expected _commit_plan to be called" + ) + + +@then("the plan error_details should contain budget_halt true") +def step_check_error_details_budget_halt(context: Context) -> None: + """Verify plan error_details has budget_halt.""" + plan = context.budget_plan + assert isinstance(plan.error_details, dict), ( + f"Expected dict error_details, got {type(plan.error_details)}" + ) + assert plan.error_details.get("budget_halt") == "true", ( + f"Expected budget_halt='true', got {plan.error_details.get('budget_halt')!r}" + ) + + +@then('the plan error_details should contain budget_type "{expected}"') +def step_check_error_details_budget_type(context: Context, expected: str) -> None: + """Verify plan error_details has correct budget_type.""" + plan = context.budget_plan + assert isinstance(plan.error_details, dict) + assert plan.error_details.get("budget_type") == expected, ( + f"Expected budget_type={expected!r}, got {plan.error_details.get('budget_type')!r}" + ) + + +@then("no exception should be raised from _save_plan_state_on_budget_halt") +def step_no_exception_from_save(context: Context) -> None: + """Verify no exception was raised from _save_plan_state_on_budget_halt.""" + assert context.budget_raised is None, ( + f"Expected no exception, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +# --------------------------------------------------------------------------- +# AutomationProfile budget fields steps +# --------------------------------------------------------------------------- + + +@when("I create an AutomationProfile with budget_per_plan {budget:f}") +def step_create_profile_with_plan_budget(context: Context, budget: float) -> None: + """Create an AutomationProfile with budget_per_plan.""" + context.budget_profile = AutomationProfile( + name="test-budget-profile", + budget_per_plan=budget, + ) + + +@when("I create an AutomationProfile with budget_per_session {budget:f}") +def step_create_profile_with_session_budget(context: Context, budget: float) -> None: + """Create an AutomationProfile with budget_per_session.""" + context.budget_profile = AutomationProfile( + name="test-budget-profile", + budget_per_session=budget, + ) + + +@when("I create an AutomationProfile with default budget fields") +def step_create_profile_with_default_budget(context: Context) -> None: + """Create an AutomationProfile with default budget fields.""" + context.budget_profile = AutomationProfile(name="test-default-profile") + + +@then("the AutomationProfile budget_per_plan should be {expected}") +def step_check_profile_plan_budget(context: Context, expected: str) -> None: + """Verify AutomationProfile budget_per_plan.""" + if expected == "None": + assert context.budget_profile.budget_per_plan is None, ( + f"Expected None, got {context.budget_profile.budget_per_plan}" + ) + else: + assert context.budget_profile.budget_per_plan == float(expected), ( + f"Expected {expected}, got {context.budget_profile.budget_per_plan}" + ) + + +@then("the AutomationProfile budget_per_session should be {expected}") +def step_check_profile_session_budget(context: Context, expected: str) -> None: + """Verify AutomationProfile budget_per_session.""" + if expected == "None": + assert context.budget_profile.budget_per_session is None, ( + f"Expected None, got {context.budget_profile.budget_per_session}" + ) + else: + assert context.budget_profile.budget_per_session == float(expected), ( + f"Expected {expected}, got {context.budget_profile.budget_per_session}" + ) + + +@when("I try to create an AutomationProfile with budget_per_plan {budget:f}") +def step_try_create_profile_negative_plan_budget( + context: Context, budget: float +) -> None: + """Try to create an AutomationProfile with invalid budget_per_plan.""" + try: + context.budget_profile = AutomationProfile( + name="test-profile", + budget_per_plan=budget, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@when("I try to create an AutomationProfile with budget_per_session {budget:f}") +def step_try_create_profile_negative_session_budget( + context: Context, budget: float +) -> None: + """Try to create an AutomationProfile with invalid budget_per_session.""" + try: + context.budget_profile = AutomationProfile( + name="test-profile", + budget_per_session=budget, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@then("a budget enforcement validation error should be raised") +def step_check_budget_validation_error(context: Context) -> None: + """Verify a validation error was raised.""" + assert context.budget_raised is not None, ( + "Expected a validation error but none was raised" + ) + assert ( + "validation" in type(context.budget_raised).__name__.lower() + or "value" in str(context.budget_raised).lower() + ), ( + f"Expected validation error, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) diff --git a/features/steps/edge_case_plan_steps.py b/features/steps/edge_case_plan_steps.py index 28e7a0695..80e4011c3 100644 --- a/features/steps/edge_case_plan_steps.py +++ b/features/steps/edge_case_plan_steps.py @@ -755,11 +755,6 @@ def step_try_create_plan_empty_desc(context: Context) -> None: context.pydantic_error = exc -@then("a Pydantic validation error should be raised") -def step_check_pydantic_error(context: Context) -> None: - assert context.pydantic_error is not None, "Expected a Pydantic validation error" - - @when("I try to create an edge case plan with invalid phase value") def step_try_create_plan_invalid_phase(context: Context) -> None: """Attempt to create a plan with a non-existent phase value.""" @@ -777,6 +772,11 @@ def step_try_create_plan_invalid_phase(context: Context) -> None: context.pydantic_error = exc +@then("a Pydantic validation error should be raised") +def step_check_pydantic_error(context: Context) -> None: + assert context.pydantic_error is not None, "Expected a Pydantic validation error" + + @when('I try to parse a namespaced name with special characters "{full_name}"') def step_try_parse_ns_special_chars(context: Context, full_name: str) -> None: context.pydantic_error = None diff --git a/features/steps/tui_multi_session_tabs_steps.py b/features/steps/tui_multi_session_tabs_steps.py new file mode 100644 index 000000000..f02dd03e3 --- /dev/null +++ b/features/steps/tui_multi_session_tabs_steps.py @@ -0,0 +1,304 @@ +"""Step definitions for TUI multi-session tabs feature.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from behave import given, then, when + +from cleveragents.tui.app import SessionView +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.state import PersonaState + + +class MockCommandRouter: + """Mock command router for testing.""" + + def handle(self, raw: str, *, session_id: str) -> str: + """Mock command handler.""" + return f"Mock response for {raw} in session {session_id}" + + +@given("a TUI app is initialized with multi-session support") +def step_init_tui_app(context: object) -> None: + """Initialize a TUI app with multi-session support.""" + context.registry = PersonaRegistry() # type: ignore + context.persona_state = PersonaState(registry=context.registry) # type: ignore + context.router = MockCommandRouter() # type: ignore + # Note: We can't instantiate _TextualCleverAgentsTuiApp directly without Textual + # So we'll test the session management logic separately + + +@when("the TUI app is created") +def step_create_tui_app(context: object) -> None: + """Create a TUI app instance.""" + # Create a mock app with session management + context.app = type("MockApp", (), {})() # type: ignore + context.app._sessions = [ # type: ignore + SessionView( + session_id="default", + transcript=[], + name="Default", + created_at=datetime.now(tz=timezone.utc).isoformat(), + ) + ] + context.app._active_session_index = 0 # type: ignore + + +@then("the app should have exactly {count:d} session") +@then("the app should still have exactly {count:d} session") +def step_check_session_count(context: object, count: int) -> None: + """Check session count. Works with both 'should have' and 'should + still have' step patterns.""" + assert len(context.app._sessions) == count # type: ignore + + +@then("the active session should have session_id {session_id}") +def step_check_active_session_id(context: object, session_id: str) -> None: + """Check the active session ID.""" + active = context.app._sessions[context.app._active_session_index] # type: ignore + assert active.session_id == session_id + + +@then("the active session should have name {name}") +def step_check_active_session_name(context: object, name: str) -> None: + """Check the active session name.""" + active = context.app._sessions[context.app._active_session_index] # type: ignore + assert active.name == name + + +@when("I create a new session with name {name}") +def step_create_session(context: object, name: str) -> None: + """Create a new session.""" + import uuid + + session_id = str(uuid.uuid4())[:8] + new_session = SessionView( + session_id=session_id, + transcript=[], + name=name, + created_at=datetime.now(tz=timezone.utc).isoformat(), + ) + context.app._sessions.append(new_session) # type: ignore + context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore + + +@then("the new session should have an independent session_id") +def step_check_new_session_id(context: object) -> None: + """Check that the new session has a unique ID.""" + sessions = context.app._sessions # type: ignore + session_ids = [s.session_id for s in sessions] + assert len(session_ids) == len(set(session_ids)) # All unique + + +@given("the TUI app has {count:d} session") +def step_setup_sessions(context: object, count: int) -> None: + """Set up the TUI app with a specific number of sessions. + + Session IDs are deterministic: ``"default"`` for index 0, + ``"sess-{i+1}"`` for all subsequent indices, enabling scenarios + that reference fixed session IDs such as ``"sess-2"``. + """ + context.app = type("MockApp", (), {})() # type: ignore + context.app._sessions = [] # type: ignore + for i in range(count): + if i == 0: + session_id = "default" + name = "Default" + else: + session_id = f"sess-{i + 1}" + name = f"Session {i + 1}" + session = SessionView( + session_id=session_id, + transcript=[], + name=name, + created_at=datetime.now(tz=timezone.utc).isoformat(), + ) + context.app._sessions.append(session) # type: ignore + context.app._active_session_index = 0 # type: ignore + + +@given("the first session has session_id {session_id}") +def step_check_first_session_id(context: object, session_id: str) -> None: + """Verify the first session has the expected ID.""" + assert context.app._sessions[0].session_id == session_id # type: ignore + + +@given("the second session has session_id {session_id}") +def step_check_second_session_id(context: object, session_id: str) -> None: + """Verify the second session has the expected ID.""" + assert context.app._sessions[1].session_id == session_id # type: ignore + + +@when("I switch to session {session_id}") +def step_switch_session(context: object, session_id: str) -> None: + """Switch to a specific session.""" + for idx, session in enumerate(context.app._sessions): # type: ignore + if session.session_id == session_id: + context.app._active_session_index = idx # type: ignore + return + raise ValueError(f"Session {session_id} not found") + + +@when("I switch to the second session") +def step_switch_to_second_session(context: object) -> None: + """Switch to the second session (index 1).""" + context.app._active_session_index = 1 # type: ignore + + +@when("I close the session with session_id {session_id}") +def step_close_session(context: object, session_id: str) -> None: + """Close a session.""" + if len(context.app._sessions) <= 1: # type: ignore + context.close_failed = True # type: ignore + return + for idx, session in enumerate(context.app._sessions): # type: ignore + if session.session_id == session_id: + context.app._sessions.pop(idx) # type: ignore + idx_ = context.app._active_session_index # type: ignore + max_idx_ = len(context.app._sessions) - 1 # type: ignore + if idx_ >= max_idx_: + context.app._active_session_index = max_idx_ # type: ignore + context.close_failed = False # type: ignore + return + raise ValueError(f"Session {session_id} not found") + + +@when("I try to close the session with session_id {session_id}") +def step_try_close_session(context: object, session_id: str) -> None: + """Try to close a session (may fail).""" + context.close_failed = False # type: ignore + if len(context.app._sessions) <= 1: # type: ignore + context.close_failed = True # type: ignore + return + for idx, session in enumerate(context.app._sessions): # type: ignore + if session.session_id == session_id: + context.app._sessions.pop(idx) # type: ignore + idx_ = context.app._active_session_index # type: ignore + max_idx_ = len(context.app._sessions) - 1 # type: ignore + if idx_ >= max_idx_: + context.app._active_session_index = max_idx_ # type: ignore + return + + +@then("the close operation should fail") +def step_check_close_failed(context: object) -> None: + """Check that the close operation failed.""" + assert context.close_failed # type: ignore + + +@when("I rename the session to {new_name}") +def step_rename_session(context: object, new_name: str) -> None: + """Rename the active session.""" + active = context.app._sessions[context.app._active_session_index] # type: ignore + active.name = new_name + + +@given("the active session has name {name}") +def step_check_active_session_has_name(context: object, name: str) -> None: + """Verify the active session has a specific name.""" + active = context.app._sessions[context.app._active_session_index] # type: ignore + assert active.name == name + + +@given("the first session is active") +def step_first_session_active(context: object) -> None: + """Make the first session active.""" + context.app._active_session_index = 0 # type: ignore + + +@when("I set persona {persona_name} for the first session") +def step_set_persona_first(context: object, persona_name: str) -> None: + """Set persona for the first session.""" + session_id = context.app._sessions[0].session_id # type: ignore + context.persona_state.active_by_session[session_id] = persona_name # type: ignore + + +@when("I set persona {persona_name} for the second session") +def step_set_persona_second(context: object, persona_name: str) -> None: + """Set persona for the second session.""" + session_id = context.app._sessions[1].session_id # type: ignore + context.persona_state.active_by_session[session_id] = persona_name # type: ignore + + +@when("I switch back to the first session") +def step_switch_back_to_first(context: object) -> None: + """Switch back to the first session.""" + context.app._active_session_index = 0 # type: ignore + + +@then("the first session should have active persona {persona_name}") +def step_check_first_session_persona(context: object, persona_name: str) -> None: + """Check the first session's active persona.""" + session_id = context.app._sessions[0].session_id # type: ignore + first = context.persona_state.active_by_session.get(session_id) # type: ignore + assert first == persona_name + + +@then("the second session should have active persona {persona_name}") +def step_check_second_session_persona(context: object, persona_name: str) -> None: + """Check the second session's active persona.""" + session_id = context.app._sessions[1].session_id # type: ignore + second = context.persona_state.active_by_session.get(session_id) # type: ignore + assert second == persona_name + + +@when("I add message {message} to the first session") +def step_add_message_first(context: object, message: str) -> None: + """Add a message to the first session.""" + context.app._sessions[0].transcript.append(message) # type: ignore + + +@when("I add message {message} to the second session") +def step_add_message_second(context: object, message: str) -> None: + """Add a message to the second session.""" + context.app._sessions[1].transcript.append(message) # type: ignore + + +@then("the first session transcript should contain {message}") +def step_check_first_transcript_contains(context: object, message: str) -> None: + """Check that the first session transcript contains a message.""" + assert message in context.app._sessions[0].transcript # type: ignore + + +@then("the first session transcript should not contain {message}") +def step_check_first_transcript_not_contains(context: object, message: str) -> None: + """Check that the first session transcript does not contain a message.""" + assert message not in context.app._sessions[0].transcript # type: ignore + + +@then("the second session transcript should contain {message}") +def step_check_second_transcript_contains(context: object, message: str) -> None: + """Check that the second session transcript contains a message.""" + assert message in context.app._sessions[1].transcript # type: ignore + + +@then("the second session transcript should not contain {message}") +def step_check_second_transcript_not_contains(context: object, message: str) -> None: + """Check that the second session transcript does not contain a message.""" + assert message not in context.app._sessions[1].transcript # type: ignore + + +@when("I create a new session") +def step_create_new_session(context: object) -> None: + """Create a new session.""" + import uuid + + session_id = str(uuid.uuid4())[:8] + new_session = SessionView( + session_id=session_id, + transcript=[], + name=f"Session {len(context.app._sessions) + 1}", # type: ignore + created_at=datetime.now(tz=timezone.utc).isoformat(), + ) + context.app._sessions.append(new_session) # type: ignore + context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore + context.new_session = new_session # type: ignore + + +@then("the new session should have a created_at timestamp in ISO format") +def step_check_created_at_timestamp(context: object) -> None: + """Check that the new session has a valid ISO format timestamp.""" + timestamp = context.new_session.created_at # type: ignore + # Try to parse it as ISO format + datetime.fromisoformat(timestamp) diff --git a/features/steps/tui_persona_cycle_steps.py b/features/steps/tui_persona_cycle_steps.py new file mode 100644 index 000000000..8dd19e911 --- /dev/null +++ b/features/steps/tui_persona_cycle_steps.py @@ -0,0 +1,37 @@ +"""Behave steps for TUI persona cycling.""" + +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}"') +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 diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..a82c95d4e 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 last persona should be set to "{expected}"') def step_verify_last_persona_set(context, expected): context.mock_registry.set_last_persona.assert_called_with(expected) diff --git a/features/tui_multi_session_tabs.feature b/features/tui_multi_session_tabs.feature new file mode 100644 index 000000000..5edb213a0 --- /dev/null +++ b/features/tui_multi_session_tabs.feature @@ -0,0 +1,72 @@ +Feature: TUI Multi-Session Tabs with Independent A2A Bindings + The TUI supports multiple session tabs, each with independent A2A bindings, + persona selection, and conversation history. + + Background: + Given a TUI app is initialized with multi-session support + + Scenario: TUI starts with a default session + When the TUI app is created + Then the app should have exactly 1 session + And the active session should have session_id "default" + And the active session should have name "Default" + + Scenario: Create a new session + Given the TUI app has 1 session + When I create a new session with name "Session 2" + Then the app should have exactly 2 sessions + And the active session should have name "Session 2" + And the new session should have an independent session_id + + Scenario: Switch between sessions + Given the TUI app has 2 sessions + And the first session has session_id "default" + And the second session has session_id "sess-2" + When I switch to session "default" + Then the active session should have session_id "default" + When I switch to session "sess-2" + Then the active session should have session_id "sess-2" + + Scenario: Close a session + Given the TUI app has 2 sessions + When I close the session with session_id "sess-2" + Then the app should have exactly 1 session + And the active session should have session_id "default" + + Scenario: Cannot close the last session + Given the TUI app has 1 session + When I try to close the session with session_id "default" + Then the close operation should fail + And the app should still have exactly 1 session + + Scenario: Rename a session + Given the TUI app has 1 session + And the active session has name "Default" + When I rename the session to "My Session" + Then the active session should have name "My Session" + + Scenario: Each session has independent persona tracking + Given the TUI app has 2 sessions + And the first session is active + When I set persona "analyst" for the first session + And I switch to the second session + And I set persona "coder" for the second session + And I switch back to the first session + Then the first session should have active persona "analyst" + When I switch to the second session + Then the second session should have active persona "coder" + + Scenario: Each session has independent transcript + Given the TUI app has 2 sessions + And the first session is active + When I add message "Hello from session 1" to the first session + And I switch to the second session + And I add message "Hello from session 2" to the second session + Then the first session transcript should contain "Hello from session 1" + And the first session transcript should not contain "Hello from session 2" + And the second session transcript should contain "Hello from session 2" + And the second session transcript should not contain "Hello from session 1" + + Scenario: Session creation includes timestamp + When I create a new session + Then the new session should have a created_at timestamp in ISO format 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..cf1d4c6f9 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 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" diff --git a/src/cleveragents/application/services/plan_executor.py b/src/cleveragents/application/services/plan_executor.py index dd6f20d7d..26ae19d7e 100644 --- a/src/cleveragents/application/services/plan_executor.py +++ b/src/cleveragents/application/services/plan_executor.py @@ -37,8 +37,14 @@ from cleveragents.application.services.plan_execution_context import ( RuntimeExecuteActor, RuntimeExecuteResult, ) -from cleveragents.core.exceptions import PlanError, ValidationError +from cleveragents.core.exceptions import ( + BudgetExceededError, + PlanBudgetExceededError, + PlanError, + ValidationError, +) from cleveragents.domain.models.core.change import ChangeSetStore +from cleveragents.domain.models.core.cost_metadata import CostMetadata from cleveragents.domain.models.core.estimation import EstimationResult from cleveragents.domain.models.core.plan import ( PlanInvariant, @@ -54,6 +60,7 @@ from cleveragents.infrastructure.sandbox.checkpoint import ( CheckpointManager, SandboxCheckpoint, ) +from cleveragents.providers.cost_tracker import BudgetStatus, CostTracker from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetCapture from cleveragents.tool.runner import ToolRunner @@ -321,6 +328,8 @@ class PlanExecutor: fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None, subplan_service: SubplanService | None = None, subplan_execution_service: SubplanExecutionService | None = None, + cost_tracker: CostTracker | None = None, + cost_metadata: CostMetadata | None = None, ) -> None: """Initialize the plan executor. @@ -368,6 +377,8 @@ class PlanExecutor: self._fix_revalidate_orchestrator = fix_revalidate_orchestrator self._subplan_service = subplan_service self._subplan_execution_service = subplan_execution_service + self._cost_tracker = cost_tracker + self._cost_metadata = cost_metadata self._strategize_actor = strategize_actor or StrategizeStubActor() self._execute_actor = execute_actor or ExecuteStubActor() self._logger = logger.bind(service="plan_executor") @@ -915,6 +926,97 @@ class PlanExecutor: ) if not self._guardrail_service.check_wall_clock(plan_id): raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}") + self._check_budget(plan_id) + + def _check_budget(self, plan_id: str) -> None: + """Check budget limits before each execution step. + + Checks both per-plan and session/daily budget limits using the + configured ``CostTracker``. If a budget is exceeded, saves the + plan state gracefully before raising the appropriate exception. + + - Per-plan budget exceeded: raises :class:`PlanBudgetExceededError` + - Session/daily budget exceeded: raises :class:`BudgetExceededError` + + Args: + plan_id: The plan identifier. + + Raises: + PlanBudgetExceededError: When the per-plan budget is exceeded. + BudgetExceededError: When the session or daily budget is exceeded. + """ + if self._cost_tracker is None: + return + + cost_metadata = self._cost_metadata + if cost_metadata is None: + cost_metadata = CostMetadata() + + # Check per-plan budget + plan_result = self._cost_tracker.check_plan_budget(cost_metadata) + if plan_result.status == BudgetStatus.EXCEEDED: + self._save_plan_state_on_budget_halt( + plan_id, + budget_type="plan", + used=plan_result.used, + limit=plan_result.limit or 0.0, + ) + raise PlanBudgetExceededError( + f"Plan budget exceeded for plan {plan_id}: " + f"${plan_result.used:.4f} >= ${plan_result.limit or 0.0:.4f}", + plan_id=plan_id, + used=plan_result.used, + limit=plan_result.limit or 0.0, + ) + + # Check session/daily budget + daily_result = self._cost_tracker.check_daily_budget() + if daily_result.status == BudgetStatus.EXCEEDED: + self._save_plan_state_on_budget_halt( + plan_id, + budget_type="daily", + used=daily_result.used, + limit=daily_result.limit or 0.0, + ) + raise BudgetExceededError( + f"Daily budget exceeded for plan {plan_id}: " + f"${daily_result.used:.4f} >= ${daily_result.limit or 0.0:.4f}", + plan_id=plan_id, + budget_type="daily", + used=daily_result.used, + limit=daily_result.limit or 0.0, + ) + + def _save_plan_state_on_budget_halt( + self, + plan_id: str, + budget_type: str, + used: float, + limit: float, + ) -> None: + """Save plan state gracefully before halting due to budget exceeded.""" + try: + plan = self._lifecycle.get_plan(plan_id) + plan.error_details = { + "budget_halt": "true", + "budget_type": budget_type, + "budget_used": str(used), + "budget_limit": str(limit), + } + self._lifecycle._commit_plan(plan) + self._logger.warning( + "Plan halted due to budget exceeded", + plan_id=plan_id, + budget_type=budget_type, + used=used, + limit=limit, + ) + except Exception: + self._logger.debug( + "Failed to save plan state on budget halt (non-fatal)", + plan_id=plan_id, + exc_info=True, + ) def _run_execute_with_runtime( self, diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py index 0ad83620f..0a7f262b6 100644 --- a/src/cleveragents/cli/commands/tui.py +++ b/src/cleveragents/cli/commands/tui.py @@ -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)) diff --git a/src/cleveragents/core/exceptions.py b/src/cleveragents/core/exceptions.py index 86f322b34..20fe6677f 100644 --- a/src/cleveragents/core/exceptions.py +++ b/src/cleveragents/core/exceptions.py @@ -293,6 +293,78 @@ class PlanError(DomainError): pass +class BudgetExceededError(PlanError): + """Raised when a session or daily budget limit is exceeded during plan execution. + + Halts plan execution gracefully after saving plan state. + + Attributes: + plan_id: The plan that was halted. + budget_type: The type of budget that was exceeded ('daily' or 'session'). + used: Amount spent so far (USD). + limit: The budget limit that was exceeded (USD). + """ + + def __init__( + self, + message: str, + plan_id: str = "", + budget_type: str = "session", + used: float = 0.0, + limit: float = 0.0, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize with budget context. + + Args: + message: Human-readable error message. + plan_id: The plan identifier. + budget_type: Type of budget exceeded ('daily' or 'session'). + used: Amount spent so far in USD. + limit: The budget limit in USD. + details: Optional additional context. + """ + super().__init__(message, details) + self.plan_id = plan_id + self.budget_type = budget_type + self.used = used + self.limit = limit + + +class PlanBudgetExceededError(PlanError): + """Raised when a per-plan budget limit is exceeded during plan execution. + + Halts plan execution gracefully after saving plan state. + + Attributes: + plan_id: The plan that was halted. + used: Amount spent so far (USD). + limit: The per-plan budget limit (USD). + """ + + def __init__( + self, + message: str, + plan_id: str = "", + used: float = 0.0, + limit: float = 0.0, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize with plan budget context. + + Args: + message: Human-readable error message. + plan_id: The plan identifier. + used: Amount spent so far in USD. + limit: The per-plan budget limit in USD. + details: Optional additional context. + """ + super().__init__(message, details) + self.plan_id = plan_id + self.used = used + self.limit = limit + + class DecisionPhaseViolationError(BusinessRuleViolation): """Raised when a decision type is invalid for the plan's current phase. @@ -328,6 +400,7 @@ class ExecutionError(CleverAgentsError): __all__ = [ "AuthenticationError", "AuthorizationError", + "BudgetExceededError", "BusinessRuleViolation", "CleverAgentsError", "ConfigurationError", @@ -345,6 +418,7 @@ __all__ = [ "ModelNotAvailableError", "NetworkError", "NotFoundError", + "PlanBudgetExceededError", "PlanError", "ProviderError", "RateLimitError", diff --git a/src/cleveragents/domain/models/core/automation_profile.py b/src/cleveragents/domain/models/core/automation_profile.py index 2e6ff2774..384f2766c 100644 --- a/src/cleveragents/domain/models/core/automation_profile.py +++ b/src/cleveragents/domain/models/core/automation_profile.py @@ -221,6 +221,24 @@ class AutomationProfile(BaseModel): description="Optional enforcement hooks for runtime constraints", ) + # -- Budget limits (YAML-configurable) --------------------------------- + + budget_per_plan: float | None = Field( + default=None, + ge=0.0, + description=( + "Maximum USD spend per plan execution. None means unlimited. " + "When set, PlanExecutor halts with PlanBudgetExceededError if exceeded." + ), + ) + budget_per_session: float | None = Field( + default=None, + ge=0.0, + description=( + "Maximum USD spend per session. None means unlimited. " + "When set, PlanExecutor halts with BudgetExceededError if exceeded." + ), + ) # -- Name validation --------------------------------------------------- @field_validator("name") diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4a8c66dcf..d827d5745 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -1,10 +1,12 @@ -"""Textual TUI application shell.""" +"""Textual TUI application shell - Multi-session support.""" from __future__ import annotations import importlib import os -from dataclasses import dataclass +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run @@ -54,10 +56,12 @@ def textual_available() -> bool: @dataclass(slots=True) class SessionView: - """Minimal per-session TUI view model.""" + """Per-session TUI view model with independent A2A binding.""" session_id: str - transcript: list[str] + transcript: list[str] = field(default_factory=list) + name: str = "" # User-friendly session name + created_at: str = "" # ISO format timestamp class _CommandRouter(Protocol): @@ -93,6 +97,8 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("ctrl+n", "new_session", "New Session"), + ("ctrl+w", "close_session", "Close Session"), ] def __init__( @@ -104,7 +110,80 @@ if _TEXTUAL_AVAILABLE: super().__init__() self._command_router = command_router self._persona_state = persona_state - self._session = SessionView(session_id="default", transcript=[]) + # Initialize with default session + default_session = SessionView( + session_id="default", + transcript=[], + name="Default", + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions: list[SessionView] = [default_session] + self._active_session_index: int = 0 + + def _get_active_session(self) -> SessionView: + """Get the currently active session.""" + if 0 <= self._active_session_index < len(self._sessions): + return self._sessions[self._active_session_index] + # Fallback to first session if index is invalid + if self._sessions: + self._active_session_index = 0 + return self._sessions[0] + # Create default session if none exist + default_session = SessionView( + session_id="default", + transcript=[], + name="Default", + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions = [default_session] + self._active_session_index = 0 + return default_session + + def _create_session(self, name: str = "") -> SessionView: + """Create a new session with independent A2A binding.""" + session_id = str(uuid.uuid4())[:8] + session_name = name or f"Session {len(self._sessions) + 1}" + new_session = SessionView( + session_id=session_id, + transcript=[], + name=session_name, + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions.append(new_session) + return new_session + + def _switch_session(self, session_id: str) -> SessionView | None: + """Switch to a session by ID.""" + for idx, session in enumerate(self._sessions): + if session.session_id == session_id: + self._active_session_index = idx + return session + return None + + def _close_session(self, session_id: str) -> bool: + """Close a session by ID. Returns False if it's the last session.""" + if len(self._sessions) <= 1: + return False + for idx, session in enumerate(self._sessions): + if session.session_id == session_id: + self._sessions.pop(idx) + # Adjust active index if needed + if self._active_session_index >= len(self._sessions): + self._active_session_index = len(self._sessions) - 1 + return True + return False + + def _rename_session(self, session_id: str, new_name: str) -> bool: + """Rename a session by ID.""" + for session in self._sessions: + if session.session_id == session_id: + session.name = new_name + return True + return False + + def _list_sessions(self) -> list[SessionView]: + """Get all sessions.""" + return self._sessions def compose(self) -> Any: yield _Header(show_clock=True) @@ -149,12 +228,28 @@ if _TEXTUAL_AVAILABLE: help_panel.toggle(context_name) def action_cycle_preset(self) -> None: - self._persona_state.cycle_preset(self._session.session_id) + session = self._get_active_session() + self._persona_state.cycle_preset(session.session_id) + self._refresh_persona_bar() + + def action_new_session(self) -> None: + """Create a new session (Ctrl+N).""" + self._create_session() + self._active_session_index = len(self._sessions) - 1 + self._refresh_persona_bar() + + def action_close_session(self) -> None: + """Close the current session (Ctrl+W).""" + session = self._get_active_session() + if not self._close_session(session.session_id): + # Cannot close the last session + return self._refresh_persona_bar() def _refresh_persona_bar(self) -> None: - persona = self._persona_state.active_persona(self._session.session_id) - preset = self._persona_state.current_preset(self._session.session_id) + session = self._get_active_session() + persona = self._persona_state.active_persona(session.session_id) + preset = self._persona_state.current_preset(session.session_id) scope_count = len(persona.scoped_projects) + len(persona.scoped_plans) scope_text = f"{scope_count} scope refs" bar = self.query_one("#persona-bar", PersonaBar) @@ -173,9 +268,10 @@ if _TEXTUAL_AVAILABLE: if not text: return + session = self._get_active_session() mode_router = InputModeRouter( command_handler=lambda raw: self._command_router.handle( - raw, session_id=self._session.session_id + raw, session_id=session.session_id ), shell_confirm=lambda _cmd: ( os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip() diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index d155ef726..c9d754d9e 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -2,12 +2,13 @@ from __future__ import annotations +import contextlib import json from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Protocol, runtime_checkable from cleveragents.application.container import get_container from cleveragents.tui.app import CleverAgentsTuiApp, textual_available @@ -15,6 +16,71 @@ from cleveragents.tui.persona.registry import PersonaRegistry from cleveragents.tui.persona.state import PersonaState from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS +# -- Session operation helpers ------------------------------------------------- + +@runtime_checkable +class _TuiAppProtocol(Protocol): + """Minimal interface for TUI app session management.""" + + _sessions: list[Any] + _active_session_index: int + + def _create_session(self, name: str = "") -> Any: ... + def _switch_session(self, session_id: str) -> Any | None: ... + def _close_session(self, session_id: str) -> bool: ... + def _rename_session(self, session_id: str, new_name: str) -> bool: ... + def _list_sessions(self) -> list[Any]: ... + + +class _SessionOps: + """Delegates session CRUD calls to a TUI app instance.""" + + def __init__(self) -> None: + self._app: _TuiAppProtocol | None = None + + def create_session(self, name: str = "") -> Any | None: + """Create a new session, returning its SessionView or ``None``.""" + if self._app is not None: + return self._app._create_session(name) + return None + + def close_session(self, session_id: str) -> bool: + """Close a session by ID, returning True if closed.""" + if self._app is not None: + return self._app._close_session(session_id) + return False + + def switch_session(self, session_id: str) -> Any | None: + """Switch to a session by ID, returning the session or ``None``.""" + if self._app is not None: + self._app._switch_session(session_id) + for s in self._app._sessions: + if s.session_id == session_id: + return s + return None + + @property + def sessions(self) -> list[Any]: + """Return the full session list.""" + if self._app is not None: + return self._app._list_sessions() + return [] + + @property + def active_session_id(self) -> str | None: + """Return the active session ID.""" + if self._app is not None and self._app._sessions: + s = self._app._sessions[self._app._active_session_index] + return s.session_id if self._app._active_session_index >= 0 else None + return None + + @property + def session_count(self) -> int: + """Return the number of sessions.""" + if self._app is not None: + return len(self._app._sessions) + return 0 + @dataclass(slots=True) class TuiCommandRouter: @@ -33,11 +99,20 @@ class TuiCommandRouter: essential for test isolation under ``multiprocessing.fork()`` where ``unittest.mock.patch`` context managers do not propagate to child workers. + session_ops: + Optional helper that delegates session CRUD calls into a TUI app + instance so slash commands like ``/session:create`` can modify + application state. Set via ``session_ops.bind(app)`` after the + app is created. """ persona_registry: PersonaRegistry persona_state: PersonaState container_factory: Callable[[], Any] | None = field(default=None, repr=False) + session_ops: _SessionOps = field( + default_factory=_SessionOps, + repr=False, + ) def _resolve_container(self) -> Any: """Return the DI container, using the injected factory or the global default.""" @@ -105,7 +180,65 @@ class TuiCommandRouter: def _session_command(self, tokens: list[str], *, session_id: str) -> str: if not tokens or tokens[0] == "show": - return f"Current session: {session_id}" + ops = self.session_ops + count = ops.session_count + active_id = ops.active_session_id or "unknown" + return ( + f"Current session: {active_id} (ID={session_id}). " + f"Total sessions: {count}" + ) + + if tokens[0] == "create": + name = " ".join(tokens[1:]) if len(tokens) > 1 else "" + result = self.session_ops.create_session(name) + if result is not None: + return f"Session created: {result.name} ({result.session_id})" + return ( + "Note: Session management requires the TUI app instance. " + "Use Ctrl+N in the TUI to create a session." + ) + + if tokens[0] == "list": + sessions = self.session_ops.sessions + if not sessions: + return "No active sessions" + lines = ["Sessions:"] + for s in sessions: + marker = " (active)" if s.session_id == session_id else "" + lines.append(f" {s.name} ({s.session_id}){marker}") + return "\n".join(lines) + + if tokens[0] == "switch": + if len(tokens) < 2: + return "Usage: /session:switch " + target_id = tokens[1] + session = self.session_ops.switch_session(target_id) + if session is not None: + return f"Switched to session: {target_id} ({session.name})" + return f"Session not found: {target_id}" + + if tokens[0] == "close": + if len(tokens) < 2: + return "Usage: /session:close " + close_id = tokens[1] + if self.session_ops.session_count <= 1: + return "Cannot close the last session" + if self.session_ops.close_session(close_id): + return f"Session closed: {close_id}" + return f"Session not found: {close_id}" + + if tokens[0] == "rename": + if len(tokens) < 3: + return "Usage: /session:rename " + rename_id = tokens[1] + new_name = " ".join(tokens[2:]) + if ( + self.session_ops._app is not None + and self.session_ops._app._rename_session(rename_id, new_name) + ): + return f"Session renamed to: {new_name}" + return f"Session not found: {rename_id}" + if tokens[0] == "export": return self._session_export(tokens[1:], session_id=session_id) if tokens[0] == "import": @@ -223,12 +356,156 @@ 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 """ + + + + + CleverAgents TUI + + + +
+
Loading CleverAgents TUI...
+
+ + +""" + + +def _run_tui_web(app: CleverAgentsTuiApp, *, port: int = 8000) -> int: # type: ignore[valid-type] + """Run the TUI app in web mode via HTTP server. + + Parameters + ---------- + app: + The Textual TUI app instance. + port: + Port for the web server. + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ + try: + # Import web server dependencies + import threading + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + + 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.""" + pass + + # 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) - router = TuiCommandRouter(persona_registry=registry, persona_state=state) + + # Create app instance first so its nested session-management methods are + # accessible via the _SessionOps delegation layer. + ops = _SessionOps() # type: ignore[assignment] + router = TuiCommandRouter( + persona_registry=registry, + persona_state=state, + session_ops=ops, + ) if headless: payload = { @@ -243,5 +520,14 @@ def run_tui(*, headless: bool = False) -> int: return 0 app = CleverAgentsTuiApp(command_router=router, persona_state=state) + # Bind the app's nested TUI instance into the ops delegation layer so + # /session:create, /session:switch, /session:close, /session:rename + # can modify application state directly. + inner = getattr(app, "_textual_clever_agents_tui_app_inner", None) or app + ops._app = inner # type: ignore[attr-defined] + + if web: + return _run_tui_web(app, port=web_port) + app.run() return 0 diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 958867bb5..31e8f2afd 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -79,9 +79,26 @@ class PersonaRegistry: return result def resolve_export_path(self, output_path: Path) -> Path: + """Resolve export path, only allowing paths within the working directory. + + Parameters + ---------- + output_path: + The requested output path (relative or absolute). + + Returns + ------- + The fully resolved path validated to be within the current working directory. + + Raises + ------ + ValueError + If *output_path* is an absolute path or resolves outside the + current working directory. + """ if output_path.is_absolute(): raise ValueError( - "Export path must be relative to current working directory" + "Export path must be relative to current working directory", ) base = Path.cwd().resolve() resolved = (base / output_path).resolve() @@ -90,9 +107,26 @@ class PersonaRegistry: return resolved def resolve_import_path(self, input_path: Path) -> Path: + """Resolve import path, only allowing paths within the working directory. + + Parameters + ---------- + input_path: + The requested input path (relative or absolute). + + Returns + ------- + The fully resolved path validated to be within the current working directory. + + Raises + ------ + ValueError + If *input_path* is an absolute path or resolves outside the + current working directory. + """ if input_path.is_absolute(): raise ValueError( - "Import path must be relative to current working directory" + "Import path must be relative to current working directory", ) base = Path.cwd().resolve() resolved = (base / input_path).resolve() 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)