"""A review pipeline needs an active reviewer, or the task waits forever in a review stage. ``resolve_pipeline`` already falls back from ``plan_code_review_test`` to ``plan_code_test`` when it is told there is no active reviewer. The manager used to leave that flag at its default, so a refactor in an organization without a reviewer went to a stage nobody could act on. The manager now looks the reviewer up in the organization, for the chat order and for triage. The bootstrap team still has no reviewer: a fresh organization gets the plain pipeline, and only a hired, active reviewer gets the review one. """ from __future__ import annotations import pytest_asyncio from sqlalchemy import select from app.application.container_org import build_memory from app.application.engine.manager import ManagerTurns from app.application.engine.runner import TurnRunner from app.core.config import settings from app.domain.base import new_id from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey from app.domain.workforce.pipeline import PipelineKey, Stage, TaskKind, TaskPipelineState from app.infrastructure.db.models import ( AiModelRow, OrganizationRow, RunRow, TaskRow, ThreadRow, UserRow, ) from app.infrastructure.repositories.ai_control import ( AiModelRepository, ModelCredentialRepository, RateCardRepository, ) from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.engine import ( EmployeeProfileRepository, PipelineStateRepository, ) from app.infrastructure.repositories.workforce import ( EmployeeRepository, MessageRepository, RunRepository, RunStepRepository, TaskRepository, ThreadRepository, ToolCallRepository, ) from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply #: One refactor, which the task-kind default sends to the review pipeline. REFACTOR_ORDER = ( '[[CREATE_TASK title="بازنویسی ورود" kind="refactor"]]بازنویسی ماژول ورود[[/CREATE_TASK]]' ) class _Org: def __init__(self, **kwargs) -> None: self.__dict__.update(kwargs) @pytest_asyncio.fixture async def org(session_factory, seeded): """An organization and its owner, with one reasoning and one cheap catalog model.""" async with session_factory() as session: user = UserRow(email=f"rev-{new_id()[:8]}@example.com", password_hash="x") session.add(user) await session.flush() organization = OrganizationRow( name="سازمان بازبین", slug=f"rev-{user.id[:6]}", owner_user_id=user.id ) session.add(organization) await session.commit() models = list( (await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name))) .scalars() .all() ) strong = next(m for m in models if "reasoning" in m.capabilities) fast = next(m for m in models if "reasoning" not in m.capabilities) yield _Org( id=organization.id, user_id=user.id, strong_id=strong.id, fast_id=fast.id, ) def _grants(org: _Org) -> FakeGrants: return FakeGrants({org.strong_id: "strong", org.fast_id: "fast"}) def _manager(session, *, scripted, grants) -> ManagerTurns: """A manager service with real repositories and a scripted model.""" return ManagerTurns( provider=scripted, employees=EmployeeRepository(session), messages=MessageRepository(session), tasks=TaskRepository(session), threads=ThreadRepository(session), models=AiModelRepository(session), credentials=ModelCredentialRepository(session), rate_cards=RateCardRepository(session), ledger=UsageLedgerRepository(session), grants=grants, memory=build_memory(session), profiles=EmployeeProfileRepository(session), states=PipelineStateRepository(session), runner=None, ) async def _order_pipeline( org: _Org, session_factory, *, reviewer_status: EmployeeStatus | None ) -> PipelineKey: """Bootstrap the team, optionally hire a reviewer, then send one refactor order.""" scripted = ScriptedModel([ScriptedReply(content=REFACTOR_ORDER)]) async with session_factory() as session: manager = _manager(session, scripted=scripted, grants=_grants(org)) await manager.bootstrap_team(organization_id=org.id, project_id=None) if reviewer_status is not None: await EmployeeRepository(session).create( Employee( organization_id=org.id, name="رضا", role_key=RoleKey.REVIEWER, status=reviewer_status, ) ) thread = await ThreadRepository(session).create( ThreadRow(organization_id=org.id, title="گفتگو") ) await session.commit() await manager.chat( organization_id=org.id, thread_id=thread.id, content="این ماژول را بازنویسی کن.", user_id=org.user_id, ) await session.commit() task = (await TaskRepository(session).list_for_org(org.id))[0] state = await PipelineStateRepository(session).get(task.id) assert state is not None assert state.kind is TaskKind.REFACTOR return state.pipeline async def test_a_refactor_order_without_a_reviewer_skips_the_review_stage(org, session_factory): pipeline = await _order_pipeline(org, session_factory, reviewer_status=None) assert pipeline is PipelineKey.PLAN_CODE_TEST async def test_a_refactor_order_with_an_active_reviewer_gets_the_review_stage( org, session_factory ): pipeline = await _order_pipeline( org, session_factory, reviewer_status=EmployeeStatus.ACTIVE ) assert pipeline is PipelineKey.PLAN_CODE_REVIEW_TEST async def test_a_paused_reviewer_does_not_count(org, session_factory): pipeline = await _order_pipeline( org, session_factory, reviewer_status=EmployeeStatus.PAUSED ) assert pipeline is PipelineKey.PLAN_CODE_TEST async def _refactor_task(session, organization_id: str) -> tuple[str, str]: task = TaskRow(organization_id=organization_id, title="بازنویسی") session.add(task) await session.flush() run = RunRow(task_id=task.id) session.add(run) await session.flush() # The state starts on the review pipeline, as a refactor order would leave it, so the # assertion below can only pass if triage moved it back to the plain pipeline. await PipelineStateRepository(session).create( TaskPipelineState( task_id=task.id, organization_id=organization_id, pipeline=PipelineKey.PLAN_CODE_REVIEW_TEST, kind=TaskKind.REFACTOR, stage=Stage.PENDING_MANAGER_REVIEW, ) ) await session.flush() return task.id, run.id async def test_triage_of_a_refactor_without_a_reviewer_skips_the_review_stage( org, session_factory, monkeypatch ): monkeypatch.setattr(settings, "engine_max_tool_calls_per_turn", 40) scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee=""/]]')]) grants = _grants(org) async with session_factory() as session: manager = _manager(session, scripted=scripted, grants=grants) await manager.bootstrap_team(organization_id=org.id, project_id=None) manager_row = next( e for e in await EmployeeRepository(session).list_for_org(org.id) if e.role_key is RoleKey.MANAGER ) task_id, run_id = await _refactor_task(session, org.id) await session.commit() manager.runner = TurnRunner( provider=scripted, models=AiModelRepository(session), credentials=ModelCredentialRepository(session), rate_cards=RateCardRepository(session), ledger=UsageLedgerRepository(session), grants=grants, memory=build_memory(session), turns=None, run_steps=RunStepRepository(session), tool_calls=ToolCallRepository(session), runs=RunRepository(session), states=PipelineStateRepository(session), ) task = await TaskRepository(session).get(task_id) outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row) await session.commit() state = await PipelineStateRepository(session).get(task_id) assert outcome.acted is True assert outcome.pipeline is PipelineKey.PLAN_CODE_TEST assert state is not None assert state.pipeline is PipelineKey.PLAN_CODE_TEST