Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions tests/test_agent_chat_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,93 @@ async def test_lead_who_is_author_does_not_receive_own_message():

slugs = [c[0] for c in bridge.calls]
assert "coord" not in slugs


# ---------------------------------------------------------------------------
# System context (agent_manual) injection
# ---------------------------------------------------------------------------

def _a2a_project_channel_with_leads(members, leads, mode="quiet"):
return {
"id": "c1",
"type": "group",
"project_id": "proj-1",
"members": members,
"settings": {
"kind": "a2a",
"response_mode": mode,
"leads": leads,
"max_hops": 3,
"cooldown_seconds": 5,
"rate_cap_per_minute": 20,
"muted": [],
},
}


@pytest.mark.asyncio
async def test_manual_injected_as_first_system_message():
"""context[0] passed to the bridge must be a system-role manual message."""
bridge = _FakeBridge()
state = _state_for({"name": "coord", "status": "running"}, bridge=bridge)
state.config.agents = [{"name": "coord", "status": "running"}]
from tinyagentos.chat.group_policy import GroupPolicy
state.group_policy = GroupPolicy()
router = AgentChatRouter(state)

msg = {"id": "m1", "author_id": "user", "author_type": "user",
"content": "@coord go", "metadata": {"hops_since_user": 0}}
ch = _a2a_project_channel_with_leads(["user", "coord"], leads=["coord"])
await router._route(msg, ch)

assert len(bridge.calls) == 1
_, enqueued = bridge.calls[0]
ctx = enqueued["context"]
assert ctx, "context must not be empty"
first = ctx[0]
assert first["role"] == "system"
# Spot-check for known manual content.
assert "@-mention routing" in first["content"]
assert "kanban board" in first["content"]


@pytest.mark.asyncio
async def test_manual_lead_branch_correct_for_lead():
"""When the recipient is a lead, the lead branch text appears."""
bridge = _FakeBridge()
state = _state_for({"name": "coord", "status": "running"}, bridge=bridge)
state.config.agents = [{"name": "coord", "status": "running"}]
from tinyagentos.chat.group_policy import GroupPolicy
state.group_policy = GroupPolicy()
router = AgentChatRouter(state)

msg = {"id": "m1", "author_id": "user", "author_type": "user",
"content": "@coord go", "metadata": {"hops_since_user": 0}}
ch = _a2a_project_channel_with_leads(["user", "coord"], leads=["coord"])
await router._route(msg, ch)

_, enqueued = bridge.calls[0]
manual = enqueued["context"][0]["content"]
assert "You ARE designated lead" in manual
assert "You are NOT a lead" not in manual


@pytest.mark.asyncio
async def test_manual_non_lead_branch_correct_for_worker():
"""When the recipient is not a lead, the non-lead branch text appears."""
bridge = _FakeBridge()
state = _state_for({"name": "worker", "status": "running"}, bridge=bridge)
state.config.agents = [{"name": "worker", "status": "running"}]
from tinyagentos.chat.group_policy import GroupPolicy
state.group_policy = GroupPolicy()
router = AgentChatRouter(state)

msg = {"id": "m1", "author_id": "user", "author_type": "user",
"content": "@worker do it", "metadata": {"hops_since_user": 0}}
ch = _a2a_project_channel_with_leads(["user", "worker"], leads=["coord"])
await router._route(msg, ch)

