|
|
|
@@ -0,0 +1,828 @@
|
|
|
|
|
"""The manager AI: chat → tasks → team, triage, final verdict, bootstrap.
|
|
|
|
|
|
|
|
|
|
What these tests defend:
|
|
|
|
|
|
|
|
|
|
* an order becomes **at most three** tasks, each with its own pipeline state at
|
|
|
|
|
``pending_manager_review`` and a link back to the thread
|
|
|
|
|
* HIRE respects the employee cap and picks a **granted** model for the tier;
|
|
|
|
|
the last manager cannot be fired
|
|
|
|
|
* ``ASK_USER`` parks the task with a human and asks in the thread
|
|
|
|
|
* triage assigns to the least busy employee, and a name is matched **inside
|
|
|
|
|
the org only** — the manager of org A can never assign an employee of org B,
|
|
|
|
|
even by exact name
|
|
|
|
|
* ``COMPLETE`` merges, ``REVISE`` goes back to the developer with cycle + 1
|
|
|
|
|
* ``KNOWLEDGE`` tags become org memory
|
|
|
|
|
* bootstrap is idempotent, and with no grants it creates an inactive team plus
|
|
|
|
|
the "waiting for the owner" message
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
from app.application.container_org import build_memory
|
|
|
|
|
from app.application.engine.manager import (
|
|
|
|
|
NO_MODEL_GRANTED_MESSAGE,
|
|
|
|
|
ManagerTurns,
|
|
|
|
|
)
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.errors import ConflictError, NotFoundError
|
|
|
|
|
from app.domain.base import new_id
|
|
|
|
|
from app.domain.workforce.engine import EmployeeProfile
|
|
|
|
|
from app.domain.workforce.models import Employee, RoleKey
|
|
|
|
|
from app.domain.workforce.pipeline import PipelineKey, Stage, TaskPipelineState
|
|
|
|
|
from app.infrastructure.db.models import (
|
|
|
|
|
AiModelRow,
|
|
|
|
|
AiProviderRow,
|
|
|
|
|
EmployeeRow,
|
|
|
|
|
OrganizationRow,
|
|
|
|
|
RunRow,
|
|
|
|
|
TaskRow,
|
|
|
|
|
ThreadRow,
|
|
|
|
|
UserRow,
|
|
|
|
|
)
|
|
|
|
|
from app.infrastructure.db.models_org import OrgMemoryRow
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def world(session_factory, seeded):
|
|
|
|
|
"""One organization with a manager, a developer, a granted model."""
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
user = UserRow(email=f"mgr-{new_id()[:8]}@example.com", password_hash="x")
|
|
|
|
|
session.add(user)
|
|
|
|
|
await session.flush()
|
|
|
|
|
org = OrganizationRow(name="سازمان مدیر", slug=f"mgr-{user.id[:6]}", owner_user_id=user.id)
|
|
|
|
|
session.add(org)
|
|
|
|
|
await session.flush()
|
|
|
|
|
provider_id = (await session.execute(select(AiProviderRow.id).limit(1))).scalar_one()
|
|
|
|
|
strong = await AiModelRepository(session).create(
|
|
|
|
|
AiModelRow(
|
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
internal_name=f"strong-{new_id()[:4]}",
|
|
|
|
|
display_name_for_admin="مدل قوی",
|
|
|
|
|
capabilities=["text", "code", "reasoning"],
|
|
|
|
|
input_price_per_1m=0.5,
|
|
|
|
|
output_price_per_1m=3.0,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
fast = await AiModelRepository(session).create(
|
|
|
|
|
AiModelRow(
|
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
internal_name=f"fast-{new_id()[:4]}",
|
|
|
|
|
display_name_for_admin="مدل سریع",
|
|
|
|
|
capabilities=["text", "code"],
|
|
|
|
|
input_price_per_1m=0.1,
|
|
|
|
|
output_price_per_1m=0.5,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
yield _World(
|
|
|
|
|
session_factory=session_factory,
|
|
|
|
|
organization_id=org.id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
strong_id=strong.id,
|
|
|
|
|
fast_id=fast.id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _World:
|
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
|
|
|
self.__dict__.update(kwargs)
|
|
|
|
|
|
|
|
|
|
async def employee(self, session, name: str, role: RoleKey = RoleKey.DEVELOPER):
|
|
|
|
|
return await EmployeeRepository(session).create(
|
|
|
|
|
Employee(organization_id=self.organization_id, name=name, role_key=role)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _manager(world, session, *, scripted, grants, employee_ids=None) -> ManagerTurns:
|
|
|
|
|
"""A manager service with real repositories and scripted everything else."""
|
|
|
|
|
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 _with_model_profile(world, session, employee_id: str, model_id: str) -> None:
|
|
|
|
|
await EmployeeProfileRepository(session).upsert(
|
|
|
|
|
EmployeeProfile(employee_id=employee_id, ai_model_id=model_id, roles=["manager"])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 3.1 chat: an order becomes tasks
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_an_order_becomes_tasks_with_pipeline_state(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel(
|
|
|
|
|
[
|
|
|
|
|
ScriptedReply(
|
|
|
|
|
content=(
|
|
|
|
|
"باشه، دو کار میسازم.\n"
|
|
|
|
|
'[[CREATE_TASK title="فرم ورود" kind="feature"]]'
|
|
|
|
|
"ساخت فرم ورود با اعتبارسنجی[[/CREATE_TASK]]"
|
|
|
|
|
'[[CREATE_TASK title="تست فرم" kind="feature" depends_on=""]]'
|
|
|
|
|
"تست فرم ورود[[/CREATE_TASK]]"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
|
|
|
|
await world.employee(session, "سارا", RoleKey.DEVELOPER)
|
|
|
|
|
await _with_model_profile(world, session, manager_id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
user_message, assistant = await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="یک صفحهٔ ورود با تست بساز.",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
tasks = await TaskRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
states = PipelineStateRepository(session)
|
|
|
|
|
|
|
|
|
|
assert "باشه" in assistant.content
|
|
|
|
|
assert "کار ساخته شد: فرم ورود" in assistant.content
|
|
|
|
|
assert len(tasks) == 2
|
|
|
|
|
assert tasks[0].thread_id == thread.id
|
|
|
|
|
for task in tasks:
|
|
|
|
|
state = await states.get(task.id)
|
|
|
|
|
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
|
|
|
|
assert state.pipeline is PipelineKey.PLAN_CODE_TEST
|
|
|
|
|
assert state.organization_id == world.organization_id
|
|
|
|
|
# The task-created event went out, so the manager chat shows the cards live.
|
|
|
|
|
assert scripted.calls == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_at_most_three_tasks_per_order(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel(
|
|
|
|
|
[
|
|
|
|
|
ScriptedReply(
|
|
|
|
|
content="".join(
|
|
|
|
|
f'[[CREATE_TASK title="کار {i}"]]توضیح[[/CREATE_TASK]]' for i in range(5)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
|
|
|
|
await _with_model_profile(world, session, manager_id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
_user, assistant = await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="پنج کار بساز.",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
tasks = await TaskRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
|
|
|
|
|
# Three created, two refused by the protocol limit, and the user is told.
|
|
|
|
|
assert len(tasks) == 3
|
|
|
|
|
assert assistant.content.count("کار ساخته شد") == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_the_chat_prompt_asks_for_atomic_tasks(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="سلام")])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
|
|
|
|
await _with_model_profile(world, session, manager_id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="یک کار بساز.",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
prompt = scripted.all_text()
|
|
|
|
|
assert "اتمیک" in prompt # atomic tasks that do not touch the same files
|
|
|
|
|
assert "همزمان" in prompt
|
|
|
|
|
assert "مدیر یک" in prompt # the roster is in the context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_chat_model_call_is_billed(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="سلام", input_tokens=9, output_tokens=4)])
|
|
|
|
|
from app.infrastructure.db.models import UsageEventRow
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
|
|
|
|
await _with_model_profile(world, session, manager_id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="سلام",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
rows = (
|
|
|
|
|
(
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(UsageEventRow).where(
|
|
|
|
|
UsageEventRow.organization_id == world.organization_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.scalars()
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
assert sum(r.quantity for r in rows if r.unit == "model_input_tokens") == 9
|
|
|
|
|
assert sum(r.quantity for r in rows if r.unit == "model_output_tokens") == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_chat_without_grants_explains_itself(world, session_factory):
|
|
|
|
|
"""No grant, no answer — and the user is told why, in Persian."""
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="سلام")])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
|
|
|
|
await _with_model_profile(world, session, manager_id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(world, session, scripted=scripted, grants=FakeGrants({}))
|
|
|
|
|
_user, assistant = await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="سلام",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
messages = await MessageRepository(session).list_for_thread(thread.id)
|
|
|
|
|
|
|
|
|
|
assert scripted.calls == 0
|
|
|
|
|
assert "دستور دریافت شد" in assistant.content
|
|
|
|
|
assert any("مدیری با مدل" in m.content for m in messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 3.2 team
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_hire_picks_a_granted_model_of_the_tier(world, session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
grants = FakeGrants({world.strong_id: "strong", world.fast_id: "fast"})
|
|
|
|
|
manager = await _manager(world, session, scripted=ScriptedModel(), grants=grants)
|
|
|
|
|
employee = await manager.hire(
|
|
|
|
|
organization_id=world.organization_id, role=RoleKey.TESTER, tier="fast"
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
profile = await EmployeeProfileRepository(session).get(employee.id)
|
|
|
|
|
|
|
|
|
|
assert profile is not None
|
|
|
|
|
assert profile.ai_model_id == world.fast_id
|
|
|
|
|
assert profile.role_list() == ["tester"]
|
|
|
|
|
assert profile.hired_by == "manager"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_hire_without_a_granted_model_is_refused(world, session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=ScriptedModel(), grants=FakeGrants({world.fast_id: "fast"})
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ConflictError):
|
|
|
|
|
await manager.hire(
|
|
|
|
|
organization_id=world.organization_id, role=RoleKey.REVIEWER, tier="strong"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_the_employee_cap_is_enforced(world, session_factory, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "engine_max_employees", 2)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
await world.employee(session, "یک")
|
|
|
|
|
await world.employee(session, "دو")
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=ScriptedModel(), grants=FakeGrants({world.fast_id: "fast"})
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ConflictError):
|
|
|
|
|
await manager.hire(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
role=RoleKey.TESTER,
|
|
|
|
|
tier="fast",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_the_last_manager_cannot_be_fired(world, session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
|
|
|
|
with pytest.raises(ConflictError):
|
|
|
|
|
await manager.fire(organization_id=world.organization_id, employee_id=manager_row.id)
|
|
|
|
|
# A second manager can be fired.
|
|
|
|
|
second = await world.employee(session, "مدیر دو", RoleKey.MANAGER)
|
|
|
|
|
await session.commit()
|
|
|
|
|
fired = await manager.fire(organization_id=world.organization_id, employee_id=second.id)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
assert fired.status.value == "archived"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_firing_a_foreign_employee_is_a_404(world, session_factory):
|
|
|
|
|
"""Another tenant's employee id is indistinguishable from a missing one."""
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
other_user = UserRow(email=f"other-{new_id()[:6]}@example.com", password_hash="x")
|
|
|
|
|
session.add(other_user)
|
|
|
|
|
await session.flush()
|
|
|
|
|
other_org = OrganizationRow(
|
|
|
|
|
name="سازمان دیگر", slug=f"oth-{other_user.id[:6]}", owner_user_id=other_user.id
|
|
|
|
|
)
|
|
|
|
|
session.add(other_org)
|
|
|
|
|
await session.flush()
|
|
|
|
|
stranger = await session.execute(select(EmployeeRow).limit(1))
|
|
|
|
|
foreign = None
|
|
|
|
|
if stranger.scalars().first() is not None:
|
|
|
|
|
foreign = stranger.scalars().first().id
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
if foreign is None:
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
foreign = (await world.employee(session, "بومی")).id
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
# The employee belongs to *this* org; the *other* org must not see it.
|
|
|
|
|
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
|
|
|
|
with pytest.raises(NotFoundError):
|
|
|
|
|
await manager.fire(organization_id="org-does-not-exist", employee_id=foreign)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 3.3 triage
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _task_with_state(
|
|
|
|
|
world, session, title: str = "کار", stage: Stage = Stage.PENDING_MANAGER_REVIEW
|
|
|
|
|
) -> tuple[str, str]:
|
|
|
|
|
task = TaskRow(organization_id=world.organization_id, title=title)
|
|
|
|
|
session.add(task)
|
|
|
|
|
await session.flush()
|
|
|
|
|
run = RunRow(task_id=task.id)
|
|
|
|
|
session.add(run)
|
|
|
|
|
await session.flush()
|
|
|
|
|
await PipelineStateRepository(session).create(
|
|
|
|
|
TaskPipelineState(task_id=task.id, organization_id=world.organization_id, stage=stage)
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
return task.id, run.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_triage_assigns_to_the_least_busy_employee(world, session_factory, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "engine_max_tool_calls_per_turn", 40)
|
|
|
|
|
scripted = ScriptedModel(
|
|
|
|
|
[ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_test"/]]')]
|
|
|
|
|
)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
busy = await world.employee(session, "مشغول", RoleKey.DEVELOPER)
|
|
|
|
|
free = await world.employee(session, "آzاد", RoleKey.DEVELOPER)
|
|
|
|
|
# The busy one already has two open tasks.
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
session.add(
|
|
|
|
|
TaskRow(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
title="کار دیگر",
|
|
|
|
|
assigned_employee_id=busy.id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
turns=None,
|
|
|
|
|
run_steps=RunStepRepository(session),
|
|
|
|
|
tool_calls=ToolCallRepository(session),
|
|
|
|
|
runs=RunRepository(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
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)
|
|
|
|
|
refreshed = await TaskRepository(session).get(task_id)
|
|
|
|
|
|
|
|
|
|
assert outcome.acted is True
|
|
|
|
|
assert outcome.decision == "assigned"
|
|
|
|
|
assert outcome.employee_id == free.id
|
|
|
|
|
assert state.stage is Stage.PENDING_ANALYSIS
|
|
|
|
|
assert refreshed.assigned_employee_id == free.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_named_employee_is_matched_inside_the_org(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee="سارا"/]]')])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
sara = await world.employee(session, "سارا", RoleKey.DEVELOPER)
|
|
|
|
|
await session.flush()
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session)
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
|
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
assert outcome.employee_id == sara.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_the_manager_of_org_a_cannot_assign_an_employee_of_org_b(world, session_factory):
|
|
|
|
|
"""Cross-tenant assignment by exact name must resolve to nothing."""
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee="دزد"/]]')])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
other_user = UserRow(email=f"b-{new_id()[:6]}@example.com", password_hash="x")
|
|
|
|
|
session.add(other_user)
|
|
|
|
|
await session.flush()
|
|
|
|
|
other_org = OrganizationRow(
|
|
|
|
|
name="سازمان ب", slug=f"b-{other_user.id[:6]}", owner_user_id=other_user.id
|
|
|
|
|
)
|
|
|
|
|
session.add(other_org)
|
|
|
|
|
await session.flush()
|
|
|
|
|
# The other org's employee is named exactly like the assignment asks.
|
|
|
|
|
session.add(EmployeeRow(organization_id=other_org.id, name="دزد", role_key="developer"))
|
|
|
|
|
await session.flush()
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session)
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
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 False
|
|
|
|
|
assert outcome.employee_id is None
|
|
|
|
|
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_ask_user_parks_the_task_and_asks_in_the_thread(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="[[ASK_USER]]کدام درگاه پرداخت؟[[/ASK_USER]]")])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session)
|
|
|
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
|
task.thread_id = thread.id
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
|
|
|
|
await session.commit()
|
|
|
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
|
|
|
messages = await MessageRepository(session).list_for_thread(thread.id)
|
|
|
|
|
|
|
|
|
|
assert outcome.decision == "asked_user"
|
|
|
|
|
assert state.stage is Stage.PENDING_USER_DECISION
|
|
|
|
|
assert any(m.content == "کدام درگاه پرداخت؟" for m in messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 3.4 final verdict
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_complete_moves_the_task_to_merge_request(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="[[COMPLETE/]]")])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session, stage=Stage.PENDING_FINAL_REVIEW)
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
|
outcome = await manager.final_review(
|
|
|
|
|
task=task,
|
|
|
|
|
run_id=run_id,
|
|
|
|
|
employee=manager_row,
|
|
|
|
|
spec="برنامه",
|
|
|
|
|
test_output="3 passed",
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
|
|
|
|
|
|
|
|
assert outcome.decision == "completed"
|
|
|
|
|
assert state.stage is Stage.MERGE_REQUEST
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_revise_sends_the_task_back_and_counts_the_cycle(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel([ScriptedReply(content="[[REVISE]]تستها قرمز است[[/REVISE]]")])
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session, stage=Stage.PENDING_FINAL_REVIEW)
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
|
outcome = await manager.final_review(
|
|
|
|
|
task=task, run_id=run_id, employee=manager_row, test_output="1 failed"
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
|
|
|
|
|
|
|
|
assert outcome.decision == "revised"
|
|
|
|
|
assert state.stage is Stage.PENDING_IMPLEMENTATION
|
|
|
|
|
assert state.cycle == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_resolve_error_retries_with_a_hint(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel(
|
|
|
|
|
[ScriptedReply(content='[[RESOLVE_ERROR hint="ابتدا مهاجرت را بنویس"/]]')]
|
|
|
|
|
)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
task_id, run_id = await _task_with_state(world, session)
|
|
|
|
|
await session.commit()
|
|
|
|
|
from app.application.engine.runner import TurnRunner
|
|
|
|
|
|
|
|
|
|
runner = TurnRunner(
|
|
|
|
|
provider=scripted,
|
|
|
|
|
models=AiModelRepository(session),
|
|
|
|
|
credentials=ModelCredentialRepository(session),
|
|
|
|
|
rate_cards=RateCardRepository(session),
|
|
|
|
|
ledger=UsageLedgerRepository(session),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong"}),
|
|
|
|
|
memory=build_memory(session),
|
|
|
|
|
states=PipelineStateRepository(session),
|
|
|
|
|
)
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
manager.runner = runner
|
|
|
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
|
await PipelineStateRepository(session).advance(
|
|
|
|
|
task_id=task_id, expected_from=Stage.PENDING_MANAGER_REVIEW, to=Stage.ERROR_REVIEW
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
outcome = await manager.error_review(
|
|
|
|
|
task=task, run_id=run_id, employee=manager_row, error_summary="تست قرمز"
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
|
|
|
|
|
|
|
|
assert outcome.decision == "resolved"
|
|
|
|
|
assert state.stage is Stage.PENDING_IMPLEMENTATION
|
|
|
|
|
assert "مهاجرت" in outcome.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 3.5 knowledge and 3.6 bootstrap
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_knowledge_tags_become_org_memory(world, session_factory):
|
|
|
|
|
scripted = ScriptedModel(
|
|
|
|
|
[
|
|
|
|
|
ScriptedReply(
|
|
|
|
|
content=('[[KNOWLEDGE kind="rule" key="deploy"]]قبل از پوش تست بگیر.[[/KNOWLEDGE]]')
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
|
|
|
|
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
|
|
|
|
thread = await ThreadRepository(session).create(
|
|
|
|
|
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
|
|
|
|
)
|
|
|
|
|
await manager.chat(
|
|
|
|
|
organization_id=world.organization_id,
|
|
|
|
|
thread_id=thread.id,
|
|
|
|
|
content="یادت باشد.",
|
|
|
|
|
user_id=world.user_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
rows = (
|
|
|
|
|
(
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(OrgMemoryRow).where(
|
|
|
|
|
OrgMemoryRow.organization_id == world.organization_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.scalars()
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(rows) == 1
|
|
|
|
|
assert rows[0].kind == "rule"
|
|
|
|
|
assert rows[0].source == "manager"
|
|
|
|
|
assert rows[0].content == "قبل از پوش تست بگیر."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_bootstrap_creates_the_team_and_is_idempotent(world, session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager = await _manager(
|
|
|
|
|
world,
|
|
|
|
|
session,
|
|
|
|
|
scripted=ScriptedModel(),
|
|
|
|
|
grants=FakeGrants({world.strong_id: "strong", world.fast_id: "fast"}),
|
|
|
|
|
)
|
|
|
|
|
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
|
|
|
|
await session.commit()
|
|
|
|
|
employees = await EmployeeRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
profiles = {}
|
|
|
|
|
for employee in employees:
|
|
|
|
|
profile = await EmployeeProfileRepository(session).get(employee.id)
|
|
|
|
|
profiles[employee.role_key] = profile.ai_model_id if profile else None
|
|
|
|
|
|
|
|
|
|
# A second call changes nothing.
|
|
|
|
|
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
|
|
|
|
await session.commit()
|
|
|
|
|
again = await EmployeeRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
|
|
|
|
|
assert len(employees) == 4
|
|
|
|
|
assert len(again) == 4
|
|
|
|
|
assert {e.role_key for e in employees} == {
|
|
|
|
|
RoleKey.MANAGER,
|
|
|
|
|
RoleKey.PLANNER,
|
|
|
|
|
RoleKey.DEVELOPER,
|
|
|
|
|
RoleKey.TESTER,
|
|
|
|
|
}
|
|
|
|
|
assert all(e.status.value == "active" for e in employees)
|
|
|
|
|
# The manager runs on the strongest granted tier, the tester on the fast one.
|
|
|
|
|
assert profiles[RoleKey.MANAGER] == world.strong_id
|
|
|
|
|
assert profiles[RoleKey.TESTER] == world.fast_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_bootstrap_without_grants_creates_an_inactive_team(world, session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
|
|
|
|
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
|
|
|
|
await session.commit()
|
|
|
|
|
employees = await EmployeeRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
threads = await ThreadRepository(session).list_for_org(world.organization_id)
|
|
|
|
|
notes: list[str] = []
|
|
|
|
|
for thread in threads:
|
|
|
|
|
notes.extend(
|
|
|
|
|
m.content for m in await MessageRepository(session).list_for_thread(thread.id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(employees) == 4
|
|
|
|
|
assert all(e.status.value == "paused" for e in employees)
|
|
|
|
|
assert NO_MODEL_GRANTED_MESSAGE in notes
|