"""Phase J Self-Improvement Plugin Tests — integration tests with real DB. Covers the controlled self-improvement loop: - Signal collection from AgentRun / ProactiveSuggestion / AuditLog - Pattern detection from grouped signals - Proposal creation (draft) and evaluation (LLM mocked) - Activation + rollback of agent config - Tenant isolation - API routes under /api/v1/improvement/ Uses the real PostgreSQL test DB (conftest fixtures) and mocks only the LLM (llm_complete) for deterministic evaluation. """ from __future__ import annotations import json import uuid from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from app.core.db import close_engine, reset_engine_for_testing from app.core.permission_registry import init_permission_registry from app.core.service_container import get_container from app.main import create_app from app.models.audit import AuditLog from app.models.tenant import Tenant from app.models.user import User from app.models.workflow import WorkflowInstance from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion from app.plugins.builtins.automation.models import AgentDefinition, AgentRun, AgentRunStep, AgentVersion from app.plugins.builtins.self_improvement.models import ( ImpactMeasurement, ImprovementPattern, ImprovementProposal, ImprovementSignal, ) from app.plugins.builtins.self_improvement.services import ( activate_proposal, collect_signals, create_proposal, detect_patterns, evaluate_proposal, measure_impact, request_approval, rollback_proposal, ) from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture(scope="session", autouse=True) async def _ensure_improvement_tables(engine: AsyncEngine): """Create the self_improvement tables if they don't exist yet. conftest.db_setup only runs Base.metadata.create_all when the test DB is empty. Since the DB already has tables, new plugin tables (self_improvement) are never created. This fixture runs create_all (idempotent, checkfirst=True) so the improvement_* tables exist before any test runs. """ from app.core.db import Base async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield # ────────────────────────────────────────────────────────────────────────── # Shared seed fixture (service-level tests) # ────────────────────────────────────────────────────────────────────────── @pytest_asyncio.fixture async def seed(db_session): """Create tenant + admin user for service-level tests.""" tenant = Tenant(name="Test Tenant", slug="test-tenant") db_session.add(tenant) await db_session.flush() admin = User( email="admin@test.local", name="Admin", password_hash="$2b$12$placeholder", is_active=True, is_system_admin=True, preferences={}, ) db_session.add(admin) await db_session.flush() return {"tenant": tenant, "admin": admin} @pytest_asyncio.fixture(autouse=True) async def _init_perms(): """Ensure permission registry is initialized for every test.""" init_permission_registry( active_plugin_names={ "permissions", "automation", "ai_proactive", "self_improvement", } ) yield # ────────────────────────────────────────────────────────────────────────── # API fixtures (self_improvement plugin active) # ────────────────────────────────────────────────────────────────────────── @pytest_asyncio.fixture async def improvement_app(engine: AsyncEngine, redis_client): """FastAPI app with self_improvement + dependencies registered and active.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) init_permission_registry( active_plugin_names={ "permissions", "automation", "ai_assistant", "unified_search", "kommunikation", "dms", "ai_proactive", "self_improvement", } ) container = get_container() await container.initialize() from app.plugins.builtins.permissions.plugin import PermissionsPlugin from app.plugins.builtins.automation.plugin import AutomationPlugin from app.plugins.builtins.ai_assistant.plugin import AIAssistantPlugin from app.plugins.builtins.unified_search.plugin import UnifiedSearchPlugin from app.plugins.builtins.kommunikation.plugin import KommunikationPlugin from app.plugins.builtins.dms.plugin import DmsPlugin from app.plugins.builtins.ai_proactive.plugin import AIProactivePlugin from app.plugins.builtins.self_improvement.plugin import SelfImprovementPlugin registry.register_plugin(PermissionsPlugin()) registry.register_plugin(AutomationPlugin()) registry.register_plugin(AIAssistantPlugin()) registry.register_plugin(UnifiedSearchPlugin()) registry.register_plugin(KommunikationPlugin()) registry.register_plugin(DmsPlugin()) registry.register_plugin(AIProactivePlugin()) registry.register_plugin(SelfImprovementPlugin()) reset_plugin_service_for_testing(registry) sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with sf() as session: await registry.install(session, "permissions") await registry.activate(session, "permissions") await registry.install(session, "dms") await registry.activate(session, "dms") await registry.install(session, "kommunikation") await registry.activate(session, "kommunikation") await registry.install(session, "automation") await registry.activate(session, "automation") await registry.install(session, "unified_search") await registry.activate(session, "unified_search") await registry.install(session, "ai_assistant") await registry.activate(session, "ai_assistant") await registry.install(session, "ai_proactive") await registry.activate(session, "ai_proactive") await registry.install(session, "self_improvement") await registry.activate(session, "self_improvement") await session.commit() yield app await close_engine() @pytest_asyncio.fixture async def improvement_client(improvement_app) -> AsyncClient: """HTTP test client with self_improvement plugin active.""" transport = ASGITransport(app=improvement_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def improvement_authed_client( improvement_client: AsyncClient, db_session: AsyncSession ) -> tuple[AsyncClient, dict]: """Authenticated admin client with seeded data and self_improvement active.""" seed = await seed_tenant_and_users(db_session) # Grant is_system_admin so require_permission(automation:*) passes await db_session.execute( update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True) ) await db_session.commit() await login_client(improvement_client, "admin@tenanta.com") return improvement_client, seed # ────────────────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────────────────── def _mock_llm_eval( content: str = ( '{"score": 0.8, "assessment": "Sound proposal", ' '"risks_identified": ["minor"], "recommendation": "approve", ' '"test_scenarios": ["run once"]}' ) ): """Return an AsyncMock for llm_complete returning a JSON evaluation.""" return AsyncMock(return_value={"content": content, "cost_usd": 0.001}) async def _create_agent(db, tenant_id, user_id, **overrides): """Create a real AgentDefinition in the DB and return it.""" agent = AgentDefinition( tenant_id=tenant_id, name=overrides.get("name", "Test Agent"), description=overrides.get("description", "A test agent"), system_prompt=overrides.get("system_prompt", "You are a helpful assistant."), tool_ids=overrides.get("tool_ids", []), temperature=overrides.get("temperature", 0.3), max_tokens=overrides.get("max_tokens", 1000), max_steps=overrides.get("max_steps", 20), created_by=user_id, ) db.add(agent) await db.flush() return agent async def _create_failed_run(db, tenant_id, agent_id, status="failed"): """Create a real AgentRun with a failure status.""" run = AgentRun( tenant_id=tenant_id, agent_id=agent_id, status=status, started_at=datetime.now(UTC), duration_seconds=5.0, error="boom", ) db.add(run) await db.flush() return run async def _create_dismissed_suggestion(db, tenant_id, user_id): """Create a real dismissed ProactiveSuggestion.""" sug = ProactiveSuggestion( tenant_id=tenant_id, user_id=user_id, entity_type="contact", suggestion_type="follow_up", title="Follow up", content="Consider following up", confidence=0.7, is_dismissed=True, ) db.add(sug) await db.flush() return sug # ────────────────────────────────────────────────────────────────────────── # J-SIGNAL: Signal Collection # ────────────────────────────────────────────────────────────────────────── class TestSignalCollection: async def test_signal_collection_from_agent_run(self, db_session, seed): """collect_signals creates ImprovementSignals from failed AgentRuns.""" agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) run = await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed") await db_session.flush() result = await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) assert result["collected"] >= 1 kinds = {s["signal_kind"] for s in result["signals"]} assert "failure" in kinds rows = ( await db_session.execute( select(ImprovementSignal).where( ImprovementSignal.tenant_id == seed["tenant"].id, ImprovementSignal.source_type == "agent_run", ) ) ).scalars().all() assert len(rows) >= 1 assert rows[0].signal_kind == "failure" assert rows[0].severity == "error" assert rows[0].source_ref_id == run.id async def test_signal_collection_from_proactive_suggestion(self, db_session, seed): """collect_signals creates signals from dismissed ProactiveSuggestions.""" await _create_dismissed_suggestion(db_session, seed["tenant"].id, seed["admin"].id) await db_session.flush() result = await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) kinds = {s["signal_kind"] for s in result["signals"]} assert "dismissal" in kinds rows = ( await db_session.execute( select(ImprovementSignal).where( ImprovementSignal.tenant_id == seed["tenant"].id, ImprovementSignal.source_type == "proactive_suggestion", ) ) ).scalars().all() assert len(rows) >= 1 assert rows[0].signal_kind == "dismissal" async def test_signal_collection_from_audit_log(self, db_session, seed): """collect_signals creates correction signals from repeated audit updates.""" for _ in range(3): db_session.add( AuditLog( tenant_id=seed["tenant"].id, user_id=seed["admin"].id, action="update", entity_type="contact", entity_id=uuid.uuid4(), changes={"name": "x"}, ) ) await db_session.flush() result = await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) kinds = {s["signal_kind"] for s in result["signals"]} assert "correction" in kinds async def test_signal_collection_workflow(self, db_session, seed): """collect_signals creates signals from failed WorkflowInstances.""" from app.models.workflow import Workflow wf = Workflow( tenant_id=seed["tenant"].id, name="Test WF", description="", steps=[], is_active=True, created_by=seed["admin"].id, ) db_session.add(wf) await db_session.flush() db_session.add( WorkflowInstance( tenant_id=seed["tenant"].id, workflow_id=wf.id, status="failed", initiated_by=seed["admin"].id, ) ) await db_session.flush() result = await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) kinds = {s["signal_kind"] for s in result["signals"]} assert "failure" in kinds # ────────────────────────────────────────────────────────────────────────── # J-PATTERN: Pattern Detection # ────────────────────────────────────────────────────────────────────────── class TestPatternDetection: async def test_detect_patterns_groups_signals(self, db_session, seed): """detect_patterns groups signals and creates an ImprovementPattern.""" agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) for _ in range(2): await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed") await db_session.flush() await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2) assert result["patterns_created"] >= 1 pattern = result["patterns"][0] assert pattern["pattern_kind"] == "retry_bottleneck" assert pattern["target_type"] == "agent" assert pattern["occurrence_count"] >= 2 rows = ( await db_session.execute( select(ImprovementPattern).where( ImprovementPattern.tenant_id == seed["tenant"].id ) ) ).scalars().all() assert len(rows) >= 1 assert rows[0].status == "detected" async def test_detect_patterns_links_signals(self, db_session, seed): """detect_patterns links signals to the created pattern.""" agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) for _ in range(2): await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed") await db_session.flush() await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2) rows = ( await db_session.execute( select(ImprovementSignal).where( ImprovementSignal.tenant_id == seed["tenant"].id, ImprovementSignal.pattern_id.is_not(None), ) ) ).scalars().all() assert len(rows) >= 2 async def test_detect_patterns_no_signals(self, db_session, seed): """detect_patterns returns empty when no signals exist.""" result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id) assert result["patterns_created"] == 0 assert result["patterns"] == [] # ────────────────────────────────────────────────────────────────────────── # J-PROP: Proposal Creation # ────────────────────────────────────────────────────────────────────────── class TestProposalCreation: async def test_create_proposal_draft(self, db_session, seed): """create_proposal creates an ImprovementProposal in draft status.""" proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Improve agent prompt", description="Tune the system prompt", target_type="agent", target_name="Test Agent", proposed_config={"temperature": 0.1}, rationale="Reduce failures", expected_benefit="Fewer retries", risk_assessment="Low", user_id=seed["admin"].id, ) assert proposal.status == "draft" assert proposal.tenant_id == seed["tenant"].id assert proposal.owner_id == seed["admin"].id assert proposal.proposed_config == {"temperature": 0.1} row = ( await db_session.execute( select(ImprovementProposal).where(ImprovementProposal.id == proposal.id) ) ).scalar_one() assert row.status == "draft" async def test_create_proposal_captures_previous_config(self, db_session, seed): """create_proposal captures previous agent config for rollback.""" agent = await _create_agent( db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3 ) await db_session.flush() proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Tune temp", description="Lower temperature", target_type="agent", target_ref_id=agent.id, target_name=agent.name, proposed_config={"temperature": 0.1}, user_id=seed["admin"].id, ) assert proposal.previous_config.get("temperature") == 0.3 assert proposal.previous_config.get("system_prompt") == agent.system_prompt async def test_create_proposal_links_pattern(self, db_session, seed): """create_proposal links to a pattern and updates its status.""" agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) for _ in range(2): await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed") await db_session.flush() await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) pat_result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2) pattern_id = uuid.UUID(pat_result["patterns"][0]["id"]) proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, pattern_id=pattern_id, title="Fix agent", description="Fix the agent", target_type="agent", target_ref_id=agent.id, target_name=agent.name, proposed_config={"max_steps": 30}, user_id=seed["admin"].id, ) assert proposal.pattern_id == pattern_id assert len(proposal.evidence_refs) >= 1 pattern = ( await db_session.execute( select(ImprovementPattern).where(ImprovementPattern.id == pattern_id) ) ).scalar_one() assert pattern.status == "proposal_created" # ────────────────────────────────────────────────────────────────────────── # J-EVAL: Evaluation # ────────────────────────────────────────────────────────────────────────── class TestProposalEvaluation: async def test_evaluate_proposal_sets_evaluated(self, db_session, seed): """evaluate_proposal sets status to evaluated with mocked LLM.""" proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Evaluate me", description="Assess this change", target_type="agent", target_name="Test Agent", proposed_config={"temperature": 0.1}, rationale="Reduce failures", expected_benefit="Fewer retries", risk_assessment="Low", user_id=seed["admin"].id, ) await db_session.flush() with patch( "app.plugins.builtins.self_improvement.services.llm_complete", new_callable=AsyncMock, return_value={ "content": json.dumps( { "score": 0.8, "assessment": "Sound", "risks_identified": [], "recommendation": "approve", "test_scenarios": ["run"], } ), "cost_usd": 0.001, }, ): result = await evaluate_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id ) assert result["status"] == "evaluated" assert result["evaluation"]["score"] == 0.8 assert result["evaluation"]["recommendation"] == "approve" await db_session.refresh(proposal) assert proposal.status == "evaluated" assert proposal.evaluated_at is not None assert proposal.evaluation_result["score"] == 0.8 async def test_evaluate_proposal_nonexistent(self, db_session, seed): """evaluate_proposal returns error for nonexistent proposal.""" result = await evaluate_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=uuid.uuid4() ) assert "error" in result # ────────────────────────────────────────────────────────────────────────── # J-ACTIVATE: Activation + Rollback # ────────────────────────────────────────────────────────────────────────── class TestActivationAndRollback: async def test_activate_proposal_applies_config(self, db_session, seed): """activate_proposal applies proposed config to the agent.""" agent = await _create_agent( db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3, max_steps=20 ) await db_session.flush() proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Tune agent", description="Lower temperature", target_type="agent", target_ref_id=agent.id, target_name=agent.name, proposed_config={"temperature": 0.1, "max_steps": 30}, user_id=seed["admin"].id, ) proposal.status = "approved" await db_session.flush() result = await activate_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id, approved_by=seed["admin"].id, ) assert result["status"] == "active" assert result["applied"] is True await db_session.refresh(agent) assert agent.temperature == 0.1 assert agent.max_steps == 30 # A version snapshot should have been created versions = ( await db_session.execute( select(AgentVersion).where( AgentVersion.tenant_id == seed["tenant"].id, AgentVersion.agent_id == agent.id, ) ) ).scalars().all() assert len(versions) >= 1 assert versions[0].snapshot["temperature"] == 0.3 async def test_rollback_proposal_restores_config(self, db_session, seed): """rollback_proposal restores the previous agent config.""" agent = await _create_agent( db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3, max_steps=20 ) await db_session.flush() proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Tune agent", description="Lower temperature", target_type="agent", target_ref_id=agent.id, target_name=agent.name, proposed_config={"temperature": 0.1}, user_id=seed["admin"].id, ) proposal.status = "approved" await db_session.flush() await activate_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id, approved_by=seed["admin"].id, ) await db_session.refresh(agent) assert agent.temperature == 0.1 result = await rollback_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id, reason="Regression", ) assert result["status"] == "rolled_back" assert result["restored"] is True await db_session.refresh(agent) assert agent.temperature == 0.3 await db_session.refresh(proposal) assert proposal.rollback_reason == "Regression" async def test_rollback_requires_active(self, db_session, seed): """rollback_proposal rejects a non-active proposal.""" proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Draft", description="Not active", target_type="agent", target_name="x", user_id=seed["admin"].id, ) await db_session.flush() result = await rollback_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id ) assert "error" in result # ────────────────────────────────────────────────────────────────────────── # J-MEASURE: Impact Measurement # ────────────────────────────────────────────────────────────────────────── class TestImpactMeasurement: async def test_measure_impact_creates_measurement(self, db_session, seed): """measure_impact creates an ImpactMeasurement for an active proposal.""" agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) await db_session.flush() proposal = await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Measure", description="Measure impact", target_type="agent", target_ref_id=agent.id, target_name=agent.name, proposed_config={"temperature": 0.1}, user_id=seed["admin"].id, ) proposal.status = "approved" await db_session.flush() await activate_proposal( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id, approved_by=seed["admin"].id, ) result = await measure_impact( db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id ) assert "measurement_id" in result assert "pre_metrics" in result assert "post_metrics" in result assert "delta" in result rows = ( await db_session.execute( select(ImpactMeasurement).where( ImpactMeasurement.tenant_id == seed["tenant"].id, ImpactMeasurement.proposal_id == proposal.id, ) ) ).scalars().all() assert len(rows) == 1 # ────────────────────────────────────────────────────────────────────────── # J-ISOLATION: Tenant Isolation # ────────────────────────────────────────────────────────────────────────── class TestTenantIsolation: async def test_signals_are_tenant_isolated(self, db_session, seed): """Signals created for one tenant are not visible to another.""" tenant_b = Tenant(name="Tenant B", slug="tenant-b") db_session.add(tenant_b) await db_session.flush() agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id) await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed") await db_session.flush() await collect_signals( db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1) ) # Tenant B sees no signals result_b = await collect_signals( db=db_session, tenant_id=tenant_b.id, since=datetime.now(UTC) - timedelta(days=1) ) assert result_b["collected"] == 0 rows_b = ( await db_session.execute( select(ImprovementSignal).where(ImprovementSignal.tenant_id == tenant_b.id) ) ).scalars().all() assert len(rows_b) == 0 async def test_proposals_are_tenant_isolated(self, db_session, seed): """Proposals created for one tenant are not visible to another.""" tenant_b = Tenant(name="Tenant B", slug="tenant-b") db_session.add(tenant_b) await db_session.flush() await create_proposal( db=db_session, tenant_id=seed["tenant"].id, title="Tenant A proposal", description="Only for A", target_type="agent", target_name="x", user_id=seed["admin"].id, ) await db_session.flush() rows_b = ( await db_session.execute( select(ImprovementProposal).where(ImprovementProposal.tenant_id == tenant_b.id) ) ).scalars().all() assert len(rows_b) == 0 rows_a = ( await db_session.execute( select(ImprovementProposal).where( ImprovementProposal.tenant_id == seed["tenant"].id ) ) ).scalars().all() assert len(rows_a) == 1 # ────────────────────────────────────────────────────────────────────────── # J-API: API Routes # ────────────────────────────────────────────────────────────────────────── class TestApiRoutes: async def test_api_signal_collect(self, improvement_authed_client): """POST /api/v1/improvement/signals/collect works.""" client, seed = improvement_authed_client resp = await client.post( "/api/v1/improvement/signals/collect", json={"limit": 10}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, resp.text data = resp.json() assert "collected" in data assert "signals" in data async def test_api_proposal_create(self, improvement_authed_client): """POST /api/v1/improvement/proposals works.""" client, seed = improvement_authed_client resp = await client.post( "/api/v1/improvement/proposals", json={ "title": "API proposal", "description": "Created via API", "target_type": "agent", "target_name": "Test Agent", "proposed_config": {"temperature": 0.1}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, resp.text data = resp.json() assert data["status"] == "draft" assert data["title"] == "API proposal" assert "id" in data async def test_api_proposal_create_missing_fields(self, improvement_authed_client): """POST /api/v1/improvement/proposals rejects missing title/target_type.""" client, _ = improvement_authed_client resp = await client.post( "/api/v1/improvement/proposals", json={"description": "no title"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 400, resp.text async def test_api_proposal_list(self, improvement_authed_client): """GET /api/v1/improvement/proposals lists proposals.""" client, _ = improvement_authed_client resp = await client.get("/api/v1/improvement/proposals", headers=ORIGIN_HEADER) assert resp.status_code == 200, resp.text data = resp.json() assert "items" in data assert "total" in data async def test_api_proposal_evaluate(self, improvement_authed_client): """POST /api/v1/improvement/proposals/{id}/evaluate works with mocked LLM.""" client, seed = improvement_authed_client create_resp = await client.post( "/api/v1/improvement/proposals", json={ "title": "Evaluate via API", "description": "Assess", "target_type": "agent", "target_name": "Test Agent", "proposed_config": {"temperature": 0.1}, }, headers=ORIGIN_HEADER, ) assert create_resp.status_code == 200, create_resp.text proposal_id = create_resp.json()["id"] with patch( "app.plugins.builtins.self_improvement.services.llm_complete", new_callable=AsyncMock, return_value={ "content": json.dumps( { "score": 0.7, "assessment": "OK", "risks_identified": [], "recommendation": "approve", "test_scenarios": [], } ), "cost_usd": 0.001, }, ): resp = await client.post( f"/api/v1/improvement/proposals/{proposal_id}/evaluate", headers=ORIGIN_HEADER, ) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "evaluated" async def test_api_proposal_activate_and_rollback(self, improvement_authed_client, db_session): """POST activate + rollback via API works end-to-end.""" client, seed = improvement_authed_client agent = await _create_agent(db_session, seed["tenant_a"].id, seed["admin_a"].id) await db_session.commit() create_resp = await client.post( "/api/v1/improvement/proposals", json={ "title": "Activate via API", "description": "Apply config", "target_type": "agent", "target_ref_id": str(agent.id), "target_name": agent.name, "proposed_config": {"temperature": 0.1}, }, headers=ORIGIN_HEADER, ) assert create_resp.status_code == 200, create_resp.text proposal_id = create_resp.json()["id"] # Set to approved directly (approval flow is covered by service tests) await db_session.execute( update(ImprovementProposal) .where(ImprovementProposal.id == uuid.UUID(proposal_id)) .values(status="approved") ) await db_session.commit() act_resp = await client.post( f"/api/v1/improvement/proposals/{proposal_id}/activate", headers=ORIGIN_HEADER, ) assert act_resp.status_code == 200, act_resp.text assert act_resp.json()["status"] == "active" await db_session.refresh(agent) assert agent.temperature == 0.1 roll_resp = await client.post( f"/api/v1/improvement/proposals/{proposal_id}/rollback", json={"reason": "test"}, headers=ORIGIN_HEADER, ) assert roll_resp.status_code == 200, roll_resp.text assert roll_resp.json()["status"] == "rolled_back" await db_session.refresh(agent) assert agent.temperature == 0.3