test_agent_profile.py 9.27 KB
"""U-004 tests: migrated agent personas compile into valid-shaped subagent
definitions and deploy correctly. No real `claude` calls here -- see
src/test_agent_e2e.py (RUN_REAL_AGENT_E2E=1) for the actual A-2 validation.

Run: python3 -m unittest src.test_agent_profile -v
"""

import json
import os
import tempfile
import unittest

from src import agent_profile, schema


class RenderAgentDefinitionTest(unittest.TestCase):
    def test_all_three_org_nodes_render(self):
        for org_node in agent_profile.ORG_NODES:
            with self.subTest(org_node=org_node):
                content = agent_profile.render_agent_definition(org_node)
                self.assertTrue(content.startswith("---\n"))
                self.assertIn("name: " + org_node, content)
                self.assertIn("memory: project", content)
                self.assertIn("model:", content)
                self.assertIn("skills:", content)
                self.assertIn("tools:", content)

    def test_validator_has_disallowed_tools_from_agents_md_contract(self):
        content = agent_profile.render_agent_definition("validator")
        self.assertIn("disallowedTools:", content)
        self.assertIn("- Edit", content)
        self.assertIn("- Write", content)

    def test_validator_references_unknown_scope_defect_sweep_skill(self):
        # B (2026-08-06): unknown-scope-defect-sweep must actually be wired
        # into the deployed skills: frontmatter, not just exist on disk.
        content = agent_profile.render_agent_definition("validator")
        self.assertIn("- unknown-scope-defect-sweep", content)

    def test_validator_disallowed_tools_has_no_scoped_bash_pattern(self):
        # 2026-07-31: confirmed for real that any "Bash(...)" entry in a
        # subagent's disallowedTools silently disables Bash *entirely*, not
        # just that pattern -- must never reappear in the actual frontmatter
        # (see agent_profile.py's FRONTMATTER["validator"] comment for the
        # full story; the migrated AGENTS.md body is allowed to *mention*
        # "Bash(...)" in prose explaining why it's no longer used there).
        content = agent_profile.render_agent_definition("validator")
        frontmatter = content.split("---", 2)[1]
        self.assertNotIn("Bash(", frontmatter)
        self.assertIn("- Bash", frontmatter)

    def test_planner_and_coder_have_no_disallowed_tools(self):
        for org_node in ("planner", "coder"):
            content = agent_profile.render_agent_definition(org_node)
            self.assertNotIn("disallowedTools:", content)

    def test_validator_body_includes_both_soul_and_agents_md(self):
        content = agent_profile.render_agent_definition("validator")
        self.assertIn("QA Agent", content)  # from SOUL.md
        self.assertIn("AGENTS.md — QA Profile 运行契约", content)  # from AGENTS.md

    def test_no_leaked_feishu_secret_or_personal_path(self):
        for org_node in agent_profile.ORG_NODES:
            content = agent_profile.render_agent_definition(org_node)
            # real value intentionally not reproduced here -- see
            # .claude/agent-memory/coder/feedback_hermes_skill_migration_secret_redaction.md
            self.assertNotIn("<FEISHU_BASE_TOKEN_REDACTED_PLACEHOLDER>", content)
            self.assertNotIn("/Users/yibo/Projects/lemonAI/graphagent", content)
            # the original told Coder to itself run this; the migrated version is
            # allowed to *mention* the flag only as a prohibition, not an instruction
            self.assertNotIn("优先使用 `claude --dangerously-skip-permissions`", content)
            self.assertNotIn("优先使用 claude --dangerously-skip-permissions", content)


