"""Unit tests for §17 ToolchainHandler 强约束实现.""" import json import os import sys import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.parent sys.path.insert(0, str(PROJECT_ROOT)) from src.daemon.prompt_composer import PromptContext, PromptComposer from src.daemon.toolchain_handler import ( ToolchainHandler, ToolchainContextSection, ToolchainApiSection, ToolchainConstraintsSection, _ACTION_HINTS, ) from src.daemon.base_task_handler import VerifyResult from src.blackboard.db import init_db, get_connection # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def tmp_db(): """Create a temporary _toolchain DB for testing.""" with tempfile.TemporaryDirectory() as d: db_path = Path(d) / "blackboard.db" init_db(db_path) yield db_path @pytest.fixture def handler(): return ToolchainHandler() def _insert_task(db_path, task_id, must_haves_json, status="working"): """Insert a task into DB for testing.""" conn = get_connection(db_path) conn.execute( "INSERT INTO tasks (id, title, description, assignee, assigned_by, " "must_haves, task_type, status) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (task_id, "test", "test desc", "zhangfei-dev", "system", must_haves_json, "toolchain", status) ) conn.commit() conn.close() def _insert_comment(db_path, task_id, author, body, comment_type="general"): """Insert a comment into DB.""" conn = get_connection(db_path) conn.execute( "INSERT INTO comments (task_id, author, comment_type, body) VALUES (?, ?, ?, ?)", (task_id, author, comment_type, body) ) conn.commit() conn.close() def _insert_output(db_path, task_id, content="test output"): """Insert an output into DB.""" conn = get_connection(db_path) conn.execute( "INSERT INTO outputs (task_id, agent, output_type, title, summary) " "VALUES (?, ?, ?, ?, ?)", (task_id, "zhangfei-dev", "document", "test", content) ) conn.commit() conn.close() # --------------------------------------------------------------------------- # Step 1a: PromptContext new fields # --------------------------------------------------------------------------- class TestPromptContextFields: def test_action_type_default(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", ) assert ctx.action_type == "" def test_action_steps_default(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", ) assert ctx.action_steps == [] def test_action_type_set(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", action_type="review_result", ) assert ctx.action_type == "review_result" def test_action_steps_set(self): steps = ["step 1", "step 2"] ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", action_steps=steps, ) assert ctx.action_steps == steps # --------------------------------------------------------------------------- # Step 2a: ToolchainContextSection steps rendering + action_hint # --------------------------------------------------------------------------- class TestToolchainContextSection: def test_renders_steps(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", event_type="review_result", event_data={"pr_number": "42", "repo": "sanguo/test"}, action_type="review_result", action_steps=["合并 PR", "提交 action report"], ) section = ToolchainContextSection() result = section.render(ctx) assert "必须执行的步骤" in result assert "1. 合并 PR" in result assert "2. 提交 action report" in result def test_renders_action_hint(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", event_type="ci_failure", action_type="ci_failure", action_steps=[], ) section = ToolchainContextSection() result = section.render(ctx) assert "CI 失败" in result assert "需要你修复" in result def test_renders_default_hint_for_unknown_action_type(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", event_type="unknown", action_type="unknown_type", action_steps=[], ) section = ToolchainContextSection() result = section.render(ctx) assert "需要你执行动作的事件" in result def test_no_steps_no_steps_section(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", event_type="review_merged", action_type="review_merged", action_steps=[], ) section = ToolchainContextSection() result = section.render(ctx) assert "必须执行的步骤" not in result # --------------------------------------------------------------------------- # Step 2b: ToolchainApiSection action_report guidance # --------------------------------------------------------------------------- class TestToolchainApiSection: def test_has_action_report_instruction(self): ctx = PromptContext( task_id="tc-123", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="zhangfei-dev", ) section = ToolchainApiSection() result = section.render(ctx) assert "action_report" in result assert "comment_type" in result assert "tc-123" in result def test_no_manual_done_instruction(self): ctx = PromptContext( task_id="tc-123", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="zhangfei-dev", ) section = ToolchainApiSection() result = section.render(ctx) # Should NOT contain the old "标记为 done" instruction assert "标记为 **done**" not in result assert '"status": "done"' not in result def test_has_outputs_instruction(self): ctx = PromptContext( task_id="tc-123", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="zhangfei-dev", ) section = ToolchainApiSection() result = section.render(ctx) assert "outputs" in result def test_has_gitea_collaboration_instruction(self): ctx = PromptContext( task_id="tc-123", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="zhangfei-dev", ) section = ToolchainApiSection() result = section.render(ctx) assert "Gitea" in result assert "Mail API" in result # --------------------------------------------------------------------------- # Step 2c: ToolchainConstraintsSection Red Flags # --------------------------------------------------------------------------- class TestToolchainConstraintsSection: def test_has_red_flags_table(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", ) section = ToolchainConstraintsSection() result = section.render(ctx) assert "Red Flags" in result assert "❌" in result def test_has_all_5_constraints(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", ) section = ToolchainConstraintsSection() result = section.render(ctx) assert "必须按步骤执行" in result assert "必须提交 action report" in result assert "不要执行任何状态转换命令" in result assert "不需要回复" in result assert "所有协作通过 Gitea 完成" in result def test_has_strong_language(self): ctx = PromptContext( task_id="t1", title="test", description="d", must_haves="", project_id="_toolchain", agent_id="a1", ) section = ToolchainConstraintsSection() result = section.render(ctx) assert "强制要求" in result assert "不是建议" in result # --------------------------------------------------------------------------- # Step 2d: verify_completion tests # --------------------------------------------------------------------------- class TestVerifyCompletion: def test_action_report_passes(self, handler, tmp_db): """action_report comment → pass""" must_haves = json.dumps({"action_type": "review_result"}) _insert_task(tmp_db, "t1", must_haves) _insert_comment(tmp_db, "t1", "zhangfei-dev", "已修复 CI", comment_type="action_report") result = handler.verify_completion("t1", tmp_db) assert result.passed is True assert result.reason == "has_action_report" def test_no_action_report_fallback_output(self, handler, tmp_db): """No action_report but has output → pass (fallback)""" must_haves = json.dumps({"action_type": "review_result"}) _insert_task(tmp_db, "t2", must_haves) _insert_output(tmp_db, "t2", "review result content") result = handler.verify_completion("t2", tmp_db) assert result.passed is True assert result.reason == "has_output" def test_no_action_report_fallback_comment(self, handler, tmp_db): """No action_report but has substantial comment → pass (fallback)""" must_haves = json.dumps({"action_type": "review_result"}) _insert_task(tmp_db, "t3", must_haves) _insert_comment(tmp_db, "t3", "zhangfei-dev", "This is a sufficiently long comment about the task.") result = handler.verify_completion("t3", tmp_db) assert result.passed is True assert result.reason == "has_comment" def test_nothing_passes(self, handler, tmp_db): """No action_report, no output, no comment → fail""" must_haves = json.dumps({"action_type": "review_result"}) _insert_task(tmp_db, "t4", must_haves) result = handler.verify_completion("t4", tmp_db) assert result.passed is False assert result.reason == "no_action" def test_short_comment_fails(self, handler, tmp_db): """Comment < 20 chars → fail""" must_haves = json.dumps({"action_type": "review_result"}) _insert_task(tmp_db, "t5", must_haves) _insert_comment(tmp_db, "t5", "zhangfei-dev", "ok") result = handler.verify_completion("t5", tmp_db) assert result.passed is False def test_review_merged_auto_passes(self, handler, tmp_db): """review_merged → always pass""" must_haves = json.dumps({"action_type": "review_merged"}) _insert_task(tmp_db, "t6", must_haves) result = handler.verify_completion("t6", tmp_db) assert result.passed is True assert result.reason == "merged_passthrough" def test_infrastructure_failure_auto_passes(self, handler, tmp_db): """infrastructure_failure → always pass (anti-recursion)""" must_haves = json.dumps({"action_type": "infrastructure_failure"}) _insert_task(tmp_db, "t7", must_haves) result = handler.verify_completion("t7", tmp_db) assert result.passed is True assert result.reason == "infrastructure_passthrough" # --------------------------------------------------------------------------- # Step 3a: _send_toolchain_task tests # --------------------------------------------------------------------------- class TestSendToolchainTask: def test_creates_task_in_toolchain_db(self): """_send_toolchain_task creates a task in _toolchain DB.""" from src.api.toolchain_routes import _send_toolchain_task, _toolchain_db_path with patch("src.api.toolchain_routes.get_data_root") as mock_root: with tempfile.TemporaryDirectory() as d: mock_root.return_value = Path(d) task_id = _send_toolchain_task( to_agent="zhangfei-dev", title="Test Task", description="Test description", event_type="ci_failure", action_type="ci_failure", steps=["Fix test", "Submit report"], context_data={"pr_number": 42}, ) assert task_id.startswith("tc-") # Verify task was written to _toolchain DB db_path = _toolchain_db_path() conn = get_connection(db_path) row = conn.execute( "SELECT * FROM tasks WHERE id=?", (task_id,) ).fetchone() assert row is not None assert row["task_type"] == "toolchain" assert row["assignee"] == "zhangfei-dev" # Verify must_haves JSON meta = json.loads(row["must_haves"]) assert meta["event_type"] == "ci_failure" assert meta["action_type"] == "ci_failure" assert meta["steps"] == ["Fix test", "Submit report"] assert meta["context"]["pr_number"] == 42 conn.close() def test_unknown_agent_returns_empty(self): """_send_toolchain_task with unknown agent returns empty string.""" from src.api.toolchain_routes import _send_toolchain_task task_id = _send_toolchain_task( to_agent="unknown-agent", title="Test", description="desc", event_type="test", action_type="test", steps=[], ) assert task_id == "" # --------------------------------------------------------------------------- # Step 2e: on_failure three-way routing tests # --------------------------------------------------------------------------- class TestOnFailureRouting: def test_business_failure_creates_gitea_comment(self, handler, tmp_db): """Business failure → Gitea PR comment @task assignee (not must_hives field)""" # S4: must_hives does NOT contain assignee — production data doesn't have it must_haves = json.dumps({ "action_type": "review_result", "context": {"repo": "sanguo/test", "pr_number": 42}, "from": "system", }) # assignee is set on the tasks table row (as production code writes it) _insert_task(tmp_db, "t-fail", must_haves) with patch.object(handler, "_create_gitea_comment") as mock_comment: mock_comment.return_value = True verify = VerifyResult(False, "no_action", "no action_report") handler.on_failure("t-fail", "zhangfei-dev", tmp_db, verify) mock_comment.assert_called_once() call_args = mock_comment.call_args assert call_args[0][0] == "sanguo/test" assert call_args[0][1] == 42 # M2: comment body should @ the task's assignee from tasks table comment_body = call_args[0][2] assert "@zhangfei-dev" in comment_body def test_infrastructure_failure_creates_task(self, handler, tmp_db): """Infrastructure failure → direct DB task for jiangwei-infra (no reverse dep)""" must_haves = json.dumps({ "action_type": "review_result", "context": {"repo": "sanguo/test", "pr_number": 42}, }) _insert_task(tmp_db, "t-infra", must_haves) with patch.object(handler, "_create_gitea_comment") as mock_comment: mock_comment.return_value = False # Gitea API down with patch.object(handler, "_create_gitea_issue") as mock_issue: mock_issue.return_value = False # Gitea API still down verify = VerifyResult(False, "no_action", "no action_report") handler.on_failure("t-infra", "zhangfei-dev", tmp_db, verify) # S3: should directly INSERT into DB, not call _send_toolchain_task # Verify a new task was created in DB for jiangwei-infra conn = get_connection(tmp_db) rows = conn.execute( "SELECT * FROM tasks WHERE assignee=?", ("jiangwei-infra",) ).fetchall() conn.close() assert len(rows) >= 1, "No infrastructure_failure task created" infra_task = rows[0] assert infra_task["task_type"] == "toolchain" meta = json.loads(infra_task["must_haves"]) assert meta["action_type"] == "infrastructure_failure" # --------------------------------------------------------------------------- # Regression: _mail path unaffected # --------------------------------------------------------------------------- class TestMailRegression: def test_send_mail_still_exists(self): """_send_mail function is preserved.""" from src.api.toolchain_routes import _send_mail assert callable(_send_mail) def test_send_mail_not_called_by_handlers(self): """No toolchain handler calls _send_mail.""" import inspect from src.api import toolchain_routes # Get source of handler functions source = inspect.getsource(toolchain_routes) # _send_mail should appear only in its own definition, not in handler bodies lines = source.split("\n") in_handler = False handler_send_mail_calls = [] for i, line in enumerate(lines): if line.strip().startswith("async def _handle_") or line.strip().startswith("async def _send_mention_mails"): in_handler = True elif line.strip().startswith("async def ") or line.strip().startswith("def _"): if not line.strip().startswith("async def _handle_") and not line.strip().startswith("async def _send_mention_mails"): in_handler = False if in_handler and "_send_mail(" in line and not line.strip().startswith("#"): handler_send_mail_calls.append((i, line.strip())) assert len(handler_send_mail_calls) == 0, \ f"_send_mail still called in handlers: {handler_send_mail_calls}" # --------------------------------------------------------------------------- # Integration: full prompt build # --------------------------------------------------------------------------- class TestFullPromptBuild: def test_prompt_contains_all_sections(self, handler): """Full prompt has context, API, and constraints sections.""" ctx = PromptContext( task_id="tc-test", title="CI 失败修复", description="Fix CI failure", must_haves=json.dumps({ "event_type": "ci_failure", "action_type": "ci_failure", "steps": ["Fix test", "Push", "Submit report"], "context": {"pr_number": 42}, }), project_id="_toolchain", agent_id="zhangfei-dev", event_type="ci_failure", event_data={"pr_number": "42", "repo": "sanguo/test"}, action_type="ci_failure", action_steps=["Fix test", "Push", "Submit report"], ) prompt = handler.build_prompt(ctx) # Must have action hint assert "CI 失败" in prompt assert "需要你修复" in prompt # Must have steps assert "必须执行的步骤" in prompt assert "1. Fix test" in prompt # Must have API section with action_report assert "action_report" in prompt assert "tc-test" in prompt # Must have constraints with Red Flags assert "Red Flags" in prompt assert "强制要求" in prompt # --------------------------------------------------------------------------- # §17 v2: CI/deploy failure branching + issue label routing + Issue API guidance # --------------------------------------------------------------------------- class TestCiFailureBranching: """ci_failure steps should include a/b branching guidance.""" def test_ci_failure_steps_contain_branching(self): source_file = PROJECT_ROOT / "src" / "api" / "toolchain_routes.py" source = source_file.read_text() assert '基础设施问题' in source assert 'type/infrastructure' in source assert 'jiangwei-infra' in source class TestDeployFailureBranching: """deploy_failure steps should include a/b branching guidance.""" def test_deploy_failure_steps_contain_branching(self): source_file = PROJECT_ROOT / "src" / "api" / "toolchain_routes.py" source = source_file.read_text() count = source.count('基础设施问题(Gitea 不可用') assert count >= 2, f'Expected >=2 deploy_failure branching, found {count}' class TestIssueAssignedLabelRouting: """issue_assigned handler should route by type/infrastructure label.""" def test_label_check_in_source(self): source_file = PROJECT_ROOT / "src" / "api" / "toolchain_routes.py" source = source_file.read_text() assert 'is_infrastructure' in source assert 'infrastructure_failure' in source assert '基础设施 Issue' in source def test_normal_issue_keeps_coding_steps(self): source_file = PROJECT_ROOT / "src" / "api" / "toolchain_routes.py" source = source_file.read_text() assert 'git checkout -b fix/' in source assert 'issue_assigned' in source class TestToolchainApiIssueGuidance: """ToolchainApiSection should include Issue creation guidance.""" def test_has_issue_creation_section(self): source_file = PROJECT_ROOT / "src" / "daemon" / "toolchain_handler.py" source = source_file.read_text() assert "需要创建 Issue 时" in source assert "/issues" in source assert "jiangwei-infra" in source assert "type/infrastructure" in source def test_issue_body_template_mentions_required_fields(self): source_file = PROJECT_ROOT / "src" / "daemon" / "toolchain_handler.py" source = source_file.read_text() assert "错误来源" in source assert "判断依据" in source class TestRedFlagsInfrastructure: """Red Flags should include the 'not my code' entry.""" def test_has_infrastructure_red_flag(self): source_file = PROJECT_ROOT / "src" / "daemon" / "toolchain_handler.py" source = source_file.read_text() assert "不是我代码的问题" in source assert "基础设施问题" in source class TestGitOperationGuidance: """ToolchainApiSection should include Git operation guidance.""" def test_has_git_operation_section(self): source_file = PROJECT_ROOT / "src" / "daemon" / "toolchain_handler.py" source = source_file.read_text() assert "Git 操作说明" in source assert "git checkout main" in source assert "git pull origin main" in source assert "git checkout -b" in source def test_has_no_main_commit_warning(self): source_file = PROJECT_ROOT / "src" / "daemon" / "toolchain_handler.py" source = source_file.read_text() assert "不要在 main 分支上直接 commit" in source def test_issue_assigned_steps_have_git_commands(self): source_file = PROJECT_ROOT / "src" / "api" / "toolchain_routes.py" source = source_file.read_text() assert 'git checkout main && git pull origin main' in source assert 'git checkout -b fix/' in source assert 'git add -A && git commit' in source assert 'git push origin fix/' in source