test(a2a): add integration tests for full A2A session and plan lifecycle

- Added robot/a2a_session_plan_lifecycle_integration.robot: Robot Framework integration test suite with 7 test cases covering the full A2A session and plan lifecycle, event queue publish/subscribe, guard enforcement (budget cap, tool call limit, denylist), and plan rollback with session state consistency.

- Added robot/helper_a2a_session_plan_lifecycle_integration.py: Helper script with real service instances (no mocks) using in-memory SQLite, wiring up A2A facade, session service, plan lifecycle service, event queue, and autonomy guardrail service.

ISSUES CLOSED: #10032
This commit is contained in:
2026-04-19 12:29:41 +00:00
committed by Forgejo
parent 446f447c03
commit d306e6e33c
2 changed files with 730 additions and 0 deletions
@@ -0,0 +1,101 @@
*** Settings ***
Documentation Integration tests for the full A2A session and plan lifecycle.
... Covers: session create/list/show/close/delete, plan create/execute/
... status/cancel, event queue publish/subscribe, guard enforcement
... (budget cap, tool call limit, denylist), and plan rollback with
... session state consistency.
...
... All tests use real service instances (no mocks) wired via the
... full DI-compatible fixture in helper_a2a_session_plan_lifecycle_integration.py.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_a2a_session_plan_lifecycle_integration.py
*** Test Cases ***
A2A Full Session Lifecycle Create List Show Close Delete
[Documentation] Verify full session lifecycle: create via A2A facade,
... list via session service, show (get) by ID, close via
... facade (which deletes), and verify deletion.
[Tags] a2a session lifecycle integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} session-full-lifecycle
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-full-lifecycle-ok
A2A Full Plan Lifecycle Create Execute Status Cancel
[Documentation] Verify full plan lifecycle within a session: create plan via
... A2A facade, get status, advance through strategize, execute,
... verify execute phase, cancel, and verify cancellation.
[Tags] a2a plan lifecycle integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} plan-full-lifecycle
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-full-lifecycle-ok
A2A Event Queue Receives Published Events During Lifecycle
[Documentation] Verify that the A2A event queue receives published events
... during plan lifecycle transitions, subscriber callbacks are
... invoked, and unsubscribe stops delivery.
[Tags] a2a events queue integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} event-queue-receives-events
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} event-queue-receives-events-ok
A2A Guard Enforcement Budget Cap Exceeded Halts Execution
[Documentation] Verify that the autonomy guardrail service blocks plan
... execution when the budget cap is exceeded, records the
... denial in the audit trail, and maintains consistent state.
[Tags] a2a guard budget integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} guard-budget-cap
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} guard-budget-cap-ok
A2A Guard Enforcement Tool Call Limit Exceeded Halts Execution
[Documentation] Verify that the autonomy guardrail service blocks plan
... execution when the per-actor tool call limit is exceeded,
... and records the denial in the audit trail.
[Tags] a2a guard tool_limit integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} guard-tool-call-limit
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} guard-tool-call-limit-ok
A2A Guard Enforcement Denylist Violation Blocks Tool Invocation
[Documentation] Verify that the automation profile denylist blocks tool
... invocations for denied tools and allows permitted tools,
... both via direct profile check and via AutomationProfileService.
[Tags] a2a guard denylist integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} guard-denylist
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} guard-denylist-ok
A2A Plan Rollback After Failed Execution Session State Consistent
[Documentation] Verify that after a plan execution failure, the plan is in
... ERRORED state, the session remains accessible and consistent,
... and the plan list is not corrupted. Session can be deleted
... cleanly after the failure.
[Tags] a2a plan rollback session integration issue_10032
${result}= Run Process ${PYTHON} ${HELPER} plan-rollback-session-consistent
... cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-rollback-session-consistent-ok
@@ -0,0 +1,629 @@
"""Helper script for A2A session and plan lifecycle integration tests.
Exercises the complete flow: session create -> plan create -> plan execute ->
plan status -> plan cancel/rollback -> session close -> session delete.
Also covers:
- Event queue receives published events during plan lifecycle
- Guard enforcement (budget cap, tool call limit) during plan execution
- Plan rollback after failed execution -> session state consistent
Usage:
python robot/helper_a2a_session_plan_lifecycle_integration.py <command>
Commands:
session-full-lifecycle Full session create/list/show/close/delete
plan-full-lifecycle Full plan create/execute/status/cancel
event-queue-receives-events Event queue receives events during lifecycle
guard-budget-cap Budget cap exceeded halts execution
guard-tool-call-limit Tool call limit exceeded halts execution
guard-denylist Denylist violation blocks tool invocation
plan-rollback-session-consistent Plan rollback, session state consistent
"""
# ruff: noqa: E402, I001
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aEvent, A2aRequest
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.automation_guard import AutomationGuard
from cleveragents.domain.models.core.autonomy_guardrails import (
ActorLimits,
AutonomyGuardrails,
)
from cleveragents.domain.models.core.plan import (
ProcessingState,
ProjectLink,
)
from cleveragents.domain.models.core.session import SessionNotFoundError
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
SessionMessageRepository,
SessionRepository,
)
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _make_db_session_factory() -> Any:
"""Create an in-memory SQLite engine and return a session factory."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return factory
def _make_plan_lifecycle_service() -> PlanLifecycleService:
"""Create a PlanLifecycleService with default settings."""
settings = Settings()
return PlanLifecycleService(settings=settings)
def _make_full_facade() -> tuple[
A2aLocalFacade,
PersistentSessionService,
PlanLifecycleService,
A2aEventQueue,
Any,
]:
"""Bootstrap the full DI-wired facade with real services.
Returns:
Tuple of (facade, session_service, plan_lifecycle_service,
event_queue, db_session)
"""
factory = _make_db_session_factory()
db_session = factory()
def get_db() -> Any:
return db_session
session_repo = SessionRepository(get_db)
message_repo = SessionMessageRepository(get_db)
session_svc = PersistentSessionService(session_repo, message_repo)
plan_svc = _make_plan_lifecycle_service()
event_queue = A2aEventQueue()
facade = A2aLocalFacade(
services={
"session_service": session_svc,
"plan_lifecycle_service": plan_svc,
"event_queue": event_queue,
}
)
return facade, session_svc, plan_svc, event_queue, db_session
# ---------------------------------------------------------------------------
# Test: Full session lifecycle
# ---------------------------------------------------------------------------
def _session_full_lifecycle() -> None:
"""Integration test: session create -> list -> show -> close -> delete."""
facade, session_svc, _plan_svc, _eq, db_session = _make_full_facade()
# 1. Create session via A2A facade
create_req = A2aRequest(
method="session.create",
params={"actor_name": "local/orchestrator"},
)
create_resp = facade.dispatch(create_req)
assert create_resp.error is None, f"session.create failed: {create_resp.error}"
assert create_resp.result is not None
session_id = create_resp.result["session_id"]
assert session_id, "Expected a session_id in response"
db_session.commit()
# 2. List sessions via session service (real service, not facade)
sessions = session_svc.list()
assert len(sessions) >= 1, f"Expected at least 1 session, got {len(sessions)}"
session_ids = [s.session_id for s in sessions]
assert session_id in session_ids, (
f"Created session {session_id} not in list"
)
# 3. Show (get) session by ID
fetched = session_svc.get(session_id)
assert fetched.session_id == session_id
assert fetched.actor_name == "local/orchestrator"
# 4. Close session via A2A facade (maps to session.close -> delete)
close_req = A2aRequest(
method="session.close",
params={"session_id": session_id},
)
close_resp = facade.dispatch(close_req)
assert close_resp.error is None, f"session.close failed: {close_resp.error}"
assert close_resp.result is not None
assert close_resp.result["status"] == "closed"
db_session.commit()
# 5. Verify session is deleted (get should raise SessionNotFoundError)
try:
session_svc.get(session_id)
print("FAIL: Expected SessionNotFoundError after close", file=sys.stderr)
sys.exit(1)
except SessionNotFoundError:
pass # Expected
print("session-full-lifecycle-ok")
# ---------------------------------------------------------------------------
# Test: Full plan lifecycle within a session
# ---------------------------------------------------------------------------
def _plan_full_lifecycle() -> None:
"""Integration test: plan create -> execute -> status -> cancel."""
facade, session_svc, plan_svc, _eq, db_session = _make_full_facade()
# Create a session first
session = session_svc.create(actor_name="local/planner")
db_session.commit()
# Create an action for the plan
action = plan_svc.create_action(
name="local/integration-test-action",
description="Integration test action",
definition_of_done="All integration tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
# 1. Create plan via A2A facade (plan.create)
create_req = A2aRequest(
method="plan.create",
params={
"action_name": str(action.namespaced_name),
"created_by": session.session_id,
},
)
create_resp = facade.dispatch(create_req)
assert create_resp.error is None, f"plan.create failed: {create_resp.error}"
assert create_resp.result is not None
plan_id = create_resp.result["plan_id"]
assert plan_id, "Expected a plan_id in response"
assert create_resp.result["status"] == "created"
# 2. Get plan status via A2A facade (plan.status)
status_req = A2aRequest(method="plan.status", params={"plan_id": plan_id})
status_resp = facade.dispatch(status_req)
assert status_resp.error is None, f"plan.status failed: {status_resp.error}"
assert status_resp.result is not None
assert status_resp.result["plan_id"] == plan_id
assert status_resp.result["phase"] == "strategize"
# 3. Advance plan through strategize phase
plan_svc.start_strategize(plan_id)
plan_svc.complete_strategize(plan_id)
# 4. Execute plan via A2A facade (plan.execute)
execute_req = A2aRequest(method="plan.execute", params={"plan_id": plan_id})
execute_resp = facade.dispatch(execute_req)
assert execute_resp.error is None, f"plan.execute failed: {execute_resp.error}"
assert execute_resp.result is not None
assert execute_resp.result["plan_id"] == plan_id
# 5. Verify plan is now in execute phase
status_resp2 = facade.dispatch(
A2aRequest(method="plan.status", params={"plan_id": plan_id})
)
assert status_resp2.result is not None
assert status_resp2.result["phase"] == "execute"
# 6. Cancel plan via A2A facade (_cleveragents/plan/cancel)
cancel_req = A2aRequest(
method="_cleveragents/plan/cancel",
params={"plan_id": plan_id},
)
cancel_resp = facade.dispatch(cancel_req)
assert cancel_resp.error is None, f"plan.cancel failed: {cancel_resp.error}"
assert cancel_resp.result is not None
assert cancel_resp.result["status"] == "cancelled"
# 7. Verify plan is cancelled
plan = plan_svc.get_plan(plan_id)
assert plan.state == ProcessingState.CANCELLED, (
f"Expected CANCELLED, got {plan.state}"
)
# 8. Close session
session_svc.delete(session.session_id)
db_session.commit()
print("plan-full-lifecycle-ok")
# ---------------------------------------------------------------------------
# Test: Event queue receives events during plan lifecycle
# ---------------------------------------------------------------------------
def _event_queue_receives_events() -> None:
"""Integration test: event queue receives published events during lifecycle."""
event_queue = A2aEventQueue()
received_events: list[A2aEvent] = []
# Subscribe to event queue
sub_id = event_queue.subscribe_local(lambda e: received_events.append(e))
assert sub_id, "Expected a subscription ID"
# Publish events simulating plan lifecycle transitions
event_queue.publish(A2aEvent(
event_type="TaskStatusUpdateEvent",
plan_id="test-plan-001",
data={"phase": "strategize", "state": "queued"},
))
event_queue.publish(A2aEvent(
event_type="TaskStatusUpdateEvent",
plan_id="test-plan-001",
data={"phase": "execute", "state": "processing"},
))
event_queue.publish(A2aEvent(
event_type="TaskStatusUpdateEvent",
plan_id="test-plan-001",
data={"phase": "apply", "state": "applied"},
))
# Verify subscriber received all events
assert len(received_events) == 3, (
f"Expected 3 events, got {len(received_events)}"
)
assert received_events[0].event_type == "TaskStatusUpdateEvent"
assert received_events[0].plan_id == "test-plan-001"
assert received_events[0].data["phase"] == "strategize"
assert received_events[1].data["phase"] == "execute"
assert received_events[2].data["phase"] == "apply"
# Verify events are stored in queue
stored = event_queue.get_events(limit=10)
assert len(stored) == 3
# Unsubscribe and verify no more events received
removed = event_queue.unsubscribe(sub_id)
assert removed is True
event_queue.publish(A2aEvent(
event_type="TaskStatusUpdateEvent",
plan_id="test-plan-001",
data={"phase": "done"},
))
assert len(received_events) == 3, "Should not receive events after unsubscribe"
# Verify event queue integration with A2A facade
facade = A2aLocalFacade(services={"event_queue": event_queue})
subscribe_req = A2aRequest(method="event.subscribe", params={})
subscribe_resp = facade.dispatch(subscribe_req)
assert subscribe_resp.error is None, (
f"event.subscribe failed: {subscribe_resp.error}"
)
assert subscribe_resp.result is not None
assert subscribe_resp.result["status"] == "subscribed"
assert "subscription_id" in subscribe_resp.result
print("event-queue-receives-events-ok")
# ---------------------------------------------------------------------------
# Test: Guard enforcement -- budget cap exceeded
# ---------------------------------------------------------------------------
def _guard_budget_cap() -> None:
"""Integration test: budget cap exceeded halts plan execution.
The AutonomyGuardrailService.check_tool_budget() returns bool:
- True if the invocation is within budget (and records the cost)
- False if blocked
"""
plan_svc = _make_plan_lifecycle_service()
guardrail_svc = AutonomyGuardrailService()
# Create action and plan
action = plan_svc.create_action(
name="local/budget-test-action",
description="Budget cap test",
definition_of_done="Test budget enforcement",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = plan_svc.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-budget")],
)
plan_id = plan.identity.plan_id
# Configure guardrails with a tight budget cap of 10.0
guardrails = AutonomyGuardrails(
tool_budget=10.0,
budget_spent=0.0,
)
guardrail_svc.configure_guardrails(plan_id, guardrails)
# Verify budget allows within cap (cost 5.0 < budget 10.0)
# check_tool_budget records the cost when allowed
allowed = guardrail_svc.check_tool_budget(plan_id, 5.0)
assert allowed is True, "Expected budget to allow cost of 5.0 within cap of 10.0"
# After recording 5.0, budget_spent = 5.0
# Now try to spend 8.0 more (5.0 + 8.0 = 13.0 > 10.0 cap)
allowed2 = guardrail_svc.check_tool_budget(plan_id, 8.0)
assert allowed2 is False, (
"Expected budget cap to block cost of 8.0 when 5.0 already spent"
)
# Verify audit trail recorded both the allow and the denial
trail = guardrail_svc.get_audit_trail(plan_id)
assert len(trail.entries) >= 2, (
f"Expected at least 2 audit entries, got {len(trail.entries)}"
)
denied_entries = [e for e in trail.entries if e.result.value == "denied"]
assert len(denied_entries) >= 1, "Expected at least one denied audit entry"
# Verify guardrails state is consistent (budget_spent = 5.0 after first call)
g = guardrail_svc.get_guardrails(plan_id)
assert g is not None
assert g.budget_spent == 5.0, (
f"Expected budget_spent=5.0 (only first call was allowed), got {g.budget_spent}"
)
print("guard-budget-cap-ok")
# ---------------------------------------------------------------------------
# Test: Guard enforcement -- tool call limit exceeded
# ---------------------------------------------------------------------------
def _guard_tool_call_limit() -> None:
"""Integration test: tool call limit exceeded halts execution.
The AutonomyGuardrailService.check_actor_tool_calls() returns bool:
- True if allowed
- False if blocked
"""
plan_svc = _make_plan_lifecycle_service()
guardrail_svc = AutonomyGuardrailService()
# Create action and plan
action = plan_svc.create_action(
name="local/tool-limit-action",
description="Tool call limit test",
definition_of_done="Test tool call limit enforcement",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = plan_svc.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-tool-limit")],
)
plan_id = plan.identity.plan_id
# Configure guardrails with a tool call limit of 3
guardrails = AutonomyGuardrails(
actor_limits=ActorLimits(max_tool_calls_per_invocation=3),
)
guardrail_svc.configure_guardrails(plan_id, guardrails)
# Verify tool calls allowed within limit (1 < 3, 2 < 3)
allowed1 = guardrail_svc.check_actor_tool_calls(plan_id, 1)
assert allowed1 is True, "Expected tool call 1 to be allowed (limit=3)"
allowed2 = guardrail_svc.check_actor_tool_calls(plan_id, 2)
assert allowed2 is True, "Expected tool call 2 to be allowed (limit=3)"
# Verify tool calls blocked at limit (3 >= 3)
blocked = guardrail_svc.check_actor_tool_calls(plan_id, 3)
assert blocked is False, "Expected tool call limit to block at 3 (limit=3)"
# Verify audit trail recorded the denial
trail = guardrail_svc.get_audit_trail(plan_id)
denied_entries = [e for e in trail.entries if e.result.value == "denied"]
assert len(denied_entries) >= 1, "Expected at least one denied audit entry"
print("guard-tool-call-limit-ok")
# ---------------------------------------------------------------------------
# Test: Guard enforcement -- denylist violation
# ---------------------------------------------------------------------------
def _guard_denylist() -> None:
"""Integration test: denylist violation blocks tool invocation.
Uses AutomationProfile.check_guard() directly to verify denylist enforcement.
AutomationProfile uses 'name' field (not 'profile_name').
GuardScope defaults to PLAN when not specified.
"""
from cleveragents.domain.models.core.automation_profile import AutomationProfile
# Create an automation profile with a denylist
# AutomationProfile uses 'name' field, not 'profile_name'
profile = AutomationProfile(
name="restricted",
guards=AutomationGuard(
tool_denylist=["rm", "delete_file", "drop_table"],
),
)
# Evaluate guard for a denied tool (scope defaults to PLAN)
result = profile.check_guard(tool_name="rm")
assert result.allowed is False, (
f"Expected 'rm' to be denied by denylist, got allowed={result.allowed}"
)
assert result.reason is not None
assert "denylist" in result.reason.lower() or "deny" in result.reason.lower(), (
f"Expected denylist-related reason, got: {result.reason}"
)
# Evaluate guard for another denied tool
result2 = profile.check_guard(tool_name="delete_file")
assert result2.allowed is False, "Expected 'delete_file' to be denied"
# Evaluate guard for an allowed tool (not in denylist)
result3 = profile.check_guard(tool_name="read_file")
assert result3.allowed is True, (
f"Expected 'read_file' to be allowed, got allowed={result3.allowed}"
)
# Verify allowlist enforcement: tool not in allowlist is blocked
profile_with_allowlist = AutomationProfile(
name="allowlist-only",
guards=AutomationGuard(
tool_allowlist=["read_file", "list_files"],
),
)
# Tool not in allowlist should be blocked
result4 = profile_with_allowlist.check_guard(tool_name="drop_table")
assert result4.allowed is False, (
"Expected 'drop_table' to be denied by allowlist (not in list)"
)
# Tool in allowlist should be allowed
result5 = profile_with_allowlist.check_guard(tool_name="read_file")
assert result5.allowed is True, (
"Expected 'read_file' to be allowed (in allowlist)"
)
print("guard-denylist-ok")
# ---------------------------------------------------------------------------
# Test: Plan rollback after failed execution -> session state consistent
# ---------------------------------------------------------------------------
def _plan_rollback_session_consistent() -> None:
"""Integration test: plan rollback after failed execution, session consistent.
Note: ERRORED state IS terminal per the spec (is_terminal returns True).
The test verifies session remains accessible and plan list is not corrupted.
"""
_facade, session_svc, plan_svc, _eq, db_session = _make_full_facade()
# Create a session
session = session_svc.create(actor_name="local/rollback-tester")
db_session.commit()
# Create action and plan
action = plan_svc.create_action(
name="local/rollback-test-action",
description="Rollback test action",
definition_of_done="Test rollback consistency",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = plan_svc.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-rollback")],
created_by=session.session_id,
)
plan_id = plan.identity.plan_id
# Advance to execute phase
plan_svc.start_strategize(plan_id)
plan_svc.complete_strategize(plan_id)
plan_svc.execute_plan(plan_id)
plan_svc.start_execute(plan_id)
# Simulate execution failure
failed_plan = plan_svc.fail_execute(plan_id, "Simulated execution failure")
assert failed_plan.state == ProcessingState.ERRORED, (
f"Expected ERRORED state, got {failed_plan.state}"
)
assert failed_plan.error_message == "Simulated execution failure"
assert failed_plan.is_errored
# ERRORED is terminal per spec (is_terminal returns True for ERRORED)
assert failed_plan.is_terminal is True, (
"ERRORED state should be terminal per spec"
)
# Verify session is still accessible (consistent state)
session_still_exists = session_svc.get(session.session_id)
assert session_still_exists.session_id == session.session_id, (
"Session should still exist after plan failure"
)
# Verify plan list still works (no corruption)
plans = plan_svc.list_plans()
plan_ids = [p.identity.plan_id for p in plans]
assert plan_id in plan_ids, "Failed plan should still be in plan list"
# Verify the failed plan can be retrieved
retrieved = plan_svc.get_plan(plan_id)
assert retrieved.state == ProcessingState.ERRORED
assert retrieved.is_terminal is True # ERRORED is terminal per spec
# Clean up: delete session
session_svc.delete(session.session_id)
db_session.commit()
# Verify session is gone
try:
session_svc.get(session.session_id)
print("FAIL: Expected SessionNotFoundError after delete", file=sys.stderr)
sys.exit(1)
except SessionNotFoundError:
pass
print("plan-rollback-session-consistent-ok")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
_COMMANDS = {
"session-full-lifecycle": _session_full_lifecycle,
"plan-full-lifecycle": _plan_full_lifecycle,
"event-queue-receives-events": _event_queue_receives_events,
"guard-budget-cap": _guard_budget_cap,
"guard-tool-call-limit": _guard_tool_call_limit,
"guard-denylist": _guard_denylist,
"plan-rollback-session-consistent": _plan_rollback_session_consistent,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
file=sys.stderr,
)
sys.exit(2)
_COMMANDS[sys.argv[1]]()
if __name__ == "__main__":
main()