_, enqueued = bridge.calls[0]
manual = enqueued["context"][0]["content"]
assert "You are NOT a lead" in manual
assert "You ARE designated lead" not in manual
173 changes: 173 additions & 0 deletions tests/test_agent_manual.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Tests for tinyagentos.agent_manual.build_manual."""
import pytest

from tinyagentos.agent_manual import build_manual


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _dm_channel():
return {"id": "c1", "type": "dm", "settings": {}}


def _group_channel():
return {"id": "c2", "type": "group", "settings": {"response_mode": "quiet"}}


def _a2a_project_channel(leads=None):
return {
"id": "c3",
"type": "group",
"project_id": "proj-1",
"settings": {
"kind": "a2a",
"leads": leads or [],
},
}


def _token_estimate(text: str) -> float:
"""Simple heuristic: words * 1.3 (matches spec)."""
return len(text.split()) * 1.3


# ---------------------------------------------------------------------------
# DM channel
# ---------------------------------------------------------------------------

class TestDMChannel:
def test_dm_has_header(self):
text = build_manual(_dm_channel(), "alice", [])
assert "taOS" in text
assert "operating manual" in text

def test_dm_has_dm_stub(self):
text = build_manual(_dm_channel(), "alice", [])
assert "1-on-1" in text or "direct-message" in text

def test_dm_no_verbs_section(self):
# The verbs section header must not appear (stub may mention the word)
text = build_manual(_dm_channel(), "alice", [])
assert "## 2. Project task verbs" not in text
assert "/new" not in text

def test_dm_no_lead_section(self):
text = build_manual(_dm_channel(), "alice", [])
assert "Lead vs non-lead" not in text

def test_dm_no_mention_routing_section(self):
# The section header must not appear (stub mentions the term to say it doesn't apply)
text = build_manual(_dm_channel(), "alice", [])
assert "## 1. @-mentions are how messages route" not in text

def test_dm_token_budget(self):
text = build_manual(_dm_channel(), "alice", [])
assert _token_estimate(text) <= 200, (
f"DM manual exceeds 200-token budget: {_token_estimate(text):.0f} tokens"
)


# ---------------------------------------------------------------------------
# Group channel without project_id
# ---------------------------------------------------------------------------

class TestGroupChannelNoProject:
def test_has_mention_section(self):
text = build_manual(_group_channel(), "alice", [])
assert "@-mention routing" in text

def test_has_quick_ref_section(self):
text = build_manual(_group_channel(), "alice", [])
assert "Quick reference" in text

def test_no_task_verbs_section(self):
text = build_manual(_group_channel(), "alice", [])
assert "kanban board" not in text

def test_no_lead_section(self):
text = build_manual(_group_channel(), "alice", [])
assert "Lead vs non-lead" not in text


# ---------------------------------------------------------------------------
# Project a2a channel — lead agent
# ---------------------------------------------------------------------------

class TestProjectA2AChannelLead:
def setup_method(self):
self.channel = _a2a_project_channel(leads=["coord"])
self.text = build_manual(self.channel, "coord", ["coord"])

def test_has_mention_section(self):
assert "@-mention routing" in self.text

def test_has_task_verbs_section(self):
assert "kanban board" in self.text

def test_has_lead_section(self):
assert "Lead vs non-lead" in self.text

def test_lead_branch_text(self):
assert "You ARE designated lead" in self.text

def test_has_quick_ref_section(self):
assert "Quick reference" in self.text

def test_no_non_lead_text(self):
assert "You are NOT a lead" not in self.text


# ---------------------------------------------------------------------------
# Project a2a channel — non-lead agent
# ---------------------------------------------------------------------------

class TestProjectA2AChannelNonLead:
def setup_method(self):
self.channel = _a2a_project_channel(leads=["coord"])
self.text = build_manual(self.channel, "worker", ["coord"])

def test_has_mention_section(self):
assert "@-mention routing" in self.text

def test_has_task_verbs_section(self):
assert "kanban board" in self.text

def test_has_lead_section(self):
assert "Lead vs non-lead" in self.text

def test_non_lead_branch_text(self):
assert "You are NOT a lead" in self.text

def test_has_quick_ref_section(self):
assert "Quick reference" in self.text

def test_no_lead_text(self):
assert "You ARE designated lead" not in self.text


# ---------------------------------------------------------------------------
# Project a2a channel — empty leads list
# ---------------------------------------------------------------------------

class TestProjectA2AEmptyLeads:
def test_empty_leads_gives_non_lead_branch(self):
channel = _a2a_project_channel(leads=[])
text = build_manual(channel, "worker", [])
assert "You are NOT a lead" in text
assert "You ARE designated lead" not in text


# ---------------------------------------------------------------------------
# Token budget — longest branch (lead in project a2a)
# ---------------------------------------------------------------------------

class TestTokenBudget:
def test_longest_branch_under_500_tokens(self):
channel = _a2a_project_channel(leads=["coord"])
text = build_manual(channel, "coord", ["coord"])
estimate = _token_estimate(text)
assert estimate <= 500, (
f"Manual exceeds 500-token budget: {estimate:.0f} estimated tokens"
)
7 changes: 6 additions & 1 deletion tinyagentos/agent_chat_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ async def _route_inner(self, message: dict, channel: dict) -> None:
logger.warning("context fetch failed for channel %s", channel.get("id"), exc_info=True)
context = []

leads = list((settings.get("leads") or [])) # post-PR #291
from tinyagentos.agent_manual import build_manual

for agent_name in recipients:
forced = force_by_slug.get(agent_name, False)
if not forced:
Expand All @@ -165,6 +168,8 @@ async def _route_inner(self, message: dict, channel: dict) -> None:
)
continue

manual_text = build_manual(channel, agent_name, leads)
agent_context = [{"role": "system", "content": manual_text}, *context]
await bridge.enqueue_user_message(
agent_name,
{
Expand All @@ -176,7 +181,7 @@ async def _route_inner(self, message: dict, channel: dict) -> None:
"created_at": message.get("created_at"),
"hops_since_user": next_hops,
"force_respond": forced,
"context": context,
"context": agent_context,
"thread_id": thread_id,
},
)
Expand Down
Loading
Loading