class NewRoleWithoutCodeChangeTest(unittest.TestCase):
    """H (2026-08-06), requirements doc acceptance standard 1: a brand-new
    role, defined purely by adding a roles/<role_id>/role.json, is
    recognized by these UNMODIFIED functions (render_agent_definition/
    deploy_agent_definitions/record_agent_profiles) via the roles_dir
    override -- no agent_profile.py/schema.py edit involved. Points
    identity.soul_md_path at a real, already-existing repo file
    (agents/planner/SOUL.md) rather than fabricating a new one -- this
    standard is about the roles/ discovery + rendering mechanism, not
    about proving a new identity file can be written (unrelated concern)."""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        role_dir = os.path.join(self.tmp.name, "researcher")
        os.makedirs(role_dir)
        role = {
            "role_id": "researcher",
            "description": "A brand-new role added with zero Python/schema changes.",
            "migrated_from_profile": "planning",
            "identity": {"soul_md_path": "agents/planner/SOUL.md", "agents_md_path": None},
            "context": {"memory": "project"},
            "model": "claude-sonnet-5",
            "skills": ["planning-requirement-expansion"],
            "applications": {"mcp_servers": []},
            "workflows": {"dynamic_planning": "generic"},
            "permissions": {"tools": ["Read", "Grep"], "disallowed_tools": [], "notes": []},
            "acceptance_criteria": {"ref": "docs/sdd/06-验收与测试锚点.md"},
        }
        with open(os.path.join(role_dir, "role.json"), "w", encoding="utf-8") as f:
            json.dump(role, f)

    def test_discovered_alongside_the_real_three_roles(self):
        discovered = agent_profile._discover_role_ids(self.tmp.name)
        self.assertIn("researcher", discovered)

    def test_render_agent_definition_recognizes_it(self):
        content = agent_profile.render_agent_definition("researcher", roles_dir=self.tmp.name)
        self.assertIn("name: researcher", content)
        self.assertIn("- planning-requirement-expansion", content)

    def test_deploy_agent_definitions_writes_it_out(self):
        with tempfile.TemporaryDirectory() as target:
            agent_profile.deploy_agent_definitions(target, roles_dir=self.tmp.name)
            path = os.path.join(target, ".claude", "agents", "researcher.md")
            self.assertTrue(os.path.isfile(path))

    def test_record_agent_profiles_writes_a_row(self):
        conn = schema.connect(":memory:")
        try:
            schema.create_schema(conn)
            agent_profile.record_agent_profiles(conn, "deadbeef", roles_dir=self.tmp.name)
            row = conn.execute(
                "SELECT migrated_from_profile FROM agent_profile WHERE org_node = 'researcher'"
            ).fetchone()
            self.assertEqual(row, ("planning",))
        finally:
            conn.close()

    def test_unknown_role_still_rejected(self):
        with self.assertRaises(ValueError):
            agent_profile.render_agent_definition("no-such-role", roles_dir=self.tmp.name)


class DeployAgentDefinitionsTest(unittest.TestCase):
    def test_writes_three_files_into_target_claude_agents(self):
        with tempfile.TemporaryDirectory() as tmp:
            agent_profile.deploy_agent_definitions(tmp)
            out_dir = os.path.join(tmp, ".claude", "agents")
            for org_node in agent_profile.ORG_NODES:
                path = os.path.join(out_dir, org_node + ".md")
                self.assertTrue(os.path.isfile(path))
                with open(path, encoding="utf-8") as f:
                    self.assertTrue(f.read().startswith("---\n"))


class RecordAgentProfilesTest(unittest.TestCase):
    def setUp(self):
        self.conn = schema.connect(":memory:")
        schema.create_schema(self.conn)

    def tearDown(self):
        self.conn.close()

    def test_inserts_one_row_per_org_node(self):
        agent_profile.record_agent_profiles(self.conn, "deadbeef")
        rows = self.conn.execute(
            "SELECT org_node, migrated_from_profile, current_git_commit_sha FROM agent_profile ORDER BY org_node"
        ).fetchall()
        self.assertEqual(len(rows), 4)
        by_node = {r[0]: r for r in rows}
        self.assertEqual(by_node["planner"][1], "planning")
        self.assertEqual(by_node["coder"][1], "code")
        self.assertEqual(by_node["validator"][1], "qa")
        # planner_reviewer has no legacy Hermes profile to migrate from
        # (role.json's migrated_from_profile: null) -- see
        # schema.migrate_relax_agent_profile_migrated_from_profile.
        self.assertIsNone(by_node["planner_reviewer"][1])
        for row in rows:
            self.assertEqual(row[2], "deadbeef")

    def test_validator_has_agents_file_path_others_do_not(self):
        agent_profile.record_agent_profiles(self.conn, "deadbeef")
        rows = dict(
            self.conn.execute("SELECT org_node, agents_file_path FROM agent_profile").fetchall()
        )
        self.assertIsNone(rows["planner"])
        self.assertIsNone(rows["coder"])
        self.assertIsNotNone(rows["validator"])

    def test_rerun_upserts_instead_of_duplicating(self):
        agent_profile.record_agent_profiles(self.conn, "sha-1")
        agent_profile.record_agent_profiles(self.conn, "sha-2")
        count = self.conn.execute("SELECT COUNT(*) FROM agent_profile").fetchone()[0]
        self.assertEqual(count, 4)
        sha = self.conn.execute(
            "SELECT current_git_commit_sha FROM agent_profile WHERE org_node = 'planner'"
        ).fetchone()[0]
        self.assertEqual(sha, "sha-2")


if __name__ == "__main__":
    unittest.main()