mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
100% Coverage
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"""roboco.models.journal coverage — entry factory functions.
|
||||
|
||||
Covers all create_*_entry helpers: happy paths, journal_id required guard,
|
||||
and optional-field branches (how_applied, source, resolution, help_needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import JournalEntryType
|
||||
from roboco.models.journal import (
|
||||
DecisionLogParams,
|
||||
GeneralEntryParams,
|
||||
LearningEntryParams,
|
||||
StruggleEntryParams,
|
||||
TaskReflectionParams,
|
||||
create_decision_log,
|
||||
create_general_entry,
|
||||
create_learning_entry,
|
||||
create_struggle_entry,
|
||||
create_task_reflection,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_task_reflection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_task_reflection_happy_path() -> None:
|
||||
journal_id = uuid4()
|
||||
task_id = uuid4()
|
||||
entry = create_task_reflection(
|
||||
TaskReflectionParams(
|
||||
task_id=task_id,
|
||||
title="Reflection",
|
||||
what_done="Implemented X",
|
||||
what_learned="Y is tricky",
|
||||
what_struggled="Z timing",
|
||||
next_steps=["Add tests", "Refactor"],
|
||||
journal_id=journal_id,
|
||||
)
|
||||
)
|
||||
assert entry.type == JournalEntryType.TASK_REFLECTION
|
||||
assert entry.journal_id == journal_id
|
||||
assert entry.task_id == task_id
|
||||
assert "Implemented X" in entry.content
|
||||
assert "[ ] Add tests" in entry.content
|
||||
|
||||
|
||||
def test_create_task_reflection_requires_journal_id() -> None:
|
||||
with pytest.raises(ValueError, match="journal_id is required"):
|
||||
create_task_reflection(
|
||||
TaskReflectionParams(
|
||||
task_id=uuid4(),
|
||||
title="t",
|
||||
what_done="d",
|
||||
what_learned="l",
|
||||
what_struggled="s",
|
||||
next_steps=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_decision_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_decision_log_happy_path() -> None:
|
||||
journal_id = uuid4()
|
||||
entry = create_decision_log(
|
||||
DecisionLogParams(
|
||||
title="Pick framework",
|
||||
context="We need a web framework",
|
||||
options=[
|
||||
{"name": "FastAPI", "pros": "fast", "cons": "newer"},
|
||||
{"name": "Django", "pros": "batteries", "cons": "heavy"},
|
||||
],
|
||||
chosen="FastAPI",
|
||||
rationale="async-first",
|
||||
consequences=["Need uvicorn", "Async DB"],
|
||||
journal_id=journal_id,
|
||||
)
|
||||
)
|
||||
assert entry.type == JournalEntryType.DECISION_LOG
|
||||
assert "Option 1: FastAPI" in entry.content
|
||||
assert "Option 2: Django" in entry.content
|
||||
assert "Chose **FastAPI**" in entry.content
|
||||
|
||||
|
||||
def test_create_decision_log_handles_missing_option_keys() -> None:
|
||||
"""Options without name/pros/cons should default to placeholders."""
|
||||
entry = create_decision_log(
|
||||
DecisionLogParams(
|
||||
title="Pick",
|
||||
context="ctx",
|
||||
options=[{}, {}],
|
||||
chosen="X",
|
||||
rationale="r",
|
||||
consequences=[],
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert "Option 1" in entry.content
|
||||
assert "N/A" in entry.content
|
||||
|
||||
|
||||
def test_create_decision_log_requires_journal_id() -> None:
|
||||
with pytest.raises(ValueError, match="journal_id is required"):
|
||||
create_decision_log(
|
||||
DecisionLogParams(
|
||||
title="t",
|
||||
context="c",
|
||||
options=[],
|
||||
chosen="x",
|
||||
rationale="r",
|
||||
consequences=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_learning_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_learning_entry_with_all_optionals() -> None:
|
||||
entry = create_learning_entry(
|
||||
LearningEntryParams(
|
||||
title="Learned async",
|
||||
what_learned="async/await",
|
||||
how_applied="Used in service layer",
|
||||
source="Real World Python book",
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert entry.type == JournalEntryType.LEARNING
|
||||
assert "How I Applied It" in entry.content
|
||||
assert "## Source" in entry.content
|
||||
assert entry.sentiment == "positive"
|
||||
|
||||
|
||||
def test_create_learning_entry_without_optionals() -> None:
|
||||
entry = create_learning_entry(
|
||||
LearningEntryParams(
|
||||
title="L",
|
||||
what_learned="basic",
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert "How I Applied It" not in entry.content
|
||||
assert "## Source" not in entry.content
|
||||
|
||||
|
||||
def test_create_learning_entry_requires_journal_id() -> None:
|
||||
with pytest.raises(ValueError, match="journal_id is required"):
|
||||
create_learning_entry(LearningEntryParams(title="t", what_learned="w"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_struggle_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_struggle_entry_with_resolution_and_help() -> None:
|
||||
entry = create_struggle_entry(
|
||||
StruggleEntryParams(
|
||||
title="Bug",
|
||||
what_struggled="race condition",
|
||||
attempted_solutions=["lock", "queue"],
|
||||
resolution="Used asyncio.Lock",
|
||||
help_needed="None - solved",
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert entry.type == JournalEntryType.STRUGGLE
|
||||
assert "## Resolution" in entry.content
|
||||
assert "## Help Needed" in entry.content
|
||||
assert entry.sentiment == "frustrated"
|
||||
|
||||
|
||||
def test_create_struggle_entry_without_optionals() -> None:
|
||||
entry = create_struggle_entry(
|
||||
StruggleEntryParams(
|
||||
title="x",
|
||||
what_struggled="y",
|
||||
attempted_solutions=["a"],
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert "## Resolution" not in entry.content
|
||||
assert "## Help Needed" not in entry.content
|
||||
|
||||
|
||||
def test_create_struggle_entry_requires_journal_id() -> None:
|
||||
with pytest.raises(ValueError, match="journal_id is required"):
|
||||
create_struggle_entry(
|
||||
StruggleEntryParams(
|
||||
title="t",
|
||||
what_struggled="x",
|
||||
attempted_solutions=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_general_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_general_entry_happy_path() -> None:
|
||||
entry = create_general_entry(
|
||||
GeneralEntryParams(
|
||||
title="General note",
|
||||
content="Just thinking",
|
||||
is_private=True,
|
||||
journal_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert entry.type == JournalEntryType.GENERAL
|
||||
assert entry.is_private is True
|
||||
assert entry.content == "Just thinking"
|
||||
|
||||
|
||||
def test_create_general_entry_requires_journal_id() -> None:
|
||||
with pytest.raises(ValueError, match="journal_id is required"):
|
||||
create_general_entry(GeneralEntryParams(title="t", content="c"))
|
||||
@@ -0,0 +1,120 @@
|
||||
"""roboco.models.llm coverage — TOON config and metrics dataclasses.
|
||||
|
||||
Covers ToonConfig defaults, EncodedBlock __str__, LLMUsage totals, and
|
||||
ToonMetrics record/reset/savings/fallback rate computations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from roboco.models.llm import (
|
||||
EncodedBlock,
|
||||
LLMUsage,
|
||||
ToonConfig,
|
||||
ToonMetrics,
|
||||
)
|
||||
|
||||
_BIG_JSON = 100
|
||||
_BIG_TOON = 60
|
||||
_INPUT = 100
|
||||
_OUTPUT = 50
|
||||
_CACHE_CREATE = 10
|
||||
_CACHE_READ = 20
|
||||
_FALLBACK_PERCENT_HALF = 50.0
|
||||
_TWO_DECODES = 2
|
||||
|
||||
|
||||
def test_toon_config_defaults() -> None:
|
||||
cfg = ToonConfig()
|
||||
assert cfg.delimiter == ","
|
||||
assert cfg.indent > 0
|
||||
assert cfg.include_length is True
|
||||
|
||||
|
||||
def test_encoded_block_str_includes_label_and_content() -> None:
|
||||
blk = EncodedBlock(content="data", label="Section")
|
||||
s = str(blk)
|
||||
assert "Section" in s
|
||||
assert "data" in s
|
||||
|
||||
|
||||
def test_llm_usage_total_tokens() -> None:
|
||||
usage = LLMUsage(input_tokens=_INPUT, output_tokens=_OUTPUT)
|
||||
assert usage.total_tokens == _INPUT + _OUTPUT
|
||||
|
||||
|
||||
def test_llm_usage_total_input_with_cache() -> None:
|
||||
usage = LLMUsage(
|
||||
input_tokens=_INPUT,
|
||||
cache_creation_input_tokens=_CACHE_CREATE,
|
||||
cache_read_input_tokens=_CACHE_READ,
|
||||
)
|
||||
assert usage.total_input_with_cache == _INPUT + _CACHE_CREATE + _CACHE_READ
|
||||
|
||||
|
||||
def test_toon_metrics_initial_state() -> None:
|
||||
m = ToonMetrics()
|
||||
assert m.json_chars == 0
|
||||
assert m.toon_chars == 0
|
||||
assert m.encode_count == 0
|
||||
assert m.decode_count == 0
|
||||
assert m.savings_percent == 0.0 # zero json_chars guard
|
||||
assert m.fallback_rate == 0.0 # zero decode_count guard
|
||||
|
||||
|
||||
def test_toon_metrics_record_encode() -> None:
|
||||
m = ToonMetrics()
|
||||
m.record_encode(json_chars=_BIG_JSON, toon_chars=_BIG_TOON)
|
||||
assert m.json_chars == _BIG_JSON
|
||||
assert m.toon_chars == _BIG_TOON
|
||||
assert m.encode_count == 1
|
||||
# 40% reduction from 100 -> 60.
|
||||
expected_savings = (1 - _BIG_TOON / _BIG_JSON) * 100
|
||||
assert m.savings_percent == expected_savings
|
||||
|
||||
|
||||
def test_toon_metrics_record_decode_no_fallback() -> None:
|
||||
m = ToonMetrics()
|
||||
m.record_decode(used_fallback=False)
|
||||
assert m.decode_count == 1
|
||||
assert m.decode_fallback_count == 0
|
||||
assert m.fallback_rate == 0.0
|
||||
|
||||
|
||||
def test_toon_metrics_record_decode_with_fallback() -> None:
|
||||
m = ToonMetrics()
|
||||
m.record_decode(used_fallback=False)
|
||||
m.record_decode(used_fallback=True)
|
||||
assert m.decode_count == _TWO_DECODES
|
||||
assert m.decode_fallback_count == 1
|
||||
assert m.fallback_rate == _FALLBACK_PERCENT_HALF
|
||||
|
||||
|
||||
def test_toon_metrics_to_dict_shape() -> None:
|
||||
m = ToonMetrics()
|
||||
m.record_encode(_BIG_JSON, _BIG_TOON)
|
||||
m.record_decode(used_fallback=True)
|
||||
out = m.to_dict()
|
||||
assert out["json_chars"] == _BIG_JSON
|
||||
assert out["toon_chars"] == _BIG_TOON
|
||||
assert out["encode_count"] == 1
|
||||
assert out["decode_count"] == 1
|
||||
assert "started_at" in out
|
||||
|
||||
|
||||
def test_toon_metrics_reset_clears_counters() -> None:
|
||||
m = ToonMetrics()
|
||||
m.record_encode(_BIG_JSON, _BIG_TOON)
|
||||
m.record_decode(used_fallback=True)
|
||||
before_reset = m.started_at
|
||||
m.reset()
|
||||
assert m.json_chars == 0
|
||||
assert m.toon_chars == 0
|
||||
assert m.encode_count == 0
|
||||
assert m.decode_count == 0
|
||||
assert m.decode_fallback_count == 0
|
||||
# started_at is refreshed after reset.
|
||||
assert isinstance(m.started_at, datetime)
|
||||
assert m.started_at >= before_reset
|
||||
assert m.started_at.tzinfo == UTC
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Coverage for misc model methods, factories, and helper functions.
|
||||
|
||||
Targets the long-tail of small-percent gaps in roboco.models.* — convenience
|
||||
properties, factory functions, lookup helpers, and __post_init__ branches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.models import AgentRole, Team
|
||||
from roboco.models.a2a import (
|
||||
A2AConversation,
|
||||
A2AConversationStatus,
|
||||
A2ATaskState,
|
||||
a2a_state_to_task_status,
|
||||
task_status_to_a2a_state,
|
||||
)
|
||||
from roboco.models.agents import AgentConfig
|
||||
from roboco.models.base import ModelProvider
|
||||
from roboco.models.channel import (
|
||||
create_announcements_channel,
|
||||
create_cell_channel,
|
||||
create_cross_cell_channel,
|
||||
)
|
||||
from roboco.models.handoff import HandoffParams, create_handoff
|
||||
from roboco.models.llm_catalog import (
|
||||
MODEL_CATALOG,
|
||||
_build_anthropic_entries,
|
||||
provider_type_for_model,
|
||||
)
|
||||
from roboco.models.permissions import (
|
||||
AgentContext,
|
||||
PermissionLevel,
|
||||
_build_role_levels,
|
||||
)
|
||||
from roboco.models.runtime import (
|
||||
AgentInstance,
|
||||
OrchestratorAgentConfig,
|
||||
OrchestratorAgentState,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentConfig convenience properties (roboco/models/agents.py 63, 68, 73, 78)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_config_convenience_properties() -> None:
|
||||
cfg = AgentConfig(
|
||||
name="Test",
|
||||
slug="test",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
system_prompt="hello",
|
||||
)
|
||||
# provider, model, temperature, max_tokens come from model_config_data
|
||||
assert cfg.provider == cfg.model_config_data.provider
|
||||
assert cfg.model == cfg.model_config_data.name
|
||||
assert cfg.temperature == cfg.model_config_data.temperature
|
||||
assert cfg.max_tokens == cfg.model_config_data.max_tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentInstance.__post_init__ — explicit None id branch (runtime.py 76-77)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_instance_post_init_assigns_uuid_when_falsy() -> None:
|
||||
# Forcing an empty UUID(int=0) is falsy → the post_init triggers re-assign.
|
||||
inst = AgentInstance.__new__(AgentInstance)
|
||||
# Fill required dataclass fields explicitly so __post_init__ runs cleanly.
|
||||
inst.id = None # type: ignore[assignment]
|
||||
inst.agent_id = "be-dev-1"
|
||||
inst.state = OrchestratorAgentState.OFFLINE
|
||||
inst.container_id = None
|
||||
inst.config = None
|
||||
inst.started_at = None
|
||||
inst.last_activity = None
|
||||
inst.current_task_id = None
|
||||
inst.error_count = 0
|
||||
inst.waiting_for = None
|
||||
inst.waiting_context = {}
|
||||
inst.__post_init__()
|
||||
assert inst.id is not None
|
||||
|
||||
|
||||
def test_agent_instance_default_factory_assigns_id() -> None:
|
||||
inst = AgentInstance(agent_id="be-dev-1")
|
||||
assert inst.id is not None
|
||||
|
||||
|
||||
def test_orchestrator_agent_config_defaults() -> None:
|
||||
|
||||
cfg = OrchestratorAgentConfig(
|
||||
agent_id="be-dev-1",
|
||||
blueprint_path=Path("/tmp/blueprint"),
|
||||
)
|
||||
assert cfg.model == "sonnet"
|
||||
assert cfg.provider_type == "anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llm_catalog: catalog skip when MODEL_MAP missing entry (line 56)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_anthropic_entries_skips_missing_models() -> None:
|
||||
# Patch MODEL_MAP to be missing one entry → loop hits the `continue` branch.
|
||||
with patch(
|
||||
"roboco.models.llm_catalog.MODEL_MAP",
|
||||
{"opus": "claude-opus-4-6"}, # sonnet+haiku missing
|
||||
):
|
||||
entries = _build_anthropic_entries()
|
||||
# Only one entry survives — opus.
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model_name == "opus"
|
||||
|
||||
|
||||
def test_provider_type_for_model_known() -> None:
|
||||
# Pick the first catalog entry deterministically.
|
||||
sample = MODEL_CATALOG[0]
|
||||
assert provider_type_for_model(sample.model_name) == sample.provider_type
|
||||
|
||||
|
||||
def test_provider_type_for_model_unknown_returns_none() -> None:
|
||||
assert provider_type_for_model("nonexistent-model-x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# permissions._build_role_levels — exception branch (lines 35-36)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_role_levels_skips_invalid_role() -> None:
|
||||
"""Bad role string should be silently swallowed in the except branch."""
|
||||
with patch(
|
||||
"roboco.models.permissions.ROLE_PERMISSION_LEVELS",
|
||||
{"not_a_role": "CEO", "ceo": "CEO"},
|
||||
):
|
||||
result = _build_role_levels()
|
||||
# 'not_a_role' silently dropped; ceo retained.
|
||||
assert AgentRole.CEO in result
|
||||
assert result[AgentRole.CEO] == PermissionLevel.CEO
|
||||
|
||||
|
||||
def test_build_role_levels_skips_invalid_level() -> None:
|
||||
with patch(
|
||||
"roboco.models.permissions.ROLE_PERMISSION_LEVELS",
|
||||
{"ceo": "NOT_A_LEVEL"},
|
||||
):
|
||||
result = _build_role_levels()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel factories — create_cell_channel, create_cross_cell_channel,
|
||||
# create_announcements_channel (channel.py 98, 116-117, 135)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_cell_channel() -> None:
|
||||
members = [uuid4(), uuid4()]
|
||||
auditor_id = uuid4()
|
||||
ch = create_cell_channel("backend", members, auditor_id)
|
||||
assert ch.name == "#backend-cell"
|
||||
assert ch.slug == "backend-cell"
|
||||
assert ch.members == members
|
||||
assert ch.silent_observers == [auditor_id]
|
||||
|
||||
|
||||
def test_create_cross_cell_channel() -> None:
|
||||
members = [uuid4(), uuid4()]
|
||||
main_pm = uuid4()
|
||||
auditor = uuid4()
|
||||
ch = create_cross_cell_channel("dev-all", members, main_pm, auditor)
|
||||
assert ch.name == "#dev-all"
|
||||
# main_pm joins the member list.
|
||||
assert main_pm in ch.members
|
||||
assert auditor in ch.silent_observers
|
||||
|
||||
|
||||
def test_create_announcements_channel() -> None:
|
||||
agents = [uuid4() for _ in range(3)]
|
||||
board = [uuid4(), uuid4()]
|
||||
main_pm = uuid4()
|
||||
auditor = uuid4()
|
||||
ch = create_announcements_channel(agents, board, main_pm, auditor)
|
||||
assert ch.slug == "announcements"
|
||||
# Writers = board + main_pm.
|
||||
assert main_pm in ch.writers
|
||||
for b in board:
|
||||
assert b in ch.writers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A helpers — A2AConversation methods + a2a_state_to_task_status fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a2a_conversation_other_agent() -> None:
|
||||
conv = A2AConversation(agent_a="be-dev-1", agent_b="fe-dev-1")
|
||||
assert conv.other_agent("be-dev-1") == "fe-dev-1"
|
||||
assert conv.other_agent("fe-dev-1") == "be-dev-1"
|
||||
|
||||
|
||||
def test_a2a_conversation_my_unread() -> None:
|
||||
_UNREAD_A = 2
|
||||
_UNREAD_B = 5
|
||||
conv = A2AConversation(
|
||||
agent_a="be-dev-1",
|
||||
agent_b="fe-dev-1",
|
||||
unread_by_a=_UNREAD_A,
|
||||
unread_by_b=_UNREAD_B,
|
||||
)
|
||||
assert conv.my_unread("be-dev-1") == _UNREAD_A
|
||||
assert conv.my_unread("fe-dev-1") == _UNREAD_B
|
||||
|
||||
|
||||
def test_a2a_conversation_is_participant() -> None:
|
||||
conv = A2AConversation(agent_a="be-dev-1", agent_b="fe-dev-1")
|
||||
assert conv.is_participant("be-dev-1") is True
|
||||
assert conv.is_participant("fe-dev-1") is True
|
||||
assert conv.is_participant("ux-dev-1") is False
|
||||
|
||||
|
||||
def test_a2a_state_to_task_status_unknown_returns_pending() -> None:
|
||||
# Default fallback for any unknown state is "pending".
|
||||
class _FakeState:
|
||||
pass
|
||||
|
||||
fake = _FakeState()
|
||||
assert a2a_state_to_task_status(fake) == "pending" # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_a2a_state_to_task_status_known() -> None:
|
||||
assert a2a_state_to_task_status(A2ATaskState.SUBMITTED) == "pending"
|
||||
assert a2a_state_to_task_status(A2ATaskState.WORKING) == "in_progress"
|
||||
assert a2a_state_to_task_status(A2ATaskState.COMPLETED) == "completed"
|
||||
assert a2a_state_to_task_status(A2ATaskState.FAILED) == "cancelled"
|
||||
assert a2a_state_to_task_status(A2ATaskState.INPUT_REQUIRED) == "blocked"
|
||||
|
||||
|
||||
def test_task_status_to_a2a_state_known() -> None:
|
||||
assert task_status_to_a2a_state("pending") == A2ATaskState.SUBMITTED
|
||||
assert task_status_to_a2a_state("in_progress") == A2ATaskState.WORKING
|
||||
assert task_status_to_a2a_state("completed") == A2ATaskState.COMPLETED
|
||||
assert task_status_to_a2a_state("cancelled") == A2ATaskState.CANCELLED
|
||||
assert task_status_to_a2a_state("blocked") == A2ATaskState.INPUT_REQUIRED
|
||||
|
||||
|
||||
def test_task_status_to_a2a_state_unknown_returns_working() -> None:
|
||||
assert task_status_to_a2a_state("garbage_status") == A2ATaskState.WORKING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentContext.level — line 79
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_context_level_returns_role_level() -> None:
|
||||
ctx = AgentContext(agent_id=uuid4(), role=AgentRole.CEO)
|
||||
assert ctx.level == PermissionLevel.CEO
|
||||
|
||||
|
||||
def test_agent_context_level_falls_back_when_role_missing() -> None:
|
||||
"""Edge: build an AgentContext for a role removed from ROLE_LEVELS."""
|
||||
ctx = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER)
|
||||
# Default fallback returns CELL_MEMBER even when not present.
|
||||
assert ctx.level in PermissionLevel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversation status enum + closed/paused round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a2a_conversation_status_values() -> None:
|
||||
assert A2AConversationStatus.ACTIVE == "active"
|
||||
assert A2AConversationStatus.PAUSED == "paused"
|
||||
assert A2AConversationStatus.CLOSED == "closed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handoff factory (models/handoff.py 202-208)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_handoff_includes_changelog_required_doc() -> None:
|
||||
task_id = uuid4()
|
||||
handoff = create_handoff(
|
||||
HandoffParams(
|
||||
task_id=task_id,
|
||||
summary="Built feature X",
|
||||
commits=[{"sha": "abc123", "message": "init"}],
|
||||
dev_notes_location="/notes/x",
|
||||
new_functionality=["new login"],
|
||||
modified_behavior=["redirect"],
|
||||
breaking_changes=["remove old endpoint"],
|
||||
)
|
||||
)
|
||||
assert handoff.task_id == task_id
|
||||
assert handoff.summary == "Built feature X"
|
||||
# required_docs always includes a changelog item.
|
||||
doc_types = [d.doc_type for d in handoff.required_docs]
|
||||
assert "changelog" in doc_types
|
||||
assert handoff.new_functionality == ["new login"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity: ModelProvider import is reachable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_model_provider_enum_has_anthropic() -> None:
|
||||
assert ModelProvider.ANTHROPIC == "anthropic"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Notification model factory function coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.models import NotificationPriority, NotificationType
|
||||
from roboco.models.notification import (
|
||||
create_alert,
|
||||
create_blocker_escalation,
|
||||
create_broadcast,
|
||||
create_documentation_request,
|
||||
create_priority_change,
|
||||
create_review_request,
|
||||
create_task_assignment,
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_assignment() -> None:
|
||||
pm_id = uuid4()
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
n = create_task_assignment(
|
||||
from_pm=pm_id,
|
||||
to_agent=agent_id,
|
||||
task_id=task_id,
|
||||
task_title="Build feature X",
|
||||
)
|
||||
assert n.type == NotificationType.TASK_ASSIGNMENT
|
||||
assert n.from_agent == pm_id
|
||||
assert n.to_agents == [agent_id]
|
||||
assert n.related_task_id == task_id
|
||||
assert "Build feature X" in n.subject
|
||||
|
||||
|
||||
def test_create_blocker_escalation() -> None:
|
||||
from_pm = uuid4()
|
||||
to_pm = uuid4()
|
||||
task_id = uuid4()
|
||||
n = create_blocker_escalation(
|
||||
from_pm=from_pm,
|
||||
to_pm=to_pm,
|
||||
task_id=task_id,
|
||||
blocker_description="DB unreachable",
|
||||
)
|
||||
assert n.type == NotificationType.BLOCKER_ESCALATION
|
||||
assert n.priority == NotificationPriority.HIGH
|
||||
assert n.body == "DB unreachable"
|
||||
assert n.related_task_id == task_id
|
||||
|
||||
|
||||
def test_create_review_request() -> None:
|
||||
from_pm = uuid4()
|
||||
to_qa = uuid4()
|
||||
task_id = uuid4()
|
||||
n = create_review_request(
|
||||
from_pm=from_pm,
|
||||
to_qa=to_qa,
|
||||
task_id=task_id,
|
||||
task_title="Refactor login",
|
||||
)
|
||||
assert n.type == NotificationType.REVIEW_REQUEST
|
||||
assert "Refactor login" in n.subject
|
||||
assert n.to_agents == [to_qa]
|
||||
|
||||
|
||||
def test_create_documentation_request() -> None:
|
||||
from_pm = uuid4()
|
||||
to_doc = uuid4()
|
||||
task_id = uuid4()
|
||||
n = create_documentation_request(
|
||||
from_pm=from_pm,
|
||||
to_documenter=to_doc,
|
||||
task_id=task_id,
|
||||
task_title="API endpoint",
|
||||
)
|
||||
assert n.type == NotificationType.DOCUMENTATION_REQUEST
|
||||
assert "API endpoint" in n.subject
|
||||
assert "needs documentation" in n.body
|
||||
|
||||
|
||||
def test_create_priority_change_p0_marks_urgent() -> None:
|
||||
sender = uuid4()
|
||||
recipients = [uuid4()]
|
||||
task_id = uuid4()
|
||||
n = create_priority_change(
|
||||
from_agent=sender,
|
||||
to_agents=recipients,
|
||||
task_id=task_id,
|
||||
task_title="Critical task",
|
||||
new_priority=0,
|
||||
)
|
||||
assert n.type == NotificationType.PRIORITY_CHANGE
|
||||
assert n.priority == NotificationPriority.URGENT
|
||||
assert "P0" in n.body
|
||||
|
||||
|
||||
def test_create_priority_change_high_label() -> None:
|
||||
sender = uuid4()
|
||||
recipients = [uuid4()]
|
||||
task_id = uuid4()
|
||||
n = create_priority_change(
|
||||
from_agent=sender,
|
||||
to_agents=recipients,
|
||||
task_id=task_id,
|
||||
task_title="P1 task",
|
||||
new_priority=1,
|
||||
)
|
||||
assert n.priority == NotificationPriority.HIGH
|
||||
assert "P1" in n.body
|
||||
|
||||
|
||||
def test_create_priority_change_unknown_priority_uses_fallback_label() -> None:
|
||||
sender = uuid4()
|
||||
recipients = [uuid4()]
|
||||
task_id = uuid4()
|
||||
n = create_priority_change(
|
||||
from_agent=sender,
|
||||
to_agents=recipients,
|
||||
task_id=task_id,
|
||||
task_title="Custom",
|
||||
new_priority=99,
|
||||
)
|
||||
# Body falls through to f"P{new_priority}".
|
||||
assert "P99" in n.body
|
||||
|
||||
|
||||
def test_create_alert() -> None:
|
||||
sender = uuid4()
|
||||
recipients = [uuid4(), uuid4()]
|
||||
n = create_alert(
|
||||
from_agent=sender,
|
||||
to_agents=recipients,
|
||||
subject="System down",
|
||||
body="rebooting now",
|
||||
)
|
||||
assert n.type == NotificationType.ALERT
|
||||
assert n.priority == NotificationPriority.URGENT
|
||||
assert n.subject == "System down"
|
||||
|
||||
|
||||
def test_create_broadcast_does_not_require_ack() -> None:
|
||||
sender = uuid4()
|
||||
recipients = [uuid4()]
|
||||
n = create_broadcast(
|
||||
from_agent=sender,
|
||||
to_agents=recipients,
|
||||
subject="All hands",
|
||||
body="please review",
|
||||
)
|
||||
assert n.type == NotificationType.BROADCAST
|
||||
assert n.requires_ack is False
|
||||
@@ -37,7 +37,8 @@ def test_clear_returns_content_and_resets() -> None:
|
||||
def test_char_count() -> None:
|
||||
buf = _buffer()
|
||||
buf.append("12345")
|
||||
assert buf.char_count == 5
|
||||
_DATA_LEN = 5
|
||||
assert buf.char_count == _DATA_LEN
|
||||
|
||||
|
||||
def test_age_property_positive() -> None:
|
||||
@@ -95,14 +96,24 @@ def test_has_sentence_ending_empty_returns_false() -> None:
|
||||
assert buf._has_sentence_ending() is False
|
||||
|
||||
|
||||
_DEFAULT_MIN_CHARS = 50
|
||||
_DEFAULT_MAX_CHARS = 5000
|
||||
_DEFAULT_IDLE_SECONDS = 2.0
|
||||
_CUSTOM_MIN_CHARS = 10
|
||||
_CUSTOM_MAX_BUFFERS = 5
|
||||
|
||||
|
||||
def test_transcription_config_defaults() -> None:
|
||||
cfg = TranscriptionConfig()
|
||||
assert cfg.min_chars_for_extraction == 50
|
||||
assert cfg.max_chars_before_flush == 5000
|
||||
assert cfg.idle_threshold_seconds == 2.0
|
||||
assert cfg.min_chars_for_extraction == _DEFAULT_MIN_CHARS
|
||||
assert cfg.max_chars_before_flush == _DEFAULT_MAX_CHARS
|
||||
assert cfg.idle_threshold_seconds == _DEFAULT_IDLE_SECONDS
|
||||
|
||||
|
||||
def test_transcription_config_custom() -> None:
|
||||
cfg = TranscriptionConfig(min_chars_for_extraction=10, max_buffers_per_agent=5)
|
||||
assert cfg.min_chars_for_extraction == 10
|
||||
assert cfg.max_buffers_per_agent == 5
|
||||
cfg = TranscriptionConfig(
|
||||
min_chars_for_extraction=_CUSTOM_MIN_CHARS,
|
||||
max_buffers_per_agent=_CUSTOM_MAX_BUFFERS,
|
||||
)
|
||||
assert cfg.min_chars_for_extraction == _CUSTOM_MIN_CHARS
|
||||
assert cfg.max_buffers_per_agent == _CUSTOM_MAX_BUFFERS
|
||||
|
||||
Reference in New Issue
Block a user