"""Unit tests for PrompterService. Covers the live-intake draft → task flow (``create_task_from_draft`` / ``confirm_live_draft`` + the enum/priority/team coercion) and the pure draft/description helpers. DB-backed tests use an in-memory async session via conftest fixtures. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch from uuid import UUID, uuid4 import pytest from sqlalchemy import select if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from roboco.db.tables import ( AgentTable, ProductTable, ProjectTable, TaskTable, ) from roboco.models.base import ( AgentRole, AgentStatus, Complexity, TaskNature, TaskStatus, TaskType, Team, ) from roboco.seeds.initial_data import AGENT_UUIDS from roboco.services import prompter as prompter_module from roboco.services.base import NotFoundError, ServiceError, ValidationError from roboco.services.prompter import ( _HISTORY_DIGEST_PER_PROJECT_LIMIT, _HISTORY_TITLE_EXCERPT_CAP, PrompterService, _cell_teams, _clean_list, _draft_cell_map, _task_activity_date, _title_excerpt, build_history_digest, compact_task_rows, compose_description, derive_scale, get_prompter_service, history_digest_layer, parse_readiness, ) from roboco.services.task import get_task_service # ============================================================================= # Pure function tests (no DB) # ============================================================================= def test_parse_readiness_extracts_and_strips_tag() -> None: content = ( "Here is my question about scope.\n\n" '```roboco-meta\n{"covered": ["objective", "scope"], ' '"ready": true, "scale": "multi"}\n```' ) clean, tag = parse_readiness(content) assert clean == "Here is my question about scope." assert tag is not None assert tag.ready is True assert tag.scale == "multi" assert tag.covered == ["objective", "scope"] # The control block must not leak into the user-visible text. assert "roboco-meta" not in clean def test_parse_readiness_absent_block_is_not_ready() -> None: clean, tag = parse_readiness("Just a plain reply, no control block.") assert clean == "Just a plain reply, no control block." assert tag is None def test_parse_readiness_malformed_json_is_graceful() -> None: content = "Reply text.\n```roboco-meta\n{not valid json]\n```" clean, tag = parse_readiness(content) assert "roboco-meta" not in clean assert clean == "Reply text." assert tag is None def test_parse_readiness_uses_last_block() -> None: content = ( '```roboco-meta\n{"ready": false, "scale": "single"}\n```\n' "Final answer.\n" '```roboco-meta\n{"ready": true, "scale": "multi"}\n```' ) clean, tag = parse_readiness(content) assert tag is not None assert tag.ready is True assert tag.scale == "multi" assert "roboco-meta" not in clean def test_derive_scale_single_vs_multi() -> None: assert derive_scale([{"team": "backend"}]) == "single" assert derive_scale([{"team": "backend"}, {"team": "frontend"}]) == "multi" # Non-cell teams (e.g. main_pm) do not count toward cell breadth. assert derive_scale([{"team": "backend"}, {"team": "main_pm"}]) == "single" assert derive_scale([]) == "single" # ----------------------------------------------------------------------------- # the_work shape tolerance — the intake agent is an LLM and sometimes emits # the_work as a list of bare team-name strings ("backend") instead of the # documented {team, summary, items} objects. Every consumer must tolerate that # without raising (regression: preview-batch used to 500 with # "'str' object has no attribute 'get'"). # ----------------------------------------------------------------------------- def test_cell_teams_tolerates_bare_string_entries() -> None: # The LLM emitted the_work as a list of team names, not objects. assert _cell_teams(["backend", "frontend", "backend"]) == ["backend", "frontend"] # A bare string that isn't a cell is skipped, just like a non-cell dict. assert _cell_teams(["backend", "main_pm"]) == ["backend"] assert _cell_teams(["nonsense"]) == [] def test_lead_cell_team_tolerates_bare_string_entries() -> None: draft = {"the_work": ["frontend", "backend"]} assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.FRONTEND # First valid cell wins; an invalid bare string is skipped. draft = {"the_work": ["nonsense", "ux_ui"]} assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.UX_UI def test_derive_scale_tolerates_bare_string_entries() -> None: assert derive_scale(["backend"]) == "single" assert derive_scale(["backend", "frontend"]) == "multi" def test_compose_description_renders_bare_string_work_entries() -> None: draft = { "objective": "Fix the intake batch preview.", "the_work": ["backend", "frontend"], "acceptance_criteria": ["Preview no longer 500s"], } md = compose_description(draft) # Each bare string renders as a cell heading; multi-cell gets the board-led line. assert "## The Work" in md assert "**Backend**" in md assert "**Frontend**" in md assert "Board-led" in md def test_compose_description_single_cell_markdown() -> None: draft = { "objective": "Let humans track token usage.", "what_this_builds": ["A usage panel on the Metrics page"], "the_work": [ { "team": "frontend", "summary": "Render the usage panel", "items": ["Add the chart", "Wire the API"], } ], "notes": ["Reuse the existing Metrics layout"], "acceptance_criteria": ["Panel shows totals", "Panel filters by range"], } md = compose_description(draft) assert "## Objective" in md assert "## What This Builds" in md assert "## The Work" in md assert "**Frontend** — Render the usage panel" in md assert "## Notes" in md assert "## Success Criteria" in md assert "- Panel shows totals" in md # Single-cell tasks get no board-led lead line. assert "Board-led" not in md def test_compose_description_multi_cell_has_board_led_lead() -> None: draft = { "objective": "Ship the Prompter.", "the_work": [ {"team": "backend", "summary": "Chat endpoint", "items": []}, {"team": "frontend", "summary": "Chat UI", "items": []}, {"team": "ux_ui", "summary": "Interaction design", "items": []}, ], "acceptance_criteria": ["It works end to end"], } md = compose_description(draft) assert "Board-led" in md assert "**Backend**" in md assert "**UX/UI**" in md def test_compose_description_falls_back_to_provided_description() -> None: # Sparse structured fields → fall back to a model-provided description. draft = {"description": "A perfectly adequate fallback description here."} md = compose_description(draft) assert md == "A perfectly adequate fallback description here." def test_lead_cell_team_prefers_the_work_cell() -> None: draft = {"the_work": [{"team": "frontend"}], "team": "backend"} assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.FRONTEND # Empty the_work falls back to the provided default. assert PrompterService._lead_cell_team({}, default=Team.BACKEND) is Team.BACKEND def test_lead_cell_team_skips_invalid_cell_names() -> None: # An off-enum cell name is skipped, not raised on; falls through to a valid one. draft = {"the_work": [{"team": "nonsense"}, {"team": "frontend"}]} assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.FRONTEND def test_coerce_draft_enums_defaults_invalid_values() -> None: # Regression: the LLM emits off-enum values (e.g. task_type="feature"). The # confirm must coerce to defaults, never raise — a bad enum guess must not # 400 the launch and force the agent to self-correct in-chat. draft = { "team": "backend", "task_type": "feature", # not a valid TaskType "nature": "bogus", # not a valid TaskNature "estimated_complexity": "enormous", # not a valid Complexity } team, task_type, nature, complexity = PrompterService._coerce_draft_enums(draft) assert team is Team.BACKEND assert task_type is TaskType.CODE assert nature is TaskNature.TECHNICAL assert complexity is Complexity.MEDIUM def test_coerce_priority_maps_words_clamps_and_defaults() -> None: # Regression: priority is the one non-enum field the agent guesses, and it # guesses a word ("high") as often as a number — int("high") used to 500. # word/number -> expected priority int (0=urgent .. 3=low). cases: dict[object, int] = { "urgent": 0, "high": 1, "medium": 2, "low": 3, 1: 1, "3": 3, 99: 3, # clamped into range "nonsense": 2, # unrecognized -> default medium None: 2, # missing -> default medium } for value, expected in cases.items(): assert PrompterService._coerce_priority(value) == expected def test_coerce_draft_enums_keeps_valid_and_derives_missing_team() -> None: # Valid values pass through; a missing team is derived from the_work. draft = { "task_type": "documentation", "nature": "technical", "estimated_complexity": "medium", "the_work": [{"team": "frontend"}], } team, task_type, nature, complexity = PrompterService._coerce_draft_enums(draft) assert team is Team.FRONTEND assert task_type is TaskType.DOCUMENTATION assert nature is TaskNature.TECHNICAL assert complexity is Complexity.MEDIUM # ============================================================================= # Factory # ============================================================================= def test_get_prompter_service_no_db() -> None: service = get_prompter_service() assert isinstance(service, PrompterService) assert service._db is None def test_get_prompter_service_raises_without_db_for_session_methods() -> None: service = get_prompter_service() with pytest.raises(ServiceError, match="DB session"): _ = service._session # ============================================================================= # DB-backed: assignee routing + confirm_live_draft # ============================================================================= @pytest.mark.asyncio async def test_assignee_is_board_distinguishes_roles(db_session: Any) -> None: """Drives product team routing: a board reviewer keeps the root on the board. A product confirmed via "Board review & Start" is assigned to a board reviewer and must stay team=board so the CEO's Approve & Start gate appears; one assigned to main-pm (or a cell dev) is not a board task. """ service = get_prompter_service(db=db_session) def _agent(role: AgentRole) -> AgentTable: return AgentTable( id=uuid4(), name="A", slug=f"a-{uuid4().hex[:8]}", role=role, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="x", capabilities=[], permissions={}, metrics={}, ) po = _agent(AgentRole.PRODUCT_OWNER) hom = _agent(AgentRole.HEAD_MARKETING) dev = _agent(AgentRole.DEVELOPER) db_session.add_all([po, hom, dev]) await db_session.flush() assert await service._assignee_is_board(cast("UUID", po.id)) is True assert await service._assignee_is_board(cast("UUID", hom.id)) is True assert await service._assignee_is_board(cast("UUID", dev.id)) is False # Unknown id is not a board agent — defensive, must not raise. assert await service._assignee_is_board(uuid4()) is False async def _seed_project_and_ceo(db_session: Any) -> tuple[UUID, UUID]: """Seed a system agent + project + CEO; return (project_id, ceo_id). Returns plain ``UUID``s (not the ORM rows) so callers pass real uuids to the service — no casting the ORM ``.id`` column type at the call site. """ system_id, project_id, ceo_id = uuid4(), uuid4(), uuid4() system = AgentTable( id=system_id, name="System", slug=f"system-{uuid4().hex[:8]}", role=AgentRole.SYSTEM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="system", capabilities=[], permissions={}, metrics={}, ) db_session.add(system) await db_session.flush() project = ProjectTable( id=project_id, name="Intake Test Project", slug=f"intake-{uuid4().hex[:8]}", git_url="https://github.com/example/intake.git", default_branch="main", protected_branches=["main"], assigned_cell=Team.BACKEND, created_by=system_id, is_active=True, ) ceo = AgentTable( id=ceo_id, name="CEO", slug=f"ceo-{uuid4().hex[:8]}", role=AgentRole.CEO, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="ceo", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([project, ceo]) await db_session.flush() # The "& Start" routes assign the draft to a fixed board/PM agent # (product-owner for "Board review", main-pm for "Approve & Start"); those # rows must exist for the assigned_to FK. merge() is idempotent, so this is # safe whether or not another test already committed them on the shared DB. for slug, role, team in ( ("product-owner", AgentRole.PRODUCT_OWNER, None), ("main-pm", AgentRole.MAIN_PM, Team.MAIN_PM), ): await db_session.merge( AgentTable( id=UUID(AGENT_UUIDS[slug]), name=slug, slug=slug, role=role, team=team, status=AgentStatus.ACTIVE, model_config={}, system_prompt=slug, capabilities=[], permissions={}, metrics={}, ) ) await db_session.flush() return project_id, ceo_id @pytest.mark.asyncio async def test_confirm_live_draft_board_route_assigns_po(db_session: Any) -> None: """ "Board review & Start" (default route) → PENDING, assigned to the Product Owner so the orchestrator fires the PO + HoM review.""" project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) draft = { "title": "Add token metrics", "objective": "See token usage at a glance.", "acceptance_criteria": ["Dashboard shows total tokens"], "team": "backend", "the_work": [ {"team": "backend", "summary": "instrument", "items": ["count tokens"]} ], } task_id = await service.confirm_live_draft(draft, ceo_id, project_id=project_id) row = await db_session.get(TaskTable, task_id) assert row is not None assert row.status == TaskStatus.PENDING # "& Start" — started now assert row.assigned_to == UUID(AGENT_UUIDS["product-owner"]) # board review assert row.source == "prompter" assert row.confirmed_by_human is True assert row.team == Team.BACKEND # lead cell from the_work assert row.created_by == ceo_id assert row.nature is not None and row.task_type is not None @pytest.mark.asyncio async def test_confirm_live_draft_main_pm_route_assigns_main_pm( db_session: Any, ) -> None: """ "Approve & Start" (route="main_pm") → PENDING, assigned to the Main PM.""" project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) draft = { "title": "Quick fix", "acceptance_criteria": ["done"], "team": "backend", } task_id = await service.confirm_live_draft( draft, ceo_id, project_id=project_id, route="main_pm" ) row = await db_session.get(TaskTable, task_id) assert row.status == TaskStatus.PENDING assert row.assigned_to == UUID(AGENT_UUIDS["main-pm"]) # A PM coordinates — a code task handed to the Main PM is coerced to # planning (the PM/code invariant; the draft's team=backend is honored but # the type is retyped so the combo never persists). assert row.task_type == TaskType.PLANNING @pytest.mark.asyncio async def test_confirm_live_draft_product_routes_to_main_pm(db_session: Any) -> None: """A product-scoped draft via the "Approve & Start" path is a Main-PM root. The board path (the ``route="board"`` default) keeps the root at ``team=board`` until the CEO approves; the Main-PM path is selected explicitly with ``route="main_pm"``. """ _project_id, ceo_id = await _seed_project_and_ceo(db_session) product_id = uuid4() product = ProductTable( id=product_id, name="Intake Product", slug=f"prod-{uuid4().hex[:8]}", description="x", created_by=ceo_id, ) db_session.add(product) await db_session.flush() service = get_prompter_service(db=db_session) draft = { "title": "Board-led feature", "acceptance_criteria": ["works end to end"], "team": "backend", } task_id = await service.confirm_live_draft( draft, ceo_id, product_id=product_id, route="main_pm" ) row = await db_session.get(TaskTable, task_id) assert row.team == Team.MAIN_PM assert row.product_id == product_id assert row.project_id is None # A Main-PM coordination root is never code — intake coerces code->planning # so main_pm + code can never coexist (the 2026-06-27 meltdown shape). assert row.task_type == TaskType.PLANNING # ============================================================================= # MegaTask: confirm_live_batch (umbrella + sequenced root-subtasks) # ============================================================================= async def _seed_second_project(db_session: Any, ceo_id: UUID) -> UUID: """Seed a second project so a MegaTask can span multiple repos.""" project_id = uuid4() db_session.add( ProjectTable( id=project_id, name="Intake Test Project 2", slug=f"intake2-{uuid4().hex[:8]}", git_url="https://github.com/example/intake2.git", default_branch="main", protected_branches=["main"], assigned_cell=Team.FRONTEND, created_by=ceo_id, is_active=True, ) ) await db_session.flush() return project_id @pytest.mark.asyncio async def test_confirm_live_batch_builds_umbrella_and_sequenced_subtasks( db_session: Any, ) -> None: """A MegaTask creates one branchless umbrella + N root-subtasks across many projects, with the collision-derived dependency edges wired so the dependency-gate runs the waves in order.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) # A & B both add a migration → serial chain A→B (the migration rule orders # them by priority then index). C is an independent frontend task in another # project, so it runs in parallel with A in wave 0. drafts: list[dict[str, Any]] = [ { "title": "A: add table", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(project1), "intends_to_touch": ["roboco/services/foo.py"], "adds_migration": True, }, { "title": "B: extend table", "acceptance_criteria": ["b"], "team": "backend", "project_id": str(project1), "intends_to_touch": ["roboco/services/bar.py"], "adds_migration": True, }, { "title": "C: frontend widget", "acceptance_criteria": ["c"], "team": "frontend", "project_id": str(project2), "intends_to_touch": ["panel/src/widget.tsx"], }, ] with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()): result = await service.confirm_live_batch( "Three things", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-builds", ) # A (migration) and C (independent) run in wave 0; B chains after A. assert result["waves"] == [[0, 2], [1]] ids = result["root_subtask_ids"] assert len(ids) == len(drafts) umbrella_id = UUID(result["umbrella_task_id"]) umbrella = await db_session.get(TaskTable, umbrella_id) assert umbrella.batch_id is not None assert umbrella.parent_task_id is None assert umbrella.project_id is None and umbrella.product_id is None assert umbrella.team == Team.MAIN_PM assert umbrella.status == TaskStatus.PENDING assert umbrella.branch_name is None # branchless # A Main-PM coordination root is never code — the umbrella is planning-typed. assert umbrella.task_type == TaskType.PLANNING a, b, c = [await db_session.get(TaskTable, UUID(sid)) for sid in ids] for sub in (a, b, c): assert sub.parent_task_id == umbrella_id assert sub.batch_id == umbrella.batch_id assert sub.team == Team.MAIN_PM assert sub.status == TaskStatus.PENDING # Each root-subtask is a Main-PM coordination root: code->planning coerced # at intake so main_pm + code can never coexist (the 2026-06-27 meltdown # shape). It still gets its own branch + PR + submit_root + pr_review gate # — the gate is branch-keyed, not task_type-keyed. assert sub.task_type == TaskType.PLANNING assert a.project_id == project1 assert b.project_id == project1 assert c.project_id == project2 # sequence = wave index: A and C in wave 0, B in wave 1. assert (a.sequence, b.sequence, c.sequence) == (0, 1, 0) # Dependency wiring: B waits on A; C is independent. assert UUID(ids[0]) in b.dependency_ids assert c.dependency_ids == [] @pytest.mark.asyncio async def test_confirm_live_batch_board_route_holds_subtasks_in_backlog( db_session: Any, ) -> None: """The "board" route sends the umbrella to the Product Owner for batch review and holds the root-subtasks in BACKLOG until the umbrella is approved.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = [ { "title": "One", "acceptance_criteria": ["x"], "team": "backend", "project_id": str(project1), }, { "title": "Two", "acceptance_criteria": ["y"], "team": "frontend", "project_id": str(project2), }, ] with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()): result = await service.confirm_live_batch( "Two repos", drafts, ceo_id, project_ids=[project1, project2], route="board", session_id="sess-board", ) umbrella = await db_session.get(TaskTable, UUID(result["umbrella_task_id"])) assert umbrella.team == Team.BOARD assert umbrella.assigned_to == UUID(AGENT_UUIDS["product-owner"]) assert umbrella.status == TaskStatus.PENDING sub = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][0])) assert sub.status == TaskStatus.BACKLOG # held until batch review approves assert sub.team == Team.BOARD @pytest.mark.asyncio async def test_confirm_live_batch_rejects_empty(db_session: Any) -> None: _project1, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) with pytest.raises(ValidationError): await service.confirm_live_batch( "Empty", [], ceo_id, project_ids=[uuid4(), uuid4()], session_id="sess-empty" ) @pytest.mark.asyncio async def test_confirm_live_batch_rejects_draft_outside_scope(db_session: Any) -> None: """A draft targeting a project NOT in the scoped project_ids is refused — the intake agent only read the scoped repos.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) outside = uuid4() # never in scope drafts = [ {"title": "A", "acceptance_criteria": ["a"], "project_id": str(project1)}, {"title": "B", "acceptance_criteria": ["b"], "project_id": str(outside)}, ] with pytest.raises(ValidationError, match="outside this MegaTask"): await service.confirm_live_batch( "Scoped", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-scope", ) @pytest.mark.asyncio async def test_confirm_live_batch_rejects_single_project(db_session: Any) -> None: """A degenerate batch whose drafts all target one project is not a MegaTask.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = [ {"title": "A", "acceptance_criteria": ["a"], "project_id": str(project1)}, {"title": "B", "acceptance_criteria": ["b"], "project_id": str(project1)}, ] with pytest.raises(ValidationError, match="at least two distinct projects"): await service.confirm_live_batch( "One repo", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-single", ) # ----------------------------------------------------------------------------- # M13: confirm_live_batch idempotency guard (Redis SETNX + result sidecar) # ----------------------------------------------------------------------------- class _FakeRedis: """In-memory store backing set(nx=True, ex=...) + get + aclose for the MegaTask confirm idempotency guard + result sidecar.""" def __init__(self) -> None: self._store: dict[str, str] = {} self.set_calls: list[tuple[str, str, bool, int]] = [] async def set( self, name: str, value: str, *, nx: bool = False, ex: int = 0 ) -> bool: self.set_calls.append((name, value, nx, ex)) if nx and name in self._store: return False self._store[name] = value return True async def get(self, name: str) -> str | None: return self._store.get(name) async def aclose(self) -> None: return None def _make_batch_drafts(project1: UUID, project2: UUID) -> list[dict[str, Any]]: """Minimal valid MegaTask batch: two drafts on two scoped projects.""" return [ { "title": "A", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(project1), "intends_to_touch": ["roboco/services/a.py"], }, { "title": "B", "acceptance_criteria": ["b"], "team": "frontend", "project_id": str(project2), "intends_to_touch": ["panel/src/b.tsx"], }, ] @pytest.mark.asyncio async def test_confirm_live_batch_idempotent_on_retry(db_session: Any) -> None: """A retry with the same session_id returns the first call's result and does NOT mint a second umbrella + root-subtasks.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = _make_batch_drafts(project1, project2) fake = _FakeRedis() with patch("roboco.services.prompter.redis.from_url", return_value=fake): r1 = await service.confirm_live_batch( "Batch", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-retry", ) r2 = await service.confirm_live_batch( "Batch", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-retry", ) assert r2["umbrella_task_id"] == r1["umbrella_task_id"] assert r2["root_subtask_ids"] == r1["root_subtask_ids"] # Exactly one umbrella + one set of root-subtasks exist for the session. umbrellas = ( ( await db_session.execute( select(TaskTable).where( TaskTable.parent_task_id.is_(None), TaskTable.batch_id.is_not(None), ) ) ) .scalars() .all() ) assert len(umbrellas) == 1 assert str(umbrellas[0].id) == r1["umbrella_task_id"] @pytest.mark.asyncio async def test_confirm_live_batch_in_progress_raises_when_sidecar_absent( db_session: Any, ) -> None: """Guard held but no result sidecar (first call still mid-build) → raise 'already in progress'; no second build attempted.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = _make_batch_drafts(project1, project2) fake = _FakeRedis() fake._store["roboco:megatask_confirm:sess-inflight"] = "1" # guard held, no sidecar with ( patch("roboco.services.prompter.redis.from_url", return_value=fake), pytest.raises(ServiceError, match="already in progress"), ): await service.confirm_live_batch( "Batch", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-inflight", ) @pytest.mark.asyncio async def test_confirm_live_batch_redis_unreachable_fails_closed( db_session: Any, ) -> None: """Redis unreachable → ServiceError('idempotency guard unavailable'); no build attempted (fail-closed, never fail-open).""" class _BoomRedis: async def set(self, *_a: Any, **_k: Any) -> bool: raise OSError("redis down") async def get(self, _name: str) -> str | None: raise OSError("redis down") async def aclose(self) -> None: return None def _boom_from_url(_url: str) -> _BoomRedis: return _BoomRedis() project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = _make_batch_drafts(project1, project2) with ( patch("roboco.services.prompter.redis.from_url", side_effect=_boom_from_url), pytest.raises(ServiceError, match="idempotency guard unavailable"), ): await service.confirm_live_batch( "Batch", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-boom", ) # ----------------------------------------------------------------------------- # M14: strip assigned_to from each sub-draft (no board-owned root-subtask deadlock) # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_confirm_live_batch_strips_assigned_to_from_drafts( db_session: Any, ) -> None: """An LLM-authored draft carrying a hallucinated/injected board-role ``assigned_to`` must NOT create a board-owned CODE root-subtask — a board role has no dev delivery verbs, so it would deadlock the umbrella. The root-subtasks are coordination roots; assignment is the PM-activation flow's call, not the draft's.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) service = get_prompter_service(db=db_session) drafts = _make_batch_drafts(project1, project2) # Inject a board-role assignee on every draft (a hallucinated PO uuid). po_uuid = UUID(AGENT_UUIDS["product-owner"]) for d in drafts: d["assigned_to"] = str(po_uuid) fake = _FakeRedis() with patch("roboco.services.prompter.redis.from_url", return_value=fake): result = await service.confirm_live_batch( "Batch", drafts, ceo_id, project_ids=[project1, project2], route="main_pm", session_id="sess-m14", ) # The caller's drafts are untouched (the strip is on the copy). for d in drafts: assert d["assigned_to"] == str(po_uuid) roots = ( ( await db_session.execute( select(TaskTable).where( TaskTable.id.in_([UUID(r) for r in result["root_subtask_ids"]]) ) ) ) .scalars() .all() ) assert roots, "expected root-subtasks to be created" for r in roots: assert r.assigned_to is None, ( f"root-subtask {r.id} wrongly assigned to {r.assigned_to}" ) # ============================================================================= # MegaTask redraft: update_live_batch (board-review keep-alive loop, batch shape) # ============================================================================= async def _confirm_board_batch( db_session: Any, ceo_id: UUID, drafts: list[dict[str, Any]], project_ids: list[UUID], ) -> dict[str, Any]: """Confirm a board-routed MegaTask (held root-subtasks) to redraft against.""" service = get_prompter_service(db=db_session) with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()): return await service.confirm_live_batch( "Seed batch", drafts, ceo_id, project_ids=project_ids, route="board", session_id=f"sess-{uuid4().hex}", ) def _two_item_drafts(project1: UUID, project2: UUID) -> list[dict[str, Any]]: return [ { "title": "One", "acceptance_criteria": ["x"], "team": "backend", "project_id": str(project1), }, { "title": "Two", "acceptance_criteria": ["y"], "team": "frontend", "project_id": str(project2), }, ] @pytest.mark.asyncio async def test_update_live_batch_patches_unchanged_scope_in_place( db_session: Any, ) -> None: """A redraft that keeps every item's project targets patches title/ description/acceptance criteria in place — no child is replaced.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) before_ids = [UUID(sid) for sid in result["root_subtask_ids"]] revised = [ {**drafts[0], "title": "One revised", "acceptance_criteria": ["x2"]}, {**drafts[1], "title": "Two revised", "acceptance_criteria": ["y2"]}, ] service = get_prompter_service(db=db_session) out = await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", revised, ceo_id, route="board" ) assert [UUID(sid) for sid in out["root_subtask_ids"]] == before_ids child0 = await db_session.get(TaskTable, before_ids[0]) child1 = await db_session.get(TaskTable, before_ids[1]) assert child0.title == "One revised" assert child0.acceptance_criteria == ["x2"] assert child0.status == TaskStatus.BACKLOG # untouched, still board-held assert child1.title == "Two revised" @pytest.mark.asyncio async def test_update_live_batch_grows_creates_extra_child(db_session: Any) -> None: """More drafts than existing root-subtasks creates the extras, BACKLOG.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) grown = [ *drafts, { "title": "Three", "acceptance_criteria": ["z"], "team": "backend", "project_id": str(project1), }, ] service = get_prompter_service(db=db_session) out = await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", grown, ceo_id, route="board" ) assert len(out["root_subtask_ids"]) == len(grown) new_child = await db_session.get(TaskTable, UUID(out["root_subtask_ids"][2])) assert new_child.title == "Three" assert new_child.status == TaskStatus.BACKLOG assert new_child.parent_task_id == UUID(result["umbrella_task_id"]) @pytest.mark.asyncio async def test_update_live_batch_shrinks_cancels_surplus_child(db_session: Any) -> None: """Fewer drafts than existing root-subtasks cancels the surplus.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = [ *_two_item_drafts(project1, project2), { "title": "Three", "acceptance_criteria": ["z"], "team": "backend", "project_id": str(project1), }, ] result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) surplus_id = UUID(result["root_subtask_ids"][2]) kept_drafts = drafts[:2] service = get_prompter_service(db=db_session) out = await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", kept_drafts, ceo_id, route="board", ) assert len(out["root_subtask_ids"]) == len(kept_drafts) surplus = await db_session.get(TaskTable, surplus_id) assert surplus.status == TaskStatus.CANCELLED @pytest.mark.asyncio async def test_update_live_batch_scope_change_replaces_child(db_session: Any) -> None: """A revised draft that moves to a different project cancels the stale child and creates a replacement — never patches a scope change in place.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) project3 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) original_second_id = UUID(result["root_subtask_ids"][1]) revised = [ drafts[0], { "title": "Two moved", "acceptance_criteria": ["y2"], "team": "frontend", "project_id": str(project3), }, ] service = get_prompter_service(db=db_session) # project3 is new to the batch — the panel round-trips the widened scope. out = await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", revised, ceo_id, project_ids=[project1, project2, project3], route="board", ) new_second_id = UUID(out["root_subtask_ids"][1]) assert new_second_id != original_second_id original = await db_session.get(TaskTable, original_second_id) assert original.status == TaskStatus.CANCELLED replacement = await db_session.get(TaskTable, new_second_id) assert replacement.project_id == project3 assert replacement.title == "Two moved" assert replacement.status == TaskStatus.BACKLOG @pytest.mark.asyncio async def test_update_live_batch_rewires_dependency_edges(db_session: Any) -> None: """Old sibling dependency edges are cleared and the fresh wave plan's edges are wired — a redraft never leaves a stale edge from the prior sequencing.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts: list[dict[str, Any]] = [ { "title": "A: add table", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(project1), "intends_to_touch": ["roboco/services/foo.py"], "adds_migration": True, }, { "title": "B: extend table", "acceptance_criteria": ["b"], "team": "backend", "project_id": str(project1), "intends_to_touch": ["roboco/services/bar.py"], "adds_migration": True, }, { "title": "C: frontend widget", "acceptance_criteria": ["c"], "team": "frontend", "project_id": str(project2), "intends_to_touch": ["panel/src/widget.tsx"], }, ] result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) a_id, b_id, c_id = (UUID(sid) for sid in result["root_subtask_ids"]) b = await db_session.get(TaskTable, b_id) assert a_id in b.dependency_ids # original chain: B waits on A (migrations) # Neither A nor B adds a migration anymore; C now explicitly waits on A. revised: list[dict[str, Any]] = [ {**drafts[0], "adds_migration": False}, {**drafts[1], "adds_migration": False}, {**drafts[2], "depends_on": [0]}, ] service = get_prompter_service(db=db_session) await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", revised, ceo_id, route="board" ) c = await db_session.get(TaskTable, c_id) assert a_id not in b.dependency_ids # stale edge cleared assert a_id in c.dependency_ids # fresh edge applied @pytest.mark.asyncio async def test_update_live_batch_board_route_resets_review_flag( db_session: Any, ) -> None: """route='board' sends the redrafted umbrella back for another review round.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) umbrella_id = UUID(result["umbrella_task_id"]) umbrella = await db_session.get(TaskTable, umbrella_id) umbrella.board_review_complete = True await db_session.flush() service = get_prompter_service(db=db_session) await service.update_live_batch( umbrella_id, "Seed batch", drafts, ceo_id, route="board" ) assert umbrella.board_review_complete is False @pytest.mark.asyncio async def test_update_live_batch_main_pm_route_approves_and_activates( db_session: Any, ) -> None: """route='main_pm' hands the umbrella to Main PM and releases its BACKLOG root-subtasks to PENDING (approve_and_start's existing batch-activation).""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) # merge() with the fixed AGENT_UUIDS id: idempotent whether or not another # test already committed this row on the shared session-scoped test DB # (mirrors ``_seed_project_and_ceo``'s product-owner/main-pm upsert above). main_pm = await db_session.merge( AgentTable( id=UUID(AGENT_UUIDS["main-pm"]), name="main-pm", slug="main-pm", role=AgentRole.MAIN_PM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="x", capabilities=[], permissions={}, metrics={}, ) ) await db_session.flush() drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) umbrella_id = UUID(result["umbrella_task_id"]) umbrella = await db_session.get(TaskTable, umbrella_id) umbrella.board_review_complete = True await db_session.flush() service = get_prompter_service(db=db_session) out = await service.update_live_batch( umbrella_id, "Seed batch", drafts, ceo_id, route="main_pm" ) assert umbrella.assigned_to == main_pm.id assert umbrella.team == Team.MAIN_PM child = await db_session.get(TaskTable, UUID(out["root_subtask_ids"][0])) assert child.status == TaskStatus.PENDING # released by approve_and_start assert child.team == Team.MAIN_PM @pytest.mark.asyncio async def test_update_live_batch_refuses_when_child_not_backlog( db_session: Any, ) -> None: """A root-subtask already past BACKLOG (claimed/dispatched) refuses the whole redraft — in-place mutation of live work would corrupt it.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) child = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][0])) child.status = TaskStatus.PENDING await db_session.flush() service = get_prompter_service(db=db_session) with pytest.raises(ValidationError, match="BACKLOG"): await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", drafts, ceo_id, route="board", ) @pytest.mark.asyncio async def test_update_live_batch_refuses_non_umbrella_task(db_session: Any) -> None: """A task_id that isn't a batch umbrella (e.g. a root-subtask) is refused.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) root_subtask_id = UUID(result["root_subtask_ids"][0]) service = get_prompter_service(db=db_session) with pytest.raises(ValidationError, match="umbrella"): await service.update_live_batch( root_subtask_id, "Seed batch", drafts, ceo_id, route="board" ) @pytest.mark.asyncio async def test_update_live_batch_refuses_unknown_task(db_session: Any) -> None: """A task_id that doesn't exist at all raises NotFoundError, not a crash.""" _project1, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) with pytest.raises(NotFoundError): await service.update_live_batch( uuid4(), "Seed batch", [{"title": "x", "acceptance_criteria": ["a"]}], ceo_id, route="board", ) @pytest.mark.asyncio async def test_update_live_batch_round2_after_shrink_succeeds(db_session: Any) -> None: """A round-1 shrink leaves a CANCELLED child; a round-2 redraft must ignore it — not be refused by the backlog gate, not mismatch positionally.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = [ *_two_item_drafts(project1, project2), { "title": "Three", "acceptance_criteria": ["z"], "team": "backend", "project_id": str(project1), }, ] result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) umbrella_id = UUID(result["umbrella_task_id"]) service = get_prompter_service(db=db_session) # Round 1: shrink to 2 → cancels the third child. round1 = await service.update_live_batch( umbrella_id, "Seed batch", drafts[:2], ceo_id, route="board" ) # Round 2: revise the surviving 2 in place — must not see the cancelled row. revised = [ {**drafts[0], "title": "One round-2"}, {**drafts[1], "title": "Two round-2"}, ] round2 = await service.update_live_batch( umbrella_id, "Seed batch", revised, ceo_id, route="board" ) assert round2["root_subtask_ids"] == round1["root_subtask_ids"] # in-place child0 = await db_session.get(TaskTable, UUID(round2["root_subtask_ids"][0])) assert child0.title == "One round-2" cancelled = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][2])) assert cancelled.status == TaskStatus.CANCELLED # untouched by round 2 @pytest.mark.asyncio async def test_update_live_batch_round2_after_scope_change_succeeds( db_session: Any, ) -> None: """A round-1 scope change leaves a CANCELLED child mid-list; round 2 must match drafts positionally against only the LIVE children.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) project3 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) umbrella_id = UUID(result["umbrella_task_id"]) service = get_prompter_service(db=db_session) # Round 1: move the second draft to project3 → old child cancelled, replaced. moved = { "title": "Two moved", "acceptance_criteria": ["y2"], "team": "frontend", "project_id": str(project3), } round1 = await service.update_live_batch( umbrella_id, "Seed batch", [drafts[0], moved], ceo_id, project_ids=[project1, project2, project3], route="board", ) replacement_id = UUID(round1["root_subtask_ids"][1]) # Round 2: same scopes → both live children patched in place (the cancelled # original must not shift the positional pairing). round2 = await service.update_live_batch( umbrella_id, "Seed batch", [{**drafts[0], "title": "One round-2"}, {**moved, "title": "Two round-2"}], ceo_id, project_ids=[project1, project2, project3], route="board", ) assert UUID(round2["root_subtask_ids"][1]) == replacement_id # in-place replacement = await db_session.get(TaskTable, replacement_id) assert replacement.title == "Two round-2" assert replacement.status == TaskStatus.BACKLOG @pytest.mark.asyncio async def test_update_live_batch_rejects_single_project_collapse( db_session: Any, ) -> None: """A redraft whose drafts all target one project is refused — same MegaTask-shape gate as the create path.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) collapsed = [ {**drafts[0]}, {**drafts[1], "project_id": str(project1)}, # both on project1 now ] service = get_prompter_service(db=db_session) with pytest.raises(ValidationError, match="at least two distinct projects"): await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", collapsed, ceo_id, project_ids=[project1, project2], route="board", ) @pytest.mark.asyncio async def test_update_live_batch_rejects_out_of_scope_draft(db_session: Any) -> None: """A redraft draft targeting a project outside the scoped set is refused — same scope gate as the create path (scope derived from the live children when the caller passes none).""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) drafts = _two_item_drafts(project1, project2) result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2] ) drifted = [ drafts[0], {**drafts[1], "project_id": str(uuid4())}, # never in scope ] service = get_prompter_service(db=db_session) with pytest.raises(ValidationError, match="outside this MegaTask"): await service.update_live_batch( UUID(result["umbrella_task_id"]), "Seed batch", drifted, ceo_id, route="board", ) @pytest.mark.asyncio async def test_distinct_projects_for_batch_unions_cells_and_skips_cancelled( db_session: Any, ) -> None: """Scope recovery unions each live child's project_id + cell_projects rows; a cancelled child's repo does not resurrect.""" project1, ceo_id = await _seed_project_and_ceo(db_session) project2 = await _seed_second_project(db_session, ceo_id) project3 = await _seed_second_project(db_session, ceo_id) drafts: list[dict[str, Any]] = [ { "title": "Single-project child", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(project1), }, { "title": "Multi-cell child", "acceptance_criteria": ["b"], "the_work": [ {"team": "backend", "summary": "s", "project_id": str(project2)}, {"team": "frontend", "summary": "s", "project_id": str(project3)}, ], }, ] result = await _confirm_board_batch( db_session, ceo_id, drafts, [project1, project2, project3] ) umbrella_id = UUID(result["umbrella_task_id"]) task_service = get_task_service(db_session) recovered = await task_service.distinct_projects_for_batch(umbrella_id) assert set(recovered) == {project1, project2, project3} # Cancel the single-project child — its repo drops out of the recovery. await task_service.cancel(UUID(result["root_subtask_ids"][0]), agent_role="main_pm") recovered = await task_service.distinct_projects_for_batch(umbrella_id) assert set(recovered) == {project2, project3} def test_preview_batch_computes_waves_without_creating() -> None: """preview_batch is pure: it returns the same waves confirm would wire, with no DB session and no task creation.""" service = get_prompter_service() # no db — pure compute drafts: list[dict[str, Any]] = [ {"title": "A", "adds_migration": True, "intends_to_touch": ["a.py"]}, {"title": "B", "adds_migration": True, "intends_to_touch": ["b.py"]}, {"title": "C", "intends_to_touch": ["c.py"]}, ] result = service.preview_batch(drafts) # A & B chain on the migration rule; C is independent → [[0, 2], [1]]. assert result["waves"] == [[0, 2], [1]] assert isinstance(result["warnings"], list) def test_preview_batch_honours_declared_depends_on() -> None: """B1b: a draft's declared depends_on becomes a real edge even when the collision surfaces are disjoint (the live S6 break: declared waves were dropped because the intends_to_touch globs didn't overlap).""" service = get_prompter_service() drafts: list[dict[str, Any]] = [ {"title": "A", "intends_to_touch": ["a.py"]}, {"title": "B", "intends_to_touch": ["b.py"], "depends_on": [0]}, ] result = service.preview_batch(drafts) assert result["waves"] == [[0], [1]] def test_preview_batch_coerces_string_declared_indices() -> None: """The LLM sometimes emits depends_on indices as strings ("0").""" service = get_prompter_service() drafts: list[dict[str, Any]] = [ {"title": "A", "intends_to_touch": ["a.py"]}, {"title": "B", "intends_to_touch": ["b.py"], "depends_on": ["0"]}, ] result = service.preview_batch(drafts) assert result["waves"] == [[0], [1]] def test_preview_batch_rejects_out_of_range_declared_dep() -> None: service = get_prompter_service() drafts: list[dict[str, Any]] = [ {"title": "A", "intends_to_touch": ["a.py"], "depends_on": [9]}, ] with pytest.raises(ValidationError): service.preview_batch(drafts) def test_preview_batch_rejects_empty() -> None: service = get_prompter_service() with pytest.raises(ValidationError): service.preview_batch([]) def test_preview_batch_tolerates_bare_string_the_work() -> None: """Regression: the LLM sometimes emits the_work as bare team-name strings. preview_batch must not 500 on that shape (it did: 'str' has no 'get').""" service = get_prompter_service() drafts: list[dict[str, Any]] = [ { "title": "A", "project_id": str(uuid4()), "the_work": ["backend"], "intends_to_touch": ["a.py"], }, { "title": "B", "project_id": str(uuid4()), "the_work": ["backend", "frontend"], "intends_to_touch": ["b.py"], }, ] result = service.preview_batch(drafts) assert isinstance(result["waves"], list) assert isinstance(result["warnings"], list) # ============================================================================= # Per-cell project map (multi-cell MegaTask root-subtask seam) — pure helpers # ============================================================================= def _work(team: str, project_id: UUID | None) -> dict[str, Any]: entry: dict[str, Any] = {"team": team, "summary": "s", "items": ["x"]} if project_id is not None: entry["project_id"] = str(project_id) return entry def test_draft_cell_map_collects_per_cell_projects_in_order() -> None: """A multi-cell draft yields one (team, project_id) per the_work entry, in the_work order, de-duped by team.""" be_proj, fe_proj = uuid4(), uuid4() draft = { "the_work": [ _work("backend", be_proj), _work("frontend", fe_proj), ] } assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj), (Team.FRONTEND, fe_proj)] def test_draft_cell_map_dedupes_repeated_team_keeping_first() -> None: """Two entries for the same cell (LLM noise) keep the first mapping — a task_cell_projects row is unique per (task, team).""" first, second = uuid4(), uuid4() draft = { "the_work": [ _work("backend", first), _work("backend", second), ] } assert _draft_cell_map(draft) == [(Team.BACKEND, first)] def test_draft_cell_map_skips_entries_without_project_id() -> None: """An entry with no project_id (single-cell legacy or a bare team string) is skipped — the draft then falls back to its top-level project_id.""" be_proj = uuid4() draft = { "the_work": [ _work("backend", be_proj), {"team": "frontend", "summary": "s", "items": []}, # no project_id ] } assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj)] def test_draft_cell_map_empty_when_no_entry_has_project_id() -> None: """A legacy single-cell draft (top-level project_id, bare-string the_work) yields an empty map — the caller falls back to the top-level project_id.""" assert _draft_cell_map({"the_work": ["backend", "frontend"]}) == [] assert _draft_cell_map({"the_work": [{"team": "backend"}]}) == [] def test_draft_cell_map_skips_off_enum_teams_but_rejects_bad_uuids() -> None: """Off-enum team names are skipped (the intake agent is an LLM and can emit a non-cell team), and an entry with no project_id is skipped (legacy single-cell). But a present-but-malformed project_id is a hard error — silently dropping it would collapse a 2-cell map to 1-cell and mis-route the draft as a single-project task (#58).""" good = uuid4() draft = { "the_work": [ _work("backend", good), {"team": "marketing", "project_id": str(uuid4())}, # not a cell _work("frontend", None), # missing project_id — skipped ] } assert _draft_cell_map(draft) == [(Team.BACKEND, good)] bad = { "the_work": [ _work("backend", good), {"team": "ux_ui", "project_id": "not-a-uuid"}, # malformed — reject ] } with pytest.raises(ValidationError, match="Invalid project_id"): _draft_cell_map(bad) def test_validate_batch_scope_accepts_single_multi_cell_draft() -> None: """One 2-cell draft already spans ≥2 distinct projects → valid MegaTask.""" be_proj, fe_proj = uuid4(), uuid4() drafts = [ { "title": "S1", "acceptance_criteria": ["a"], "the_work": [ _work("backend", be_proj), _work("frontend", fe_proj), ], } ] # Must not raise: 2 distinct projects across the one draft's cells. PrompterService._validate_batch_scope(drafts, [be_proj, fe_proj]) def test_validate_batch_scope_rejects_out_of_scope_per_cell_project() -> None: """A per-cell project_id outside the scoped set is refused.""" in_scope, out_of_scope = uuid4(), uuid4() drafts = [ { "title": "S1", "acceptance_criteria": ["a"], "the_work": [ _work("backend", in_scope), _work("frontend", out_of_scope), ], } ] with pytest.raises(ValidationError, match="outside this MegaTask"): PrompterService._validate_batch_scope(drafts, [in_scope, uuid4()]) def test_validate_batch_scope_rejects_draft_with_no_project() -> None: """A draft with neither a per-cell map nor a top-level project_id is refused.""" drafts = [ { "title": "S1", "acceptance_criteria": ["a"], "the_work": [_work("backend", None), _work("frontend", None)], } ] with pytest.raises(ValidationError, match="has no project"): PrompterService._validate_batch_scope(drafts, [uuid4(), uuid4()]) def test_validate_batch_scope_distinct_count_spans_all_cells() -> None: """The ≥2 minimum counts distinct projects across ALL drafts' cells, not per draft. Two single-cell drafts on the same project still fail (degenerate).""" only = uuid4() drafts = [ { "title": "A", "acceptance_criteria": ["a"], "the_work": [_work("backend", only)], }, { "title": "B", "acceptance_criteria": ["b"], "the_work": [_work("frontend", only)], # same project, different cell }, ] with pytest.raises(ValidationError, match="at least two distinct projects"): PrompterService._validate_batch_scope(drafts, [only, uuid4()]) def test_validate_batch_scope_legacy_single_cell_drafts_still_work() -> None: """Back-compat: drafts using a top-level project_id (no the_work map) still validate against the scope and the ≥2 distinct minimum.""" p1, p2 = uuid4(), uuid4() drafts = [ {"title": "A", "acceptance_criteria": ["a"], "project_id": str(p1)}, {"title": "B", "acceptance_criteria": ["b"], "project_id": str(p2)}, ] PrompterService._validate_batch_scope(drafts, [p1, p2]) @pytest.mark.asyncio async def test_resolve_owning_team_multi_cell_map_routes_to_main_pm() -> None: """A multi-cell ad-hoc map is a coordination root (mirrors a product root), so it routes to the Main PM — never the lead cell (a cell PM can't delegate cross-cell; that would deadlock the fan-out). No DB access on this branch.""" service = get_prompter_service() # no db — the cell-map branch never reads it be_proj, fe_proj = uuid4(), uuid4() draft = { "the_work": [ _work("backend", be_proj), _work("frontend", fe_proj), ] } team = await service._resolve_owning_team( draft, resolved_product_id=None, resolved_assigned_to=None, team_override=None, default_lead=Team.BACKEND, ) assert team is Team.MAIN_PM @pytest.mark.asyncio async def test_resolve_owning_team_single_cell_still_routes_to_lead_cell() -> None: """A single-cell project draft (no product, no multi-cell map) keeps its legacy owner: the lead cell.""" service = get_prompter_service() draft = {"the_work": [_work("backend", uuid4())]} team = await service._resolve_owning_team( draft, resolved_product_id=None, resolved_assigned_to=None, team_override=None, default_lead=Team.BACKEND, ) assert team is Team.BACKEND @pytest.mark.asyncio async def test_resolve_owning_team_product_with_cell_map_stays_board( monkeypatch: pytest.MonkeyPatch, ) -> None: """#160: a product draft that also carries a ≥2-cell the_work map is still a product root — on the board-review path it stays team=board, not forced to Main PM (which would strand it past the CEO Approve & Start gate).""" service = get_prompter_service() be_proj, fe_proj = uuid4(), uuid4() draft = {"the_work": [_work("backend", be_proj), _work("frontend", fe_proj)]} product_id = uuid4() async def _is_board(_agent_id: UUID) -> bool: return True monkeypatch.setattr(service, "_assignee_is_board", _is_board) team = await service._resolve_owning_team( draft, resolved_product_id=product_id, resolved_assigned_to=uuid4(), team_override=None, default_lead=Team.BACKEND, ) assert team is Team.BOARD async def _not_board(_agent_id: UUID) -> bool: return False monkeypatch.setattr(service, "_assignee_is_board", _not_board) team = await service._resolve_owning_team( draft, resolved_product_id=product_id, resolved_assigned_to=uuid4(), team_override=None, default_lead=Team.BACKEND, ) assert team is Team.MAIN_PM def test_clean_list_extracts_dict_wrapped_items() -> None: """#159: _clean_list (via coerce_str_list) extracts text from the Claude SDK's XML-ish dict wrappers (```` -> ``{"item": {"$text": …}}``) instead of rendering ``str(dict)``. Pins the behavior so a regression to ``str(dict)`` in the rendered description is caught.""" out = _clean_list([{"item": {"$text": "build it"}}, "ship it", " ", ""]) assert out == ["build it", "ship it"] @pytest.mark.asyncio async def test_create_task_from_draft_preserves_product_with_one_cell_map( db_session: Any, ) -> None: """#57: a draft carrying a top-level product_id AND a 1-cell the_work map keeps the product — the lone cell map is redundant, not a signal to drop the product and force the cell's project_id.""" _project_id, ceo_id = await _seed_project_and_ceo(db_session) product_id = uuid4() db_session.add( ProductTable( id=product_id, name="One-cell product", slug=f"prod-{uuid4().hex[:8]}", description="x", created_by=ceo_id, ) ) await db_session.flush() service = get_prompter_service(db=db_session) draft = { "title": "Board-led single-cell product", "acceptance_criteria": ["done"], "product_id": str(product_id), "the_work": [_work("backend", uuid4())], } task = await service.create_task_from_draft(draft, ceo_id) assert task.product_id == product_id assert task.project_id is None @pytest.mark.asyncio async def test_create_task_from_draft_does_not_mutate_caller_draft( db_session: Any, ) -> None: """#59: create_task_from_draft coerces + recomposes on a copy — the caller's draft dict and its the_work unit dicts are left untouched (no in-place rewrite of acceptance_criteria / items).""" project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) original_items = [" trim me ", "keep"] draft: dict[str, Any] = { "title": "No-mutation check", "acceptance_criteria": ["done"], "project_id": str(project_id), "the_work": [ {"team": "backend", "summary": "s", "items": list(original_items)} ], } await service.create_task_from_draft(draft, ceo_id) # The caller's the_work unit items were NOT coerced in place... assert draft["the_work"][0]["items"] == original_items # ...and the top-level acceptance_criteria was NOT replaced. assert draft["acceptance_criteria"] == ["done"] @pytest.mark.asyncio async def test_create_task_from_draft_defaults_source_to_prompter( db_session: Any, ) -> None: project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) draft = { "title": "Default-source draft", "acceptance_criteria": ["done"], "project_id": str(project_id), } task = await service.create_task_from_draft(draft, ceo_id) assert task.source == "prompter" @pytest.mark.asyncio async def test_create_task_from_draft_accepts_custom_source( db_session: Any, ) -> None: """A non-intake caller (e.g. an approved roadmap item) stamps its own source tag on the draft instead of the intake default.""" project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) draft = { "title": "Roadmap-sourced draft", "acceptance_criteria": ["done"], "project_id": str(project_id), "source": "roadmap", } task = await service.create_task_from_draft(draft, ceo_id) assert task.source == "roadmap" assert task.confirmed_by_human is True # the CEO approval IS the confirmation @pytest.mark.asyncio async def test_create_task_from_draft_rejects_unwhitelisted_source( db_session: Any, ) -> None: """An LLM-authored draft can't impersonate a privileged origin: a source outside the whitelist falls back to 'prompter' (a 'release_manager' spoof would otherwise wedge the release engine's one-open-proposal dedup).""" project_id, ceo_id = await _seed_project_and_ceo(db_session) service = get_prompter_service(db=db_session) draft = { "title": "Spoofed-source draft", "acceptance_criteria": ["done"], "project_id": str(project_id), "source": "release_manager", } task = await service.create_task_from_draft(draft, ceo_id) assert task.source == "prompter" # ============================================================================= # Prompter memory v1 — history digest + compact search rows (pure, no DB) # ============================================================================= def _task(title: str, **overrides: Any) -> TaskTable: """An unattached TaskTable instance — plain attribute assignment, no session. Defaults to a completed backend task with no dates; pass ``completed_at`` / ``updated_at`` / ``created_at`` / ``status`` / ``team`` to override. """ fields: dict[str, Any] = { "id": uuid4(), "title": title, "status": TaskStatus.COMPLETED, "team": Team.BACKEND, "completed_at": None, "updated_at": None, "created_at": None, } fields.update(overrides) return TaskTable(**fields) def test_task_activity_date_prefers_completed_at() -> None: now = datetime.now(UTC) task = _task( "t", completed_at=now, updated_at=now - timedelta(days=1), created_at=now - timedelta(days=2), ) assert _task_activity_date(task) == now def test_task_activity_date_falls_back_to_updated_at() -> None: now = datetime.now(UTC) task = _task( "t", completed_at=None, updated_at=now, created_at=now - timedelta(days=1) ) assert _task_activity_date(task) == now def test_task_activity_date_falls_back_to_created_at() -> None: now = datetime.now(UTC) task = _task("t", completed_at=None, updated_at=None, created_at=now) assert _task_activity_date(task) == now def test_title_excerpt_leaves_short_titles_untouched() -> None: assert _title_excerpt("Fix login bug") == "Fix login bug" def test_title_excerpt_truncates_long_titles_with_ellipsis() -> None: long_title = "A" * 100 excerpt = _title_excerpt(long_title) assert len(excerpt) == _HISTORY_TITLE_EXCERPT_CAP assert excerpt.endswith("…") def test_build_history_digest_empty_is_blank() -> None: assert build_history_digest([]) == "" def test_build_history_digest_caps_at_limit_keeps_most_recent() -> None: now = datetime.now(UTC) # t0 oldest ... t19 newest. ascending = [ _task(f"t{i}", created_at=now + timedelta(days=i), updated_at=None) for i in range(20) ] # Mimic the DB's most-recent-first ordering. most_recent_first = list(reversed(ascending)) digest = build_history_digest(most_recent_first) lines = digest.splitlines() assert len(lines) == _HISTORY_DIGEST_PER_PROJECT_LIMIT for i in range(5): # the 5 oldest are excluded assert f"`{str(ascending[i].id)[:8]}`" not in digest for i in range(5, 20): # the 15 most recent are present assert f"`{str(ascending[i].id)[:8]}`" in digest def test_build_history_digest_renders_oldest_first() -> None: now = datetime.now(UTC) a = _task("Task A", created_at=now - timedelta(days=2), updated_at=None) b = _task("Task B", created_at=now - timedelta(days=1), updated_at=None) c = _task("Task C", created_at=now, updated_at=None) # DB order is most-recent-first: C, B, A. digest = build_history_digest([c, b, a]) idx_a = digest.index("Task A") idx_b = digest.index("Task B") idx_c = digest.index("Task C") assert idx_a < idx_b < idx_c def test_compact_task_rows_shape() -> None: now = datetime.now(UTC) task = _task( "Fix login bug", status=TaskStatus.COMPLETED, team=Team.BACKEND, completed_at=now, ) rows = compact_task_rows([task]) assert len(rows) == 1 row = rows[0] assert set(row.keys()) == {"id", "title", "status", "team", "date"} assert row["id"] == str(task.id) assert row["title"] == "Fix login bug" assert row["status"] == "completed" assert row["team"] == "backend" assert row["date"] == now.date().isoformat() def test_compact_task_rows_preserves_none_team() -> None: task = _task("No team", team=None, created_at=datetime.now(UTC)) rows = compact_task_rows([task]) assert rows[0]["team"] is None # ----------------------------------------------------------------------------- # history_digest_layer — ambient-block assembly (project_history_digest stubbed) # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_history_digest_layer_empty_projects_returns_none() -> None: assert await history_digest_layer(cast("AsyncSession", object()), []) is None @pytest.mark.asyncio async def test_history_digest_layer_single_project_has_no_header( monkeypatch: pytest.MonkeyPatch, ) -> None: async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None: return "- `abc12345` Some task (completed, 2026-01-01)" monkeypatch.setattr(prompter_module, "project_history_digest", _fake) project = SimpleNamespace(slug="roboco", id=uuid4()) text = await history_digest_layer(cast("AsyncSession", object()), [project]) assert text is not None assert text.startswith("## Task History\n\n### Recent tasks\n") assert "### Recent tasks —" not in text @pytest.mark.asyncio async def test_history_digest_layer_multi_project_headers_by_slug( monkeypatch: pytest.MonkeyPatch, ) -> None: projects = [ SimpleNamespace(slug="backend-svc", id=uuid4()), SimpleNamespace(slug="frontend-app", id=uuid4()), ] async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None: return f"- `deadbeef` Task for {project.slug} (completed, 2026-01-01)" monkeypatch.setattr(prompter_module, "project_history_digest", _fake) text = await history_digest_layer(cast("AsyncSession", object()), projects) assert text is not None assert "### Recent tasks — `backend-svc`" in text assert "### Recent tasks — `frontend-app`" in text @pytest.mark.asyncio async def test_history_digest_layer_skips_projects_with_no_tasks( monkeypatch: pytest.MonkeyPatch, ) -> None: has_tasks = SimpleNamespace(slug="has-tasks", id=uuid4()) no_tasks = SimpleNamespace(slug="empty-proj", id=uuid4()) async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None: return ( "- `deadbeef` A task (completed, 2026-01-01)" if project is has_tasks else None ) monkeypatch.setattr(prompter_module, "project_history_digest", _fake) text = await history_digest_layer( cast("AsyncSession", object()), [has_tasks, no_tasks] ) assert text is not None assert "has-tasks" in text assert "empty-proj" not in text @pytest.mark.asyncio async def test_history_digest_layer_all_empty_returns_none( monkeypatch: pytest.MonkeyPatch, ) -> None: async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None: return None monkeypatch.setattr(prompter_module, "project_history_digest", _fake) projects = [ SimpleNamespace(slug="a", id=uuid4()), SimpleNamespace(slug="b", id=uuid4()), ] assert await history_digest_layer(cast("AsyncSession", object()), projects) is None