"""A manager order from a thread without a project still gets the organization's project. The web UI creates the manager thread without ``project_id`` (``manager/page.tsx``). Before this fix such an order made a task with no project, so the engine gave it no worktree and no sandbox. ``ManagerTurns`` now falls back to the organization's project, for the task and for its pipeline state. This test drives the path: the owner grants the default models, the manager completes the wizard over HTTP, a thread is created over HTTP without ``project_id``, and the order goes to the engine's manager, as in ``test_engine_manager.py``. """ from __future__ import annotations from sqlalchemy import select from app.application.container_engine import build_scheduler from app.infrastructure.db.models import AiModelRow, OrganizationRow from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.workforce import TaskRepository from tests.billing_helpers import fund_org from tests.conftest import auth_header from tests.engine_fakes import ScriptedModel, ScriptedReply from tests.test_org_wizard import _complete, _manager DEFAULTS = "/api/admin/default-model-grants" #: The manager's chat reply: one task tag, so the order becomes one task. ORDER_REPLY = ( "باشه، یک کار می‌سازم.\n" '[[CREATE_TASK title="صفحهٔ ورود" kind="feature"]]' "ساخت صفحهٔ ورود با اعتبارسنجی[[/CREATE_TASK]]" ) async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project( client, session_factory, seeded, platform_owner_token, monkeypatch ): scripted = ScriptedModel([ScriptedReply(content=ORDER_REPLY)]) monkeypatch.setattr("app.application.container.get_model_provider", lambda: scripted) # The platform owner grants one reasoning model and one cheap model to the default set. async with session_factory() as session: 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) cheap = next(m for m in models if "reasoning" not in m.capabilities) owner = auth_header(platform_owner_token) for model in (strong, cheap): granted = await client.put( f"{DEFAULTS}/{model.id}", json={"is_active": True}, headers=owner ) assert granted.status_code == 200, granted.text # The manager completes the wizard: org, project, team and grants. _, headers = await _manager(client) body = await _complete(client, headers) org_id = body["organization"]["id"] project_id = body["project"]["id"] # prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls. await fund_org(session_factory, org_id) # The web UI's way: a thread with no project_id. created = await client.post( f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers ) assert created.status_code == 201, created.text assert created.json()["project_id"] is None thread_id = created.json()["id"] # The order goes to the engine's manager. The HTTP message route builds its manager with # the pipeline states too (workforce_service._manager_turns, covered by # test_integration_order_route.py); this test calls the manager directly only to read the # reply and the states in the same session. async with session_factory() as session: owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id _, assistant = await build_scheduler(session).manager.chat( organization_id=org_id, thread_id=thread_id, content="یک صفحهٔ ورود بساز.", user_id=owner_user_id, ) await session.commit() tasks = await TaskRepository(session).list_for_org(org_id) states = await PipelineStateRepository(session).list_for_org(org_id) assert "کار ساخته شد: صفحهٔ ورود" in assistant.content assert len(tasks) == 1 assert tasks[0].project_id == project_id assert len(states) == 1 assert states[0].project_id == project_id