mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* refactor(analytics): reduce cyclomatic complexity in usage/pricing/rollup
Collapse the three near-identical get_by_* aggregation methods in
UsageService into a shared _aggregate_by helper parameterized by group
column and key name, and centralize token null-coalescing in a
_row_tokens helper. Extract the per-row upsert in _sweep_daily_rollup
into _upsert_rollup_row, and the pricing-table lookup into
_lookup_prices. All blocks now rank <= B and both modules rank A, so the
xenon gate passes; behavior is unchanged and existing tests stay green.
* feat(billing): make token pricing provider-aware
Distinguish three cases when a model has no per-token rate: a non-Anthropic
model (local Ollama, or an Ollama Cloud ":cloud" model billed by flat
subscription / GPU-time) legitimately has no per-token cost and returns 0.0
silently; an unpriced Anthropic ("claude"-named) model also returns 0.0 but
logs a warning, since that is real spend being undercounted and catches new or
renamed Claude models missing from the table. Folds the old ollama/ prefix
special-case into the general non-Anthropic path so there is one code path,
and replaces the blanket 'no pricing data' warning that fired even for
self-hosted models.
* fix(tasks): preserve ownership when force-unclaiming to pending
The stale-claim reaper and the dependency-blocked release both routed through
_force_unclaim_to_pending, which nulled assigned_to and left the task in a
pending state owned by nobody — no dispatcher re-spawns an ownerless pending
task, so it went dormant. The dispatcher-side claimed_by fallback only masked
half the cases.
Capture the owner before releasing the claim and keep both assigned_to and
claimed_by pointed at it (mirroring the unblock restore), releasing only the
live claim (active_claimant_id + heartbeat) and the WorkSession. The same agent
now resumes the task once it re-dispatches. Updates the reaper test that
asserted the old orphaning behavior and adds owner-preservation coverage for
both the reaper and dependency-release paths.
* fix(tasks): unblock restores the owner into both ownership fields
Audit follow-up to the force-unclaim ownership fix. unblock() only restored
assigned_to from blocker_raised_by, which block() stashes solely from
assigned_to. A task claimed via give_me_work (claimed_by set, assigned_to null)
therefore unblocked into a split-owner state — assigned_to null but claimed_by
set — that both the dev dispatcher and the PM pool-router race to pick up. It
also left claimed_by pointing at the resolver PM after an escalation.
Resolve the owner as blocker_raised_by or assigned_to or claimed_by and write
it to both fields, matching the force-unclaim and reassign convention so the
original worker resumes cleanly. Adds coverage for the give_me_work-claim case
and asserts owner restoration on the existing in_progress-resume test.
* test(orchestrator): cover dev owner resolution and the claimed_by fallback
_resolve_dev_owner_uuid had no coverage. Add the status-dependent precedence
(claimed/blocked prefer the live claimant; other statuses prefer the
PM-assigned owner) and the half-reap fallback where a pending task with
assigned_to nulled still resolves its owner from claimed_by instead of going
dormant.
* fix(tasks): wire the pre-block snapshot so unblock(restore=True) works
The restore=True path on a PM unblock was a no-op: pre_block_state /
pre_block_assignee (migration 006) were read by unblock_with_restore but never
written, so it always fell through to legacy unblock() and the restore flag did
nothing.
Snapshot the resting status + owner at every block entry (dependency block,
soft block, escalation) before mutating, capturing only the first block in a
chain so a re-block doesn't overwrite the original state. Escalation snapshots
the outgoing owner, not the escalation target, so restore returns the original
worker. The restore path applies the same branchless guard legacy unblock()
relies on — a snapshotted in_progress with no branch diverts to pending instead
of looping the dispatcher — and is extracted into _apply_pre_block_restore to
keep complexity under the gate. Adds coverage for snapshot capture, restore,
the branchless divert, and escalation owner restoration.
* test(tasks): update orphan-reconciler and dependency-release tests for owner preservation
Both the startup orphan reconciler and the dependency-blocked claim release
route through unclaim_for_reaper / _force_unclaim_to_pending, which now preserve
the owner instead of nulling assigned_to. Update the two tests that asserted the
old orphaning behavior to assert the owner is kept (so the same agent resumes)
while the live claim is released.
* chore(tests): scrub internal work-item labels from test names, docstrings, comments
Rename four test files that carried audit work-item IDs in their filenames
(test_p0_7_branch_atomicity, test_p2_8_orphan_reconciler,
test_p2_9_autogen_prompt_layer, test_p2_7_attempt_id) to describe what they
test, and strip the matching P-/D-/S- cluster labels from docstrings, comments,
and assertion messages across the test suite and two orchestrator comments.
These are internal references with no meaning in the codebase; behavior is
unchanged.
* style: reformat assertion line shortened by the internal-ref scrub
* build: waive unreachable torch CVE-2025-3000 in pip-audit gate
torch is a transitive CPU-pinned dep (piragi / sentence-transformers) never
loaded at runtime — the stack uses Ollama over HTTP for all embeddings/LLM, so
the vulnerable torch.jit.script path is unreachable. CVE-2025-3000 is MEDIUM,
local-only, with no published fix. Documented --ignore-vuln waiver; revisit when
a fixed torch ships.
* fix(orchestrator): route unplaceable pending tasks to main-pm instead of dropping them
_get_routing_target returned None when a 'dev'-classified task had no cell
agent (no team, or a non-cell team like fullstack/system) or when the routing
classification was unrecognized. _route_unassigned_pm_task logged 'no routing
target found' and returned, leaving the task ownerless and pending — and no
dispatcher re-spawns an unrouted pending task, so it went dormant for 10+ min
until the stuck-task detector caught it.
Fall back to main-pm (the same default cell_pm routing and escalation already
use) so the task is always owned and triaged, never stranded. Logs the fallback
so unplaceable tasks stay visible. Adds a test asserting no (routing, team)
combination ever resolves to None.
* fix(panel): make intake chat markdown inherit the bubble's text color
MarkdownBody is shared by the assistant (text-foreground) and user
(text-primary-foreground) bubbles. [&_*]:!text-inherit only colored the prose
div's descendants, so the prose div itself kept the prose typography body color
(gray) and children inherited that — unreadable on the muted assistant bubble.
Add !text-inherit on the prose div itself so it inherits the bubble's color
too; descendants then inherit the correct foreground. Fixes both bubbles without
hardcoding a color.
* fix(prompter): keep a board-reviewed product on the board team so Approve & Start shows
A product coordination root confirmed via 'Board review & Start' is assigned to
a board reviewer (product-owner) for review, but create_task_from_draft set
team=main_pm for every product unconditionally. The CEO's Approve & Start gate
keys on team=board, so the button never appeared — and because the owner stayed
a board agent while the team said main_pm, the dispatcher routed it to the board
path (nothing left to do after review) and the task stranded at pending, with
the board agent fruitlessly trying to escalate it up.
Route a product by its assignee: a board reviewer keeps it team=board (so the
gate appears and approve_and_start later hands it to Main PM), while a main-pm
assignee — the 'Approve & Start' straight-through path — is team=main_pm. Adds
_assignee_is_board mirroring TaskService's board-role check, and a test.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
664 lines
22 KiB
Python
664 lines
22 KiB
Python
"""Unit tests for PrompterService.
|
|
|
|
Tests the service layer logic with mocked LLM calls. Uses an in-memory
|
|
async session (via conftest fixtures) for DB-backed tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
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.base import NotFoundError, ServiceError, ValidationError
|
|
from roboco.services.prompter import (
|
|
PrompterService,
|
|
_build_chat_prompt,
|
|
_build_draft_prompt,
|
|
_build_reasoning,
|
|
compose_description,
|
|
derive_scale,
|
|
get_prompter_service,
|
|
parse_readiness,
|
|
)
|
|
|
|
# =============================================================================
|
|
# 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"
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_build_chat_prompt_basic() -> None:
|
|
messages = [
|
|
{"role": "user", "content": "I need a feature"},
|
|
{"role": "assistant", "content": "Tell me more"},
|
|
]
|
|
prompt = _build_chat_prompt(messages, None)
|
|
assert "user: I need a feature" in prompt
|
|
assert "assistant: Tell me more" in prompt
|
|
assert "Continue the conversation" in prompt
|
|
|
|
|
|
def test_build_chat_prompt_with_context() -> None:
|
|
messages = [{"role": "user", "content": "hello"}]
|
|
prompt = _build_chat_prompt(messages, {"team": "backend"})
|
|
assert "Context:" in prompt
|
|
assert "team: backend" in prompt
|
|
|
|
|
|
def test_build_draft_prompt() -> None:
|
|
messages = [{"role": "user", "content": "I need a login page"}]
|
|
prompt = _build_draft_prompt(messages, None)
|
|
assert "valid JSON" in prompt
|
|
assert "user: I need a login page" in prompt
|
|
|
|
|
|
def test_build_reasoning() -> None:
|
|
messages = [{"role": "user", "content": "Hello"}] * 3
|
|
draft = {"title": "My Task", "team": "backend", "estimated_complexity": "medium"}
|
|
reasoning = _build_reasoning(messages, draft)
|
|
assert "My Task" in reasoning
|
|
assert "backend" in reasoning
|
|
assert "medium" in reasoning
|
|
assert "3 messages" in reasoning
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
|
|
|
|
# =============================================================================
|
|
# Stateless chat / draft (with mocked LLM)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_success_with_mock_llm() -> None:
|
|
service = get_prompter_service()
|
|
|
|
with patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
return_value="Great, let's continue!",
|
|
):
|
|
result = await service.chat(
|
|
messages=[{"role": "user", "content": "I need a feature"}]
|
|
)
|
|
|
|
assert result["message"] == "Great, let's continue!"
|
|
assert result["draft_ready"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_draft_ready_signal() -> None:
|
|
service = get_prompter_service()
|
|
|
|
reply = (
|
|
"Got it — I have what I need.\n\n"
|
|
'```roboco-meta\n{"covered": ["objective", "scope", "surface", '
|
|
'"acceptance"], "ready": true, "scale": "single"}\n```'
|
|
)
|
|
with patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
return_value=reply,
|
|
):
|
|
result = await service.chat(
|
|
messages=[{"role": "user", "content": "I need a feature"}]
|
|
)
|
|
|
|
assert result["draft_ready"] is True
|
|
assert result["scale"] == "single"
|
|
# The control block is stripped from the user-visible reply.
|
|
assert "roboco-meta" not in result["message"]
|
|
assert result["message"] == "Got it — I have what I need."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_raises_on_empty_response() -> None:
|
|
service = get_prompter_service()
|
|
|
|
with (
|
|
patch.object(
|
|
service, "_create_message", new_callable=AsyncMock, return_value=""
|
|
),
|
|
pytest.raises(ServiceError, match="LLM returned empty content"),
|
|
):
|
|
await service.chat(messages=[{"role": "user", "content": "Hello"}])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_raises_on_llm_error() -> None:
|
|
service = get_prompter_service()
|
|
|
|
with (
|
|
patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
side_effect=Exception("API unavailable"),
|
|
),
|
|
pytest.raises(ServiceError, match="LLM chat failed"),
|
|
):
|
|
await service.chat(messages=[{"role": "user", "content": "Hello"}])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_success_with_mock_llm() -> None:
|
|
service = get_prompter_service()
|
|
|
|
draft_data = {
|
|
"title": "Add login",
|
|
"description": "Implement login functionality with JWT tokens",
|
|
"acceptance_criteria": ["User can log in"],
|
|
"team": "backend",
|
|
"task_type": "code",
|
|
"nature": "technical",
|
|
"estimated_complexity": "medium",
|
|
}
|
|
|
|
with patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(draft_data),
|
|
):
|
|
result = await service.draft(
|
|
messages=[{"role": "user", "content": "I need a login feature"}]
|
|
)
|
|
|
|
assert result["draft"]["title"] == "Add login"
|
|
assert result["draft"]["source"] == "prompter"
|
|
assert result["draft"]["confirmed_by_human"] is False
|
|
assert "reasoning" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_raises_on_invalid_json() -> None:
|
|
service = get_prompter_service()
|
|
|
|
with (
|
|
patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
return_value="Not JSON at all",
|
|
),
|
|
pytest.raises(ValidationError, match="not valid JSON"),
|
|
):
|
|
await service.draft(messages=[{"role": "user", "content": "Hello"}])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_raises_on_llm_error() -> None:
|
|
service = get_prompter_service()
|
|
|
|
with (
|
|
patch.object(
|
|
service,
|
|
"_create_message",
|
|
new_callable=AsyncMock,
|
|
side_effect=Exception("API unavailable"),
|
|
),
|
|
pytest.raises(ServiceError, match="LLM draft generation failed"),
|
|
):
|
|
await service.draft(messages=[{"role": "user", "content": "Hello"}])
|
|
|
|
|
|
# =============================================================================
|
|
# Session-based: create_session (DB-backed via conftest)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_db(db_session: Any) -> None:
|
|
"""create_session persists a PrompterSessionTable row."""
|
|
service = get_prompter_service(db=db_session)
|
|
|
|
agent_id = uuid4()
|
|
agent = AgentTable(
|
|
id=agent_id,
|
|
name="TestAgent",
|
|
slug=f"test-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
|
|
session = await service.create_session(agent_id=agent_id)
|
|
assert session.id is not None
|
|
assert session.status == "active"
|
|
assert session.agent_id == agent_id
|
|
|
|
|
|
@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(po.id) is True
|
|
assert await service._assignee_is_board(hom.id) is True
|
|
assert await service._assignee_is_board(dev.id) is False
|
|
# Unknown id is not a board agent — defensive, must not raise.
|
|
assert await service._assignee_is_board(uuid4()) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_not_found(db_session: Any) -> None:
|
|
"""_get_session raises NotFoundError for unknown session ID."""
|
|
service = get_prompter_service(db=db_session)
|
|
with pytest.raises(NotFoundError):
|
|
await service._get_session(uuid4(), uuid4())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_draft_empty_session_raises(db_session: Any) -> None:
|
|
"""get_or_generate_draft raises ValidationError if no messages exist."""
|
|
service = get_prompter_service(db=db_session)
|
|
|
|
agent_id = uuid4()
|
|
agent = AgentTable(
|
|
id=agent_id,
|
|
name="TestAgent",
|
|
slug=f"test-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
|
|
session = await service.create_session(agent_id=agent_id)
|
|
|
|
with pytest.raises(ValidationError, match="empty conversation"):
|
|
await service.get_or_generate_draft(
|
|
session_id=UUID(str(session.id)),
|
|
agent_id=agent_id,
|
|
)
|
|
|
|
|
|
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"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_live_draft_product_routes_to_main_pm(db_session: Any) -> None:
|
|
"""A product-scoped live draft is a board-led coordination root (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)
|
|
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
|