auto-sync: 2026-06-05 11:03:30

This commit is contained in:
cfdaily
2026-06-05 11:03:30 +08:00
parent e9c9aaddfe
commit 6a649aba07
30 changed files with 602 additions and 1276 deletions
View File
+479
View File
@@ -0,0 +1,479 @@
"""F2 测试:黑板核心(DB + CRUD + 状态机 + 并发)"""
import json
import threading
from pathlib import Path
from typing import List
import pytest
from src.blackboard.db import (
VALID_TRANSITIONS,
TERMINAL_STATUSES,
COMMENT_TYPES,
OUTPUT_TYPES,
REVIEW_TYPES,
VERDICT_TYPES,
init_db,
get_connection,
)
from src.blackboard.models import (
Task, Comment, Output, Decision, Observation,
Review, Experience,
)
from src.blackboard.operations import Blackboard
from src.blackboard.queries import Queries
pytestmark = pytest.mark.unit
@pytest.fixture
def tmp_db(tmp_path):
"""创建临时黑板"""
db_path = tmp_path / "test.db"
bb = Blackboard(db_path)
return bb, Queries(db_path), db_path
# ===================================================================
# Schema 初始化
# ===================================================================
class TestSchema:
def test_init_creates_all_tables(self, tmp_path):
db_path = tmp_path / "new.db"
init_db(db_path)
conn = get_connection(db_path)
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
conn.close()
expected = {
"tasks", "comments", "outputs", "decisions",
"observations", "events", "agents", "task_attempts",
"reviews", "experiences", "experience_tags",
}
assert expected.issubset(tables)
def test_wal_mode(self, tmp_path):
db_path = tmp_path / "wal.db"
init_db(db_path)
conn = get_connection(db_path)
row = conn.execute("PRAGMA journal_mode").fetchone()
conn.close()
assert row[0] == "wal"
def test_busy_timeout(self, tmp_path):
db_path = tmp_path / "busy.db"
init_db(db_path)
conn = get_connection(db_path)
row = conn.execute("PRAGMA busy_timeout").fetchone()
conn.close()
assert row[0] == 5000
def test_foreign_keys_on(self, tmp_path):
db_path = tmp_path / "fk.db"
init_db(db_path)
conn = get_connection(db_path)
row = conn.execute("PRAGMA foreign_keys").fetchone()
conn.close()
assert row[0] == 1
# ===================================================================
# Task CRUD
# ===================================================================
class TestTaskCRUD:
def test_create_and_get(self, tmp_db):
bb, _, _ = tmp_db
task = Task(id="t1", title="Test Task", task_type="coding")
bb.create_task(task)
got = bb.get_task("t1")
assert got is not None
assert got.title == "Test Task"
assert got.status == "pending"
def test_create_with_all_fields(self, tmp_db):
bb, _, _ = tmp_db
task = Task(
id="t2", title="Full Task", description="desc",
assignee="zhangfei-dev", assigned_by="pangtong-fujunshi",
depends_on='["t1"]', parent_task="t0",
priority=3, task_type="review", deadline="2026-06-01",
risk_level="high", estimated_duration_minutes=60,
must_haves='{"truths": ["a"], "artifacts": ["b"], "constraints": ["c"]}',
)
bb.create_task(task)
got = bb.get_task("t2")
assert got.priority == 3
assert got.risk_level == "high"
assert got.assignee == "zhangfei-dev"
def test_get_nonexistent(self, tmp_db):
bb, _, _ = tmp_db
assert bb.get_task("nope") is None
def test_list_tasks(self, tmp_db):
bb, _, _ = tmp_db
for i in range(5):
bb.create_task(Task(id=f"t{i}", title=f"Task {i}"))
tasks = bb.list_tasks()
assert len(tasks) == 5
def test_list_tasks_by_status(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="A"))
bb.create_task(Task(id="t2", title="B"))
bb.update_task_status("t1", "claimed", agent="agent1")
pending = bb.list_tasks(status="pending")
assert len(pending) == 1
assert pending[0].id == "t2"
def test_list_tasks_by_assignee(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="A", assignee="agent1"))
bb.create_task(Task(id="t2", title="B", assignee="agent2"))
tasks = bb.list_tasks(assignee="agent1")
assert len(tasks) == 1
# ===================================================================
# 状态机
# ===================================================================
class TestStateMachine:
def test_valid_transitions(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="SM Test"))
# pending → claimed → working → review → done
assert bb.update_task_status("t1", "claimed", agent="a1")
assert bb.update_task_status("t1", "working", agent="a1")
assert bb.update_task_status("t1", "review", agent="a1")
assert bb.update_task_status("t1", "done", agent="system")
def test_invalid_transition(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Bad"))
# pending → done is invalid
assert not bb.update_task_status("t1", "done")
def test_terminal_state_blocked(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Done"))
bb.update_task_status("t1", "claimed", agent="a1")
bb.update_task_status("t1", "working", agent="a1")
bb.update_task_status("t1", "review", agent="a1")
bb.update_task_status("t1", "done", agent="system")
# done is terminal
assert not bb.update_task_status("t1", "pending")
def test_failed_to_pending_retry(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Retry"))
bb.update_task_status("t1", "claimed", agent="a1")
bb.update_task_status("t1", "working", agent="a1")
bb.update_task_status("t1", "failed", agent="a1")
assert bb.update_task_status("t1", "pending")
task = bb.get_task("t1")
assert task.retry_count == 1
def test_blocked_to_pending(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Blocked"))
bb.update_task_status("t1", "claimed", agent="a1")
bb.update_task_status("t1", "working", agent="a1")
assert bb.update_task_status("t1", "blocked", agent="a1")
assert bb.update_task_status("t1", "pending")
def test_cancel_from_pending(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Cancel"))
assert bb.update_task_status("t1", "cancelled")
# cancelled → pending is allowed (cancel can be undone)
assert bb.update_task_status("t1", "pending")
# ===================================================================
# Claim(原子 CAS
# ===================================================================
class TestClaim:
def test_claim_pending(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Claim"))
assert bb.claim_task("t1", "agent1")
task = bb.get_task("t1")
assert task.status == "claimed"
assert task.assignee == "agent1"
def test_claim_assigned_task(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Assigned", assignee="agent1"))
# Same agent can claim
assert bb.claim_task("t1", "agent1")
def test_cannot_claim_others_task(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Other", assignee="agent1"))
assert not bb.claim_task("t1", "agent2")
def test_cannot_claim_working(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Working"))
bb.update_task_status("t1", "claimed", agent="a1")
bb.update_task_status("t1", "working", agent="a1")
assert not bb.claim_task("t1", "agent2")
def test_concurrent_claim(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Race"))
results = []
def claim(agent):
bb2 = Blackboard(bb.db_path)
results.append(bb2.claim_task("t1", agent))
t1 = threading.Thread(target=claim, args=("agent1",))
t2 = threading.Thread(target=claim, args=("agent2",))
t1.start()
t2.start()
t1.join()
t2.join()
# Only one should succeed
assert sum(1 for r in results if r) == 1
# ===================================================================
# Comment
# ===================================================================
class TestComment:
def test_add_and_get(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Comment"))
cid = bb.add_comment("t1", "agent1", "Hello", mentions=["agent2"])
comments = bb.get_comments("t1")
assert len(comments) == 1
assert comments[0].body == "Hello"
assert comments[0].comment_type == "general"
assert json.loads(comments[0].mentions) == ["agent2"]
def test_comment_types(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Types"))
for ct in COMMENT_TYPES:
bb.add_comment("t1", "agent1", f"Type: {ct}", comment_type=ct)
comments = bb.get_comments("t1")
assert len(comments) == len(COMMENT_TYPES)
def test_invalid_comment_type(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Bad"))
with pytest.raises(ValueError):
bb.add_comment("t1", "a", "x", comment_type="invalid")
def test_filter_by_type(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Filter"))
bb.add_comment("t1", "a", "general", comment_type="general")
bb.add_comment("t1", "a", "handoff", comment_type="handoff")
handoffs = bb.get_comments("t1", comment_type="handoff")
assert len(handoffs) == 1
# ===================================================================
# Output
# ===================================================================
class TestOutput:
def test_write_and_get(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Output"))
oid = bb.write_output("t1", "agent1", "code", "main.py",
summary="Main script")
outputs = bb.get_outputs("t1")
assert len(outputs) == 1
assert outputs[0].title == "main.py"
assert outputs[0].output_type == "code"
def test_all_output_types(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Types"))
for ot in OUTPUT_TYPES:
bb.write_output("t1", "a", ot, f"file.{ot}")
assert len(bb.get_outputs("t1")) == len(OUTPUT_TYPES)
def test_invalid_output_type(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Bad"))
with pytest.raises(ValueError):
bb.write_output("t1", "a", "invalid", "x")
# ===================================================================
# Decision + Observation
# ===================================================================
class TestDecision:
def test_add_and_get(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Dec"))
bb.add_decision("t1", "pangtong", "Use FastAPI",
"Better async support", ["Flask", "Litestar"])
decs = bb.get_decisions("t1")
assert len(decs) == 1
assert decs[0].rationale == "Better async support"
assert json.loads(decs[0].alternatives) == ["Flask", "Litestar"]
class TestObservation:
def test_add_and_get(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Obs"))
bb.add_observation("t1", "agent1", "Potential issue", severity="warning")
obs = bb.get_observations("t1")
assert len(obs) == 1
assert obs[0].severity == "warning"
def test_unresolved_filter(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Obs"))
bb.add_observation("t1", "a", "unresolved", severity="blocking")
unresolved = bb.get_observations("t1", unresolved_only=True)
assert len(unresolved) == 1
assert unresolved[0].severity == "blocking"
# ===================================================================
# Review
# ===================================================================
class TestReview:
def test_add_and_get(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Rev"))
review = Review(
id="rev-1", task_id="t1", reviewer="simayi",
review_type="output_review", verdict="approved",
confidence=0.9, summary="LGTM",
)
bb.add_review(review)
reviews = bb.get_reviews("t1")
assert len(reviews) == 1
assert reviews[0].verdict == "approved"
assert reviews[0].confidence == 0.9
# ===================================================================
# Experience
# ===================================================================
class TestExperience:
def test_add_and_query(self, tmp_db):
bb, _, _ = tmp_db
exp = Experience(
experience_id="exp-1", source="task_completion",
summary="SQLite WAL works well", category="best_practice",
created_by="pangtong", tags=["sqlite", "performance"],
)
bb.add_experience(exp)
results = bb.query_experiences(tags=["sqlite"])
assert len(results) == 1
assert set(results[0].tags) == {"sqlite", "performance"}
def test_touch_increments(self, tmp_db):
bb, _, _ = tmp_db
exp = Experience(
experience_id="exp-1", source="manual",
summary="test", category="pattern", created_by="pangtong",
)
bb.add_experience(exp)
bb.touch_experience("exp-1")
bb.touch_experience("exp-1")
results = bb.query_experiences()
assert results[0].usage_count == 2
# ===================================================================
# Event
# ===================================================================
class TestEvent:
def test_events_written_on_transitions(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Events"))
bb.update_task_status("t1", "claimed", agent="a1")
bb.update_task_status("t1", "working", agent="a1")
events = bb.get_events(task_id="t1")
# create + claimed + working = 3 events
assert len(events) >= 3
# ===================================================================
# Queries
# ===================================================================
class TestQueries:
def test_task_summary(self, tmp_db):
bb, q, _ = tmp_db
bb.create_task(Task(id="t1", title="A"))
bb.create_task(Task(id="t2", title="B"))
bb.update_task_status("t1", "claimed", agent="a1")
summary = q.task_summary()
assert summary.get("pending") == 1
assert summary.get("claimed") == 1
def test_pending_dispatchable(self, tmp_db):
bb, q, _ = tmp_db
bb.create_task(Task(id="t1", title="A"))
bb.create_task(Task(id="t2", title="B", depends_on='["t1"]'))
dispatchable = q.pending_dispatchable()
# t1 has no deps → dispatchable; t2 depends on t1 → not
assert len(dispatchable) == 1
assert dispatchable[0].id == "t1"
def test_blocked_tasks_with_deps(self, tmp_db):
bb, q, _ = tmp_db
bb.create_task(Task(id="t1", title="A"))
bb.create_task(Task(id="t2", title="B", depends_on='["t1"]'))
bb.update_task_status("t2", "claimed", agent="a1")
bb.update_task_status("t2", "working", agent="a1")
bb.update_task_status("t2", "blocked", agent="a1")
blocked = q.blocked_tasks_with_deps()
assert len(blocked) == 1
assert blocked[0]["all_deps_done"] is False
# ===================================================================
# 并发写入
# ===================================================================
class TestConcurrency:
def test_concurrent_writes(self, tmp_db):
bb, _, _ = tmp_db
bb.create_task(Task(id="t1", title="Concurrent"))
errors = []
def write_comments(agent, count):
try:
bb2 = Blackboard(bb.db_path)
for i in range(count):
bb2.add_comment("t1", agent, f"{agent}-{i}")
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=write_comments, args=(f"a{i}", 10))
for i in range(5)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
comments = bb.get_comments("t1")
assert len(comments) == 50
+191
View File
@@ -0,0 +1,191 @@
"""#11 Bootstrap 四段式拼装单元测试(v2.1)
覆盖:
- T1: build(task, role) 4 段结构
- T2: token 估算 + 预算告警
- T3: 缺失组件降级
- T4: build_for_task 便捷方法
- T5: _read_skill fallback
- T6: ROLE_SKILL_MAP 覆盖
"""
import pytest
pytestmark = pytest.mark.unit
from unittest.mock import patch
from pathlib import Path
from src.daemon.bootstrap import BootstrapBuilder, estimate_tokens
@pytest.fixture
def builder():
return BootstrapBuilder(max_tokens=4096)
# ---------------------------------------------------------------------------
# T1: build(task, role) 4 段结构
# ---------------------------------------------------------------------------
class TestFourSectionBuild:
def test_basic_executor_build(self, builder):
b = builder.build(
task={"task_id": "t1", "title": "Write tests", "description": "Write unit tests",
"must_haves": "100% coverage", "status": "claimed"},
role="executor",
)
# 段 1: 任务上下文
assert "Write tests" in b
assert "t1" in b
assert "100% coverage" in b
# 段 4: 硬约束
assert "review" in b
assert "handoff" in b
def test_basic_reviewer_build(self, builder):
b = builder.build(
task={"task_id": "t2", "title": "Review PR"},
role="reviewer",
)
assert "Review PR" in b
# 段 4: reviewer 硬约束
assert "pass/fail" in b or "pass" in b
def test_planner_build(self, builder):
b = builder.build(
task={"task_id": "t3", "title": "Plan sprint"},
role="planner",
)
assert "Plan sprint" in b
def test_depends_on_outputs_injected(self, builder):
b = builder.build(
task={
"title": "T",
"depends_on_outputs": [
{"task_id": "t0", "summary": "Data downloaded"},
],
},
role="executor",
)
assert "前序产出" in b
assert "Data downloaded" in b
def test_no_depends_on_omitted(self, builder):
b = builder.build(
task={"title": "T"},
role="executor",
)
assert "前序产出" not in b
# ---------------------------------------------------------------------------
# T2: token 估算 + 预算告警
# ---------------------------------------------------------------------------
class TestTokenEstimation:
def test_estimate_tokens_basic(self):
assert estimate_tokens("hello") > 0
def test_estimate_tokens_long_text(self):
text = "a" * 1000
tokens = estimate_tokens(text)
assert 200 < tokens < 400
# ---------------------------------------------------------------------------
# T3: 缺失组件降级
# ---------------------------------------------------------------------------
class TestGracefulDegradation:
def test_empty_task(self, builder):
b = builder.build(task={}, role="executor")
assert b # 不为空
assert "硬约束" in b
def test_partial_task(self, builder):
b = builder.build(task={"title": "Only title"}, role="executor")
assert "Only title" in b
# ---------------------------------------------------------------------------
# T4: build_for_task 便捷方法
# ---------------------------------------------------------------------------
class TestBuildForTask:
def test_build_for_task_object(self, builder):
"""用 mock task 对象测试 build_for_task"""
class MockTask:
id = "t1"
title = "Build Feature"
description = "Implement X with tests"
must_haves = "Unit tests, Documentation"
status = "claimed"
task = MockTask()
b = builder.build_for_task(task, role="executor")
assert "Build Feature" in b
assert "Implement X" in b
def test_build_for_task_ignores_kwargs(self, builder):
"""build_for_task 忽略旧参数"""
class MockTask:
id = "t1"
title = "T"
description = ""
must_haves = ""
status = ""
task = MockTask()
# 旧参数 project_config/experiences 不应报错
b = builder.build_for_task(
task, role="executor",
project_config={"name": "Old"},
experiences=[{"x": 1}],
)
assert "T" in b
# 不应出现旧参数内容
assert "Old" not in b
# ---------------------------------------------------------------------------
# T5: _read_skill fallback
# ---------------------------------------------------------------------------
class TestReadSkillFallback:
def test_missing_skill_file_returns_empty(self, builder):
"""Skill 文件不存在时返回空字符串,不抛异常"""
result = builder._read_skill("nonexistent-skill-xyz")
assert result == ""
def test_existing_skill_file_read(self, builder):
"""能读取实际存在的 Skill 文件"""
# blackboard-executor 应该存在(P1 已创建)
result = builder._read_skill("blackboard-executor")
assert "执行" in result or "executor" in result.lower()
# ---------------------------------------------------------------------------
# T6: ROLE_SKILL_MAP 覆盖
# ---------------------------------------------------------------------------
class TestRoleSkillMap:
def test_all_roles_mapped(self):
assert set(BootstrapBuilder.ROLE_SKILL_MAP.keys()) == {
"executor", "reviewer", "reviewer-simayi",
"reviewer-pangtong", "planner", "claim",
}
def test_unknown_role_warns(self, builder):
"""未映射的 role 输出 warning"""
import logging
with patch("src.daemon.bootstrap.logger") as mock_logger:
builder.build(task={"title": "T"}, role="unknown_role")
mock_logger.warning.assert_called_with(
"No skill mapping for role: %s", "unknown_role"
)
def test_discussion_role_no_warning(self, builder, caplog):
"""discussion 角色不应触发 warning"""
import logging
with caplog.at_level(logging.WARNING, logger="moziplus-v2.bootstrap"):
builder.build(task={"title": "T"}, role="discussion")
assert "No skill mapping" not in caplog.text
+142
View File
@@ -0,0 +1,142 @@
"""F4 测试:CLI 工具"""
import json
import os
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from src.cli.blackboard import run_blackboard_cli, run_admin_cli
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
@pytest.fixture
def project_env(tmp_path):
"""创建临时项目环境"""
project_root = tmp_path / "projects"
project_root.mkdir()
os.environ["BLACKBOARD_ROOT"] = str(project_root)
# Create a test project with DB
proj_dir = project_root / "test-proj"
proj_dir.mkdir()
bb = Blackboard(proj_dir / "blackboard.db")
bb.create_task(Task(id="t1", title="Existing Task", task_type="coding"))
yield project_root
del os.environ["BLACKBOARD_ROOT"]
class TestBlackboardCLI:
def test_read_all_tasks(self, project_env, capsys):
rc = run_blackboard_cli(["read", "--project", "test-proj"])
assert rc == 0
out = capsys.readouterr().out
assert "t1" in out
def test_read_json(self, project_env, capsys):
rc = run_blackboard_cli(["read", "--project", "test-proj", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert len(data) == 1
assert data[0]["id"] == "t1"
def test_create_task(self, project_env, capsys):
rc = run_blackboard_cli([
"create", "--project", "test-proj",
"--id", "t2", "--title", "New Task",
"--task-type", "review", "--priority", "3",
])
assert rc == 0
assert "Created task: t2" in capsys.readouterr().out
def test_claim(self, project_env, capsys):
rc = run_blackboard_cli([
"claim", "--project", "test-proj",
"--task-id", "t1", "--agent", "zhangfei-dev",
])
assert rc == 0
def test_output(self, project_env, capsys):
# First claim and start
run_blackboard_cli(["claim", "--project", "test-proj",
"--task-id", "t1", "--agent", "a1"])
rc = run_blackboard_cli([
"output", "--project", "test-proj",
"--task-id", "t1", "--agent", "a1",
"--type", "code", "--title", "main.py",
])
assert rc == 0
def test_comment(self, project_env, capsys):
rc = run_blackboard_cli([
"comment", "--project", "test-proj",
"--task-id", "t1", "--author", "pangtong",
"--body", "Good work", "--type", "general",
"--mentions", "zhangfei-dev,guanyu-dev",
])
assert rc == 0
def test_decide(self, project_env, capsys):
rc = run_blackboard_cli([
"decide", "--project", "test-proj",
"--task-id", "t1", "--decider", "pangtong",
"--decision", "Use FastAPI", "--rationale", "Best async support",
])
assert rc == 0
def test_observe(self, project_env, capsys):
rc = run_blackboard_cli([
"observe", "--project", "test-proj",
"--task-id", "t1", "--observer", "simayi",
"--body", "Potential issue", "--severity", "warning",
])
assert rc == 0
def test_review(self, project_env, capsys):
rc = run_blackboard_cli([
"review", "--project", "test-proj",
"--review-id", "rev-1", "--task-id", "t1",
"--reviewer", "simayi", "--review-type", "output_review",
"--verdict", "approved", "--summary", "LGTM",
"--confidence", "0.9",
])
assert rc == 0
def test_read_nonexistent_task(self, project_env, capsys):
rc = run_blackboard_cli(["read", "--project", "test-proj", "--task-id", "nope"])
assert rc == 1
def test_no_command(self, project_env, capsys):
rc = run_blackboard_cli([])
assert rc == 1
class TestAdminCLI:
def test_project_create(self, tmp_path, capsys):
project_root = tmp_path / "projects"
project_root.mkdir()
os.environ["BLACKBOARD_ROOT"] = str(project_root)
try:
rc = run_admin_cli([
"project-create", "--id", "new-proj",
"--name", "New Project",
"--agents", "agent1,agent2",
])
assert rc == 0
assert (project_root / "new-proj" / "config" / "project.yaml").exists()
finally:
del os.environ["BLACKBOARD_ROOT"]
def test_project_list(self, project_env, capsys):
rc = run_admin_cli(["project-list"])
assert rc == 0
# test-proj is not in registry (created manually), so empty output is fine
def test_no_command(self, project_env, capsys):
rc = run_admin_cli([])
assert rc == 1
+160
View File
@@ -0,0 +1,160 @@
"""F10 ActiveAgentCounter 单元测试
按 test-plan-v2.6.md §F10
- T1: 全局上限(P0
- T2: per-agent 串行(P0
- T3: release 恢复(P0
- T4: 并发竞争(P1
"""
import asyncio
import pytest
pytestmark = pytest.mark.unit
from src.daemon.counter import ActiveAgentCounter
@pytest.fixture
def counter():
return ActiveAgentCounter(max_global=3, max_per_agent=1)
# ---------------------------------------------------------------------------
# T1: 全局上限
# ---------------------------------------------------------------------------
class TestGlobalLimit:
def test_acquire_within_limit(self, counter):
"""全局未满 → 成功"""
result = asyncio.run(counter.acquire("agent-1"))
assert result is True
assert counter.global_active == 1
def test_acquire_fills_global(self, counter):
"""填满全局"""
for i in range(3):
asyncio.run(counter.acquire(f"agent-{i}"))
assert counter.global_active == 3
def test_acquire_exceeds_global(self, counter):
"""超出全局上限 → 失败"""
for i in range(3):
asyncio.run(counter.acquire(f"agent-{i}"))
result = asyncio.run(counter.acquire("agent-extra"))
assert result is False
def test_can_acquire_respects_limit(self, counter):
"""can_acquire 检查全局上限"""
assert asyncio.run(counter.can_acquire("agent-1")) is True
for i in range(3):
asyncio.run(counter.acquire(f"agent-{i}"))
assert asyncio.run(counter.can_acquire("agent-extra")) is False
# ---------------------------------------------------------------------------
# T2: per-agent 串行
# ---------------------------------------------------------------------------
class TestPerAgentSerial:
def test_same_agent_blocked(self, counter):
"""同一 Agent 第二个 acquire 失败"""
asyncio.run(counter.acquire("agent-1"))
result = asyncio.run(counter.acquire("agent-1"))
assert result is False
def test_different_agents_ok(self, counter):
"""不同 Agent 可以同时 acquire"""
assert asyncio.run(counter.acquire("agent-1")) is True
assert asyncio.run(counter.acquire("agent-2")) is True
def test_per_agent_limit_2(self):
"""max_per_agent=2 允许两个"""
c = ActiveAgentCounter(max_global=5, max_per_agent=2)
assert asyncio.run(c.acquire("a1")) is True
assert asyncio.run(c.acquire("a1")) is True
assert asyncio.run(c.acquire("a1")) is False # 第三个失败
# ---------------------------------------------------------------------------
# T3: release 恢复
# ---------------------------------------------------------------------------
class TestRelease:
def test_release_allows_new_acquire(self, counter):
"""release 后可以重新 acquire"""
asyncio.run(counter.acquire("agent-1"))
counter.release("agent-1")
result = asyncio.run(counter.acquire("agent-1"))
assert result is True
def test_release_updates_count(self, counter):
"""release 后计数正确"""
asyncio.run(counter.acquire("agent-1"))
assert counter.global_active == 1
counter.release("agent-1")
assert counter.global_active == 0
def test_release_same_agent_twice(self, counter):
"""release 后同一 Agent 可以再次 acquire"""
asyncio.run(counter.acquire("agent-1"))
counter.release("agent-1")
assert asyncio.run(counter.can_acquire("agent-1")) is True
def test_release_updates_active_agents(self, counter):
"""active_agents 正确追踪"""
asyncio.run(counter.acquire("agent-1"))
assert counter.active_agents == {"agent-1": 1}
counter.release("agent-1")
assert "agent-1" not in counter.active_agents
def test_full_cycle(self, counter):
"""完整获取-释放循环"""
# Fill
for i in range(3):
asyncio.run(counter.acquire(f"a{i}"))
assert counter.global_active == 3
# Full → can't acquire
assert asyncio.run(counter.can_acquire("extra")) is False
# Release one
counter.release("a1")
assert counter.global_active == 2
# Now can acquire
assert asyncio.run(counter.acquire("extra")) is True
assert counter.global_active == 3
# ---------------------------------------------------------------------------
# T4: 并发竞争(P1
# ---------------------------------------------------------------------------
class TestConcurrency:
def test_concurrent_acquire_no_exceed(self):
"""并发 acquire 不超过全局限制"""
counter = ActiveAgentCounter(max_global=3, max_per_agent=1)
results = []
async def try_acquire(agent_id):
r = await counter.acquire(agent_id)
results.append((agent_id, r))
if r:
await asyncio.sleep(0.01)
counter.release(agent_id)
async def run():
tasks = [try_acquire(f"agent-{i}") for i in range(10)]
await asyncio.gather(*tasks)
asyncio.run(run())
succeeded = sum(1 for _, r in results if r)
# 不超过 max_global 的倍数(因为 release 后可重用)
assert succeeded > 0
def test_no_negative_count(self, counter):
"""release 不会导致负数计数"""
counter.release("nonexistent") # 不应崩溃
assert counter.global_active >= 0
+272
View File
@@ -0,0 +1,272 @@
"""F9 Agent 调度器单元测试 — 三级决策树 + 批量决策"""
import asyncio
import json
import pytest
from pathlib import Path
from typing import Any, Dict, Optional
from unittest.mock import AsyncMock, MagicMock
from src.blackboard.models import Task
from src.daemon.dispatcher import Dispatcher, DispatchLevel
from src.daemon.spawner import AgentBusyError
pytestmark = pytest.mark.unit
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def dispatcher():
return Dispatcher(
registered_agents=["zhangfei-dev", "guanyu-dev", "simayi-challenger"],
)
@pytest.fixture
def task_pending():
return Task(
id="t1", title="Write code", status="pending",
assigned_by="daemon", task_type="coding",
assignee="zhangfei-dev",
)
@pytest.fixture
def task_no_assignee():
return Task(
id="t2", title="Generic task", status="pending",
assigned_by="daemon",
)
@pytest.fixture
def task_unknown_agent():
return Task(
id="t3", title="Unknown agent task", status="pending",
assigned_by="daemon", assignee="nonexistent-agent",
)
# ---------------------------------------------------------------------------
# T1: 三级决策树
# ---------------------------------------------------------------------------
class TestDecisionTree:
def test_local_action(self, dispatcher, task_pending):
"""L1 本地执行"""
decision = dispatcher.decide(task_pending, "L1_guardrail")
assert decision["level"] == DispatchLevel.LOCAL
assert decision["agent_id"] == "daemon"
def test_local_format_check(self, dispatcher, task_pending):
"""format_check → 本地"""
decision = dispatcher.decide(task_pending, "format_check")
assert decision["level"] == DispatchLevel.LOCAL
def test_local_file_exists(self, dispatcher, task_pending):
"""file_exists_check → 本地"""
decision = dispatcher.decide(task_pending, "file_exists_check")
assert decision["level"] == DispatchLevel.LOCAL
def test_registered_agent(self, dispatcher, task_pending):
"""注册 Agent → Full Agent"""
decision = dispatcher.decide(task_pending)
assert decision["level"] == DispatchLevel.FULL_AGENT
assert decision["agent_id"] == "zhangfei-dev"
def test_adjudication_new_session(self, dispatcher, task_pending):
"""adjudication → new_session=True"""
decision = dispatcher.decide(task_pending, "adjudication")
assert decision["level"] == DispatchLevel.FULL_AGENT
assert decision["new_session"] is True
def test_no_assignee_subagent(self, dispatcher, task_no_assignee):
"""无 assignee → 能力映射 fallback 庞统(Full Agent"""
decision = dispatcher.decide(task_no_assignee)
assert decision["level"] == DispatchLevel.FULL_AGENT
assert decision["agent_id"] == "pangtong-fujunshi" # fallback
def test_unknown_agent_escalate(self, dispatcher, task_unknown_agent):
"""未注册 Agent → 升级庞统"""
decision = dispatcher.decide(task_unknown_agent)
assert decision["level"] == DispatchLevel.ESCALATE
assert decision["agent_id"] == "pangtong-fujunshi"
assert decision["new_session"] is True
def test_different_registered_agents(self, dispatcher):
"""不同注册 Agent 正确路由"""
for agent_id in ["zhangfei-dev", "guanyu-dev", "simayi-challenger"]:
task = Task(id="t", title="T", status="pending",
assigned_by="d", assignee=agent_id)
decision = dispatcher.decide(task)
assert decision["level"] == DispatchLevel.FULL_AGENT
assert decision["agent_id"] == agent_id
def test_unknown_action_with_registered_agent(self, dispatcher, task_pending):
"""未知 action_type 但有注册 assignee → Full Agent"""
decision = dispatcher.decide(task_pending, "some_unknown_action")
assert decision["level"] == DispatchLevel.FULL_AGENT
# ---------------------------------------------------------------------------
# T2: 调度执行(带 mock
# ---------------------------------------------------------------------------
class TestDispatch:
def test_dispatch_local(self, dispatcher, task_pending):
"""本地调度不需要 spawner"""
result = asyncio.run(dispatcher.dispatch(task_pending, "L1_guardrail"))
assert result["status"] == "dispatched"
assert result["level"] == "local"
def test_dispatch_full_agent(self, dispatcher, task_pending):
"""Full Agent 调度"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(return_value="session-123")
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "dispatched"
assert result["session_id"] == "session-123"
assert result["level"] == "full"
def test_dispatch_no_spawner_returns_error(self, dispatcher, task_pending):
"""无 spawner → error"""
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "error"
assert "No spawner" in result["reason"]
def test_dispatch_subagent(self, dispatcher, task_no_assignee):
"""无 assignee → 能力映射 fallback 庞统(Full Agent"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(return_value="auto-123")
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_no_assignee))
assert result["status"] == "dispatched"
assert result["level"] == "full"
assert result["agent_id"] == "pangtong-fujunshi"
def test_dispatch_escalate(self, dispatcher, task_unknown_agent):
"""升级调度"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(return_value="esc-123")
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_unknown_agent))
assert result["status"] == "dispatched"
assert result["level"] == "escalate"
def test_dispatch_spawn_failure(self, dispatcher, task_pending):
"""spawn 失败 → error"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(side_effect=RuntimeError("spawn failed"))
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "error"
# ---------------------------------------------------------------------------
# T3: 队列满拒绝
# ---------------------------------------------------------------------------
class TestConcurrencyControl:
def test_counter_busy_skips(self, dispatcher, task_pending):
"""Agent 忙 → skip"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(
side_effect=AgentBusyError("zhangfei-dev", reason="counter_blocked")
)
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "skipped"
assert "busy" in result["reason"].lower()
def test_counter_releases_on_error(self, dispatcher, task_pending):
"""spawn 失败后释放 counter"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(side_effect=RuntimeError("fail"))
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "error"
def test_local_not_blocked_by_counter(self, dispatcher, task_pending):
"""本地执行不受 counter 限制"""
mock_counter = MagicMock()
mock_counter.can_acquire = AsyncMock(return_value=False)
dispatcher.counter = mock_counter
result = asyncio.run(dispatcher.dispatch(task_pending, "L1_guardrail"))
assert result["status"] == "dispatched"
# ---------------------------------------------------------------------------
# T4: 批量决策
# ---------------------------------------------------------------------------
class TestBatchDecision:
def test_dispatch_pending(self, dispatcher):
"""批量决策返回所有任务的决策"""
tasks = [
Task(id="t1", title="T1", status="pending",
assigned_by="d", assignee="zhangfei-dev"),
Task(id="t2", title="T2", status="pending",
assigned_by="d", assignee=None),
Task(id="t3", title="T3", status="pending",
assigned_by="d", assignee="unknown"),
]
results = dispatcher.dispatch_pending(tasks)
assert len(results) == 3
assert results[0]["level"] == DispatchLevel.FULL_AGENT
assert results[1]["level"] == DispatchLevel.FULL_AGENT
assert results[1]["agent_id"] == "pangtong-fujunshi" # fallback
assert results[2]["level"] == DispatchLevel.ESCALATE
def test_message_building(self, dispatcher):
"""构建给 Agent 的消息"""
task = Task(
id="t1", title="Build Feature", status="pending",
assigned_by="daemon", task_type="coding",
description="Implement X", must_haves="Tests, Docs",
)
msg = dispatcher._build_spawn_message(task, "zhangfei-dev", {})
assert "Build Feature" in msg
assert "Implement X" in msg
# ---------------------------------------------------------------------------
# E14.3: Dispatcher 错误区分(v2.8 #07.1 O3 新增)
# ---------------------------------------------------------------------------
class TestDispatcherErrorClassification:
"""E14.3: Dispatcher 捕获 AgentBusyError → 日志记录具体原因 → 路由决策"""
def test_busy_error_reason_in_result(self, dispatcher, task_pending):
"""AgentBusyError 被捕获后,结果包含具体 busy 原因(status=skipped"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(
side_effect=AgentBusyError("zhangfei-dev", reason="session_locked",
detail={"blockers": [("session_locked", 12345)]})
)
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] == "skipped"
assert "session" in result.get("reason", "").lower() or "locked" in result.get("reason", "").lower()
def test_counter_busy_returns_skipped(self, dispatcher, task_pending):
"""counter_blocked → skipped(非 error"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(
side_effect=AgentBusyError("zhangfei-dev", reason="counter_blocked")
)
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
assert result["status"] in ("skipped", "error")
+256
View File
@@ -0,0 +1,256 @@
"""F15 Experience Distillation 单元测试
按 test-plan-v2.6.md §F15
- T1: 经验提取(P0
- T2: 持久化(P0
- T3: 相似推荐(P0
- T4: 模式分类(P1
"""
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.experience import (
Experience,
ExperienceCategory,
ExperienceDistiller,
ExperienceStore,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path):
return ExperienceStore(store_path=tmp_path / "experiences.jsonl")
@pytest.fixture
def distiller(store):
return ExperienceDistiller(store=store)
@pytest.fixture
def memory_store():
"""纯内存 store"""
return ExperienceStore()
# ---------------------------------------------------------------------------
# T1: 经验提取
# ---------------------------------------------------------------------------
class TestDistillation:
def test_distill_from_review_failure(self, distiller):
exps = distiller.distill_from_task(
task_id="t1",
task_title="Build Feature",
task_type="coding",
review_result={
"verdict": "fail",
"results": [
{"step": "existence", "verdict": "fail",
"details": "Missing output.md", "suggestions": []},
],
},
)
assert len(exps) >= 1
assert any(e.category == "pitfall" for e in exps)
def test_distill_from_suggestions(self, distiller):
exps = distiller.distill_from_task(
task_id="t2",
task_title="Write Tests",
task_type="testing",
review_result={
"verdict": "pass",
"results": [
{"step": "quality", "verdict": "pass", "score": 0.9,
"suggestions": ["Always test edge cases"]},
],
},
)
assert len(exps) >= 1
summaries = [e.summary for e in exps]
assert any("edge cases" in s for s in summaries)
def test_distill_from_text_output(self, distiller):
exps = distiller.distill_from_task(
task_id="t3",
task_title="Deploy Service",
outputs=[
{"content": "## Best Practice\n\nAlways use health checks when deploying services."},
],
)
assert len(exps) >= 1
assert any(e.category == "best_practice" for e in exps)
def test_distill_pitfall_from_text(self, distiller):
exps = distiller.distill_from_task(
task_id="t4",
task_title="Debug Issue",
outputs=[
{"content": "## Bug Report\n\nForgot to close the database connection."},
],
)
assert any(e.category == "pitfall" for e in exps)
def test_distill_environment_from_text(self, distiller):
exps = distiller.distill_from_task(
task_id="t5",
task_title="Setup",
outputs=[
{"content": "Need to install Python 3.9+ and configure the PATH."},
],
)
assert any(e.category == "environment" for e in exps)
def test_empty_outputs_no_crash(self, distiller):
exps = distiller.distill_from_task(
task_id="t6",
task_title="Empty Task",
)
assert exps == []
# ---------------------------------------------------------------------------
# T2: 持久化
# ---------------------------------------------------------------------------
class TestPersistence:
def test_save_and_reload(self, tmp_path):
path = tmp_path / "experiences.jsonl"
store1 = ExperienceStore(store_path=path)
exp = Experience(category="pitfall", summary="Test experience", tags=["test"])
store1.add(exp)
# Reload
store2 = ExperienceStore(store_path=path)
assert store2.count() == 1
loaded = store2.get(exp.id)
assert loaded is not None
assert loaded.summary == "Test experience"
def test_delete_persists(self, tmp_path):
path = tmp_path / "experiences.jsonl"
store = ExperienceStore(store_path=path)
exp = Experience(category="pitfall", summary="To delete")
store.add(exp)
store.delete(exp.id)
assert store.count() == 0
# Reload
store2 = ExperienceStore(store_path=path)
assert store2.count() == 0
def test_multiple_experiences(self, tmp_path):
path = tmp_path / "experiences.jsonl"
store = ExperienceStore(store_path=path)
for i in range(5):
store.add(Experience(category="pattern", summary=f"Exp {i}"))
assert store.count() == 5
store2 = ExperienceStore(store_path=path)
assert store2.count() == 5
def test_memory_store_no_file(self):
store = ExperienceStore()
store.add(Experience(category="test", summary="Memory only"))
assert store.count() == 1
# ---------------------------------------------------------------------------
# T3: 相似推荐
# ---------------------------------------------------------------------------
class TestRecommendation:
def test_recommend_by_tags(self, distiller, store):
store.add(Experience(category="pitfall", summary="Coding pitfall",
tags=["coding"]))
store.add(Experience(category="best_practice", summary="Testing BP",
tags=["testing"]))
results = distiller.recommend(tags=["coding"])
assert len(results) >= 1
assert any("Coding pitfall" in e.summary for e in results)
def test_recommend_by_query(self, distiller, store):
store.add(Experience(category="pitfall", summary="Always close DB connections"))
store.add(Experience(category="best_practice", summary="Use type hints"))
results = distiller.recommend(query="db")
assert len(results) >= 1
assert any("DB" in e.summary for e in results)
def test_recommend_by_task_type(self, distiller, store):
store.add(Experience(category="pitfall", summary="P1", tags=["coding"]))
store.add(Experience(category="pitfall", summary="P2", tags=["testing"]))
results = distiller.recommend(task_type="coding")
assert any("P1" in e.summary for e in results)
def test_recommend_empty(self, distiller):
results = distiller.recommend()
assert results == []
def test_recommend_limit(self, distiller, store):
for i in range(10):
store.add(Experience(category="pitfall", summary=f"Exp {i}",
tags=["coding"]))
results = distiller.recommend(tags=["coding"], limit=3)
assert len(results) <= 3
# ---------------------------------------------------------------------------
# T4: 模式分类
# ---------------------------------------------------------------------------
class TestPatternClassification:
def test_classify_pitfall(self, distiller):
assert distiller._classify_text("This is a common bug") == "pitfall"
def test_classify_best_practice(self, distiller):
assert distiller._classify_text("Always use version control") == "best_practice"
def test_classify_environment(self, distiller):
assert distiller._classify_text("Install the required packages") == "environment"
def test_classify_no_match(self, distiller):
assert distiller._classify_text("The weather is nice today") is None
def test_classify_chinese(self, distiller):
assert distiller._classify_text("这是一个常见的陷阱") == "pitfall"
assert distiller._classify_text("建议使用类型注解") == "best_practice"
# ---------------------------------------------------------------------------
# Experience model
# ---------------------------------------------------------------------------
class TestExperienceModel:
def test_to_dict_roundtrip(self):
exp = Experience(
category="pitfall",
summary="Test",
source_task_id="t1",
tags=["coding"],
)
d = exp.to_dict()
exp2 = Experience.from_dict(d)
assert exp2.summary == exp.summary
assert exp2.category == exp.category
assert exp2.tags == exp.tags
def test_search_by_category(self, store):
store.add(Experience(category="pitfall", summary="P1"))
store.add(Experience(category="best_practice", summary="B1"))
results = store.search(category="pitfall")
assert len(results) == 1
assert results[0].summary == "P1"
+257
View File
@@ -0,0 +1,257 @@
"""F8 健康检查单元测试
按 test-plan-v2.6.md §F8
- T1: 正常场景(P0
- T2: 僵尸检测(P0
- T3: 恢复场景(P0
- T4: 多项目独立检测(P1
"""
import asyncio
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
from src.daemon.health import HealthChecker
from src.daemon.ticker import Ticker
from src.blackboard.registry import ProjectRegistry
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "test-proj" / "blackboard.db"
@pytest.fixture
def bb(db_path):
return Blackboard(db_path)
@pytest.fixture
def checker():
return HealthChecker(zombie_threshold=3)
# ---------------------------------------------------------------------------
# T1: 正常场景
# ---------------------------------------------------------------------------
class TestNormal:
def test_healthy_project(self, checker, db_path, bb):
"""有真实变更 → 健康"""
# 写一个真实事件
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
result = checker.check("test-proj", db_path, tick_num=1)
assert result["healthy"] is True
assert result["zombie"] is False
def test_no_zombie_alert_under_threshold(self, checker, db_path, bb):
"""stale < threshold → 不告警"""
for i in range(2):
result = checker.check("test-proj", db_path, tick_num=i + 1)
assert result["healthy"] is True
assert result["zombie"] is False
def test_stale_ticks_increment(self, checker, db_path, bb):
"""每次无变更 tick 递增 stale_ticks"""
r1 = checker.check("test-proj", db_path, tick_num=1)
assert r1["stale_ticks"] == 1
r2 = checker.check("test-proj", db_path, tick_num=2)
assert r2["stale_ticks"] == 2
def test_no_db_is_healthy(self, checker, tmp_path):
"""无 DB 文件 → 健康(不报错)"""
result = checker.check("no-db", tmp_path / "nonexistent.db", tick_num=1)
assert result["healthy"] is True
# ---------------------------------------------------------------------------
# T2: 僵尸检测
# ---------------------------------------------------------------------------
class TestZombieDetection:
def test_zombie_after_threshold(self, checker, db_path, bb):
"""达到 threshold → 僵尸告警"""
# 连续 threshold 次 tick 无真实变更
for i in range(3):
result = checker.check("test-proj", db_path, tick_num=i + 1)
assert result["zombie"] is True
assert result["healthy"] is False
assert result["alert_written"] is True
def test_zombie_writes_observation(self, checker, db_path, bb):
"""僵尸告警写入 observations 表"""
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
from src.blackboard.queries import Queries
queries = Queries(db_path)
# 查 observations(排除 events 里的 daemon_tick
conn = queries._conn()
try:
rows = conn.execute(
"SELECT * FROM observations WHERE observer='daemon'"
).fetchall()
finally:
conn.close()
assert len(rows) >= 1
body = json.loads(rows[0]["body"])
assert body["type"] == "zombie_detected"
assert body["stale_ticks"] == 3
def test_zombie_writes_event(self, checker, db_path, bb):
"""僵尸告警写入 events 表"""
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
from src.blackboard.queries import Queries
queries = Queries(db_path)
events = queries.recent_events(limit=20)
zombie_events = [e for e in events
if e["event_type"] == "agent_zombie_detected"]
assert len(zombie_events) >= 1
def test_no_duplicate_alert(self, checker, db_path, bb):
"""已告警后不再重复告警"""
for i in range(5):
result = checker.check("test-proj", db_path, tick_num=i + 1)
# 只在第一次达到 threshold 时告警
assert result["alert_written"] is False # 后续 tick 不再重复
assert result["zombie"] is True
def test_custom_threshold(self, db_path, bb):
"""自定义 threshold"""
checker = HealthChecker(zombie_threshold=5)
for i in range(4):
result = checker.check("test-proj", db_path, tick_num=i + 1)
assert result["healthy"] is True
r5 = checker.check("test-proj", db_path, tick_num=5)
assert r5["zombie"] is True
# ---------------------------------------------------------------------------
# T3: 恢复场景
# ---------------------------------------------------------------------------
class TestRecovery:
def test_recovery_after_real_event(self, checker, db_path, bb):
"""僵尸后有真实变更 → 告警解除"""
# 触发僵尸
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
# 写一个真实事件(非 daemon_tick
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
# 下一次 check
result = checker.check("test-proj", db_path, tick_num=4)
assert result["healthy"] is True
assert result["resolved"] is True
def test_recovery_resets_stale(self, checker, db_path, bb):
"""恢复后 stale_ticks 归零"""
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
result = checker.check("test-proj", db_path, tick_num=4)
assert result["stale_ticks"] == 0
def test_recovery_then_zombie_again(self, checker, db_path, bb):
"""恢复后再次变僵尸"""
# 第一次僵尸
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
# 恢复
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
checker.check("test-proj", db_path, tick_num=4)
# 再次僵尸
for i in range(3):
r = checker.check("test-proj", db_path, tick_num=5 + i)
assert r["zombie"] is True
def test_recovery_writes_info_observation(self, checker, db_path, bb):
"""恢复时写 info observation"""
for i in range(3):
checker.check("test-proj", db_path, tick_num=i + 1)
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
checker.check("test-proj", db_path, tick_num=4)
from src.blackboard.queries import Queries
queries = Queries(db_path)
conn = queries._conn()
try:
rows = conn.execute(
"SELECT * FROM observations WHERE observer='daemon' "
"ORDER BY id DESC"
).fetchall()
finally:
conn.close()
# 最新一条应该是 resolved
body = json.loads(rows[0]["body"])
assert body["type"] == "zombie_resolved"
assert rows[0]["severity"] == "info"
# ---------------------------------------------------------------------------
# T4: 多项目独立检测(P1
# ---------------------------------------------------------------------------
class TestMultiProject:
def test_independent_detection(self, tmp_path):
"""项目 A 僵尸不影响项目 B"""
checker = HealthChecker(zombie_threshold=3)
# 项目 A
db_a = tmp_path / "a" / "blackboard.db"
bb_a = Blackboard(db_a)
# 项目 B(有真实事件)
db_b = tmp_path / "b" / "blackboard.db"
bb_b = Blackboard(db_b)
bb_b.create_task(Task(id="b1", title="B", status="pending", assigned_by="d"))
for i in range(3):
checker.check("a", db_a, tick_num=i + 1)
checker.check("b", db_b, tick_num=i + 1)
status_a = checker.get_status("a")
status_b = checker.get_status("b")
assert status_a["is_zombie"] is True
assert status_b["is_zombie"] is False
def test_independent_recovery(self, tmp_path):
"""项目 A 恢复不影响项目 B"""
checker = HealthChecker(zombie_threshold=2)
db_a = tmp_path / "a" / "blackboard.db"
bb_a = Blackboard(db_a)
db_b = tmp_path / "b" / "blackboard.db"
bb_b = Blackboard(db_b)
# 两个都变僵尸
for i in range(2):
checker.check("a", db_a, tick_num=i + 1)
checker.check("b", db_b, tick_num=i + 1)
# 只恢复 A
bb_a.create_task(Task(id="a1", title="A", status="pending", assigned_by="d"))
checker.check("a", db_a, tick_num=3)
checker.check("b", db_b, tick_num=3)
assert checker.get_status("a")["is_zombie"] is False
assert checker.get_status("b")["is_zombie"] is True
+265
View File
@@ -0,0 +1,265 @@
"""F7 Inbox JSONL Watcher 单元测试
按测试计划 test-plan-v2.6.md §F7
- T1: 写入+消费(P0
- T2: truncateP0
- T3: 并发写入(P0
- T4: 损坏行恢复(P1
- T5: 空文件处理(P1
"""
import asyncio
import json
import threading
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.inbox import InboxWatcher
@pytest.fixture
def inbox_path(tmp_path):
return tmp_path / "inbox" / "daemon.jsonl"
@pytest.fixture
def watcher(inbox_path):
return InboxWatcher(inbox_path, watch_interval=0.05)
# ---------------------------------------------------------------------------
# T1: 写入+消费
# ---------------------------------------------------------------------------
class TestWriteConsume:
def test_single_event(self, inbox_path):
"""写入单条事件 → 消费到"""
InboxWatcher.write_event(inbox_path, {"type": "task_done", "task_id": "t1", "agent": "a"})
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 1
assert events[0]["type"] == "task_done"
assert events[0]["task_id"] == "t1"
def test_multiple_events(self, inbox_path):
"""写入多条事件 → 全部消费到"""
for i in range(5):
InboxWatcher.write_event(inbox_path, {"type": "progress", "task_id": f"t{i}", "pct": i * 20})
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 5
assert events[0]["task_id"] == "t0"
assert events[4]["task_id"] == "t4"
def test_consume_increments_counter(self, inbox_path):
"""total_processed 递增"""
watcher = InboxWatcher(inbox_path)
assert watcher.total_processed == 0
InboxWatcher.write_event(inbox_path, {"type": "test"})
asyncio.run(watcher.poll())
assert watcher.total_processed == 1
InboxWatcher.write_event(inbox_path, {"type": "test2"})
InboxWatcher.write_event(inbox_path, {"type": "test3"})
asyncio.run(watcher.poll())
assert watcher.total_processed == 3
def test_consume_no_file_returns_empty(self, inbox_path):
"""文件不存在时返回空列表"""
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert events == []
def test_batch_write(self, inbox_path):
"""批量写入"""
evts = [{"type": "batch", "seq": i} for i in range(10)]
InboxWatcher.write_events(inbox_path, evts)
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 10
# ---------------------------------------------------------------------------
# T2: truncate
# ---------------------------------------------------------------------------
class TestTruncate:
def test_file_cleared_after_consume(self, inbox_path):
"""消费后文件被清空"""
InboxWatcher.write_event(inbox_path, {"type": "test"})
assert inbox_path.exists()
assert inbox_path.stat().st_size > 0
watcher = InboxWatcher(inbox_path)
asyncio.run(watcher.poll())
# 文件存在但为空
assert inbox_path.exists()
assert inbox_path.stat().st_size == 0
def test_second_consume_returns_empty(self, inbox_path):
"""二次消费无新事件"""
InboxWatcher.write_event(inbox_path, {"type": "test"})
watcher = InboxWatcher(inbox_path)
events1 = asyncio.run(watcher.poll())
assert len(events1) == 1
events2 = asyncio.run(watcher.poll())
assert len(events2) == 0
def test_write_after_truncate(self, inbox_path):
"""truncate 后可以继续写入"""
InboxWatcher.write_event(inbox_path, {"type": "first"})
watcher = InboxWatcher(inbox_path)
asyncio.run(watcher.poll())
InboxWatcher.write_event(inbox_path, {"type": "second"})
events = asyncio.run(watcher.poll())
assert len(events) == 1
assert events[0]["type"] == "second"
# ---------------------------------------------------------------------------
# T3: 并发写入
# ---------------------------------------------------------------------------
class TestConcurrentWrite:
def test_multi_agent_concurrent(self, inbox_path):
"""多 Agent 同时写入不同事件,全部消费到"""
n_agents = 5
n_events_per_agent = 10
barrier = threading.Barrier(n_agents)
def writer(agent_id):
barrier.wait()
for i in range(n_events_per_agent):
InboxWatcher.write_event(inbox_path, {
"agent": agent_id,
"seq": i,
})
threads = [threading.Thread(target=writer, args=(f"agent-{j}",))
for j in range(n_agents)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == n_agents * n_events_per_agent
# 每个 agent 的事件都到齐
agents_seen = {e["agent"] for e in events}
assert len(agents_seen) == n_agents
# ---------------------------------------------------------------------------
# T4: 损坏行恢复(P1
# ---------------------------------------------------------------------------
class TestBadLineRecovery:
def test_skip_invalid_json(self, inbox_path):
"""非法 JSON 行跳过不崩溃"""
inbox_path.parent.mkdir(parents=True, exist_ok=True)
with open(inbox_path, "w") as f:
f.write('{"type": "good"}\n')
f.write('not json at all\n')
f.write('{"type": "also_good"}\n')
f.write('{broken json\n')
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 2
assert events[0]["type"] == "good"
assert events[1]["type"] == "also_good"
def test_non_dict_json_skipped(self, inbox_path):
"""合法 JSON 但非 dict 也跳过"""
inbox_path.parent.mkdir(parents=True, exist_ok=True)
with open(inbox_path, "w") as f:
f.write('{"type": "good"}\n')
f.write('[1, 2, 3]\n')
f.write('"just a string"\n')
f.write('42\n')
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 1
assert events[0]["type"] == "good"
def test_empty_lines_skipped(self, inbox_path):
"""空行不影响"""
inbox_path.parent.mkdir(parents=True, exist_ok=True)
with open(inbox_path, "w") as f:
f.write('\n\n{"type": "good"}\n\n\n')
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert len(events) == 1
# ---------------------------------------------------------------------------
# T5: 空文件处理(P1
# ---------------------------------------------------------------------------
class TestEmptyFile:
def test_empty_file_no_events(self, inbox_path):
"""空文件返回空列表"""
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.touch()
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert events == []
def test_whitespace_only_file(self, inbox_path):
"""只有空白的文件返回空列表"""
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.write_text(" \n \n \n")
watcher = InboxWatcher(inbox_path)
events = asyncio.run(watcher.poll())
assert events == []
# ---------------------------------------------------------------------------
# Watcher 生命周期
# ---------------------------------------------------------------------------
class TestWatcherLifecycle:
def test_start_stop(self, watcher, inbox_path):
"""可以启动和停止"""
async def run():
await watcher.start()
assert watcher.is_running
await asyncio.sleep(0.2)
await watcher.stop()
assert not watcher.is_running
asyncio.run(run())
def test_auto_consume_via_callback(self, inbox_path):
"""watcher 循环自动消费并通过回调传递事件"""
collected = []
async def on_event(event):
collected.append(event)
watcher = InboxWatcher(inbox_path, process_callback=on_event, watch_interval=0.05)
async def run():
await watcher.start()
# 写入事件
InboxWatcher.write_event(inbox_path, {"type": "auto"})
await asyncio.sleep(0.3) # 等待 watcher 消费
await watcher.stop()
asyncio.run(run())
assert len(collected) >= 1
assert collected[0]["type"] == "auto"
+104
View File
@@ -0,0 +1,104 @@
"""F3+F4 测试:多项目管理 + CLI 工具"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from src.blackboard.registry import ProjectRegistry
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
# ===================================================================
# F3: 多项目管理
# ===================================================================
@pytest.fixture
def registry(tmp_path):
return ProjectRegistry(tmp_path)
class TestProjectRegistry:
def test_create_project(self, registry):
info = registry.create_project("test-proj", "Test Project",
agents=["agent1", "agent2"])
assert info["name"] == "Test Project"
assert info["agents"] == ["agent1", "agent2"]
assert info["status"] == "active"
def test_create_creates_dirs(self, registry):
registry.create_project("test-proj", "Test")
root = registry.root
assert (root / "test-proj" / "config" / "project.yaml").exists()
assert (root / "test-proj" / "artifacts").is_dir()
assert (root / "test-proj" / "experiences").is_dir()
assert (root / "test-proj" / "skills").is_dir()
def test_create_duplicate_fails(self, registry):
registry.create_project("p1", "P1")
with pytest.raises(ValueError, match="already exists"):
registry.create_project("p1", "P1 Duplicate")
def test_get_project(self, registry):
registry.create_project("p1", "P1")
info = registry.get_project("p1")
assert info is not None
assert info["name"] == "P1"
def test_get_nonexistent(self, registry):
assert registry.get_project("nope") is None
def test_list_projects(self, registry):
registry.create_project("p1", "P1")
registry.create_project("p2", "P2")
projects = registry.list_projects()
assert len(projects) == 2
assert "p1" in projects
assert "p2" in projects
def test_archive_project(self, registry):
registry.create_project("p1", "P1")
assert registry.archive_project("p1")
info = registry.get_project("p1")
assert info["status"] == "archived"
def test_archive_nonexistent(self, registry):
assert not registry.archive_project("nope")
def test_delete_project(self, registry):
registry.create_project("p1", "P1")
assert registry.delete_project("p1")
# v2.8: 逻辑删除,get_project 返回带 status=deleted 的 dict 而非 None
deleted = registry.get_project("p1")
assert deleted is not None
assert deleted["status"] == "deleted"
def test_registry_yaml_persists(self, registry):
registry.create_project("p1", "P1")
# Reload from disk
reg2 = ProjectRegistry(registry.root)
assert reg2.get_project("p1") is not None
def test_per_project_blackboard(self, tmp_path):
"""每个项目有独立的黑板 DB"""
registry = ProjectRegistry(tmp_path)
registry.create_project("proj-a", "A")
registry.create_project("proj-b", "B")
db_a = tmp_path / "proj-a" / "blackboard.db"
db_b = tmp_path / "proj-b" / "blackboard.db"
bb_a = Blackboard(db_a)
bb_b = Blackboard(db_b)
bb_a.create_task(Task(id="t1", title="A Task"))
bb_b.create_task(Task(id="t1", title="B Task"))
assert bb_a.get_task("t1").title == "A Task"
assert bb_b.get_task("t1").title == "B Task"
+87
View File
@@ -0,0 +1,87 @@
"""F14 Rebuttal 单元测试
按 test-plan-v2.6.md §F14
- F14 T1: 反驳权流程(P0
- F14 T2: 最大轮次限制(P0
"""
import json
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
from src.daemon.review import RebuttalManager
pytestmark = pytest.mark.unit
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "blackboard.db"
@pytest.fixture
def bb(db_path):
return Blackboard(db_path)
# ---------------------------------------------------------------------------
# F14 T1: 反驳权流程
# ---------------------------------------------------------------------------
class TestRebuttal:
def test_submit_rebuttal_accepted(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
result = rm.submit_rebuttal("t1", "agent-1", "I disagree with the review")
assert result["status"] == "accepted"
assert result["round"] == 1
assert result["escalation_target"] == "simayi-challenger"
def test_second_round_escalates_to_pangtong(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "Round 1")
result = rm.submit_rebuttal("t1", "agent-1", "Round 2")
assert result["status"] == "accepted"
assert result["round"] == 2
assert result["escalation_target"] == "pangtong-fujunshi"
# ---------------------------------------------------------------------------
# F14 T2: 最大轮次限制
# ---------------------------------------------------------------------------
class TestRebuttalLimits:
def test_max_rounds_rejected(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "a", "R1")
rm.submit_rebuttal("t1", "a", "R2")
result = rm.submit_rebuttal("t1", "a", "R3")
assert result["status"] == "rejected"
assert "Max" in result["reason"]
def test_rebuttal_without_bb(self):
rm = RebuttalManager(bb=None)
result = rm.submit_rebuttal("t1", "a", "reason")
assert result["status"] == "accepted"
assert result["round"] == 1
def test_rebuttal_observation_recorded(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "test reason", evidence="file.txt")
obs = bb.get_observations(task_id="t1")
rebuttals = [o for o in obs if "Rebuttal round" in (o.body or "")]
assert len(rebuttals) == 1
+274
View File
@@ -0,0 +1,274 @@
"""Router 单元测试 — 三种路由模式 + 校验 + fallback"""
import pytest
pytestmark = pytest.mark.unit
from unittest.mock import MagicMock, patch
from src.daemon.router import (
AgentRouter, AgentProfile, RouteDecision, KNOWN_CAPABILITIES,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def profiles():
return {
"zhangfei-dev": AgentProfile(
agent_id="zhangfei-dev",
capabilities=["coding", "implementation", "scripting"],
can_review=False, max_concurrent=1,
),
"simayi-challenger": AgentProfile(
agent_id="simayi-challenger",
capabilities=["review", "quality_check", "debate"],
can_review=True, max_concurrent=2,
),
"pangtong-fujunshi": AgentProfile(
agent_id="pangtong-fujunshi",
capabilities=["planning", "coordination", "escalation", "strategy"],
can_review=True, is_fallback=True, max_concurrent=3,
),
}
@pytest.fixture
def router(profiles):
return AgentRouter(agent_profiles=profiles)
def make_task(**overrides):
base = {
"id": "t1", "title": "Test Task", "status": "pending",
"assignee": "zhangfei-dev", "current_agent": None,
"next_capability": None, "task_type": "coding",
"description": "Write some code",
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# T1: 确定性快速路径
# ---------------------------------------------------------------------------
class TestDeterministic:
def test_local_action(self, router):
"""机械检查 → daemon"""
d = router.route(make_task(), action_type="L1_guardrail")
assert d.agent_id == "daemon"
assert d.mode == "deterministic"
def test_format_check_local(self, router):
d = router.route(make_task(), action_type="format_check")
assert d.agent_id == "daemon"
def test_retry_same_agent(self, router):
"""retry → 原执行者"""
d = router.route(make_task(current_agent="zhangfei-dev"),
action_type="retry")
assert d.agent_id == "zhangfei-dev"
assert d.mode == "deterministic"
def test_direct_assignee(self, router):
"""有 assignee 且非生命周期流转 → 直接用"""
d = router.route(make_task(), action_type="execute")
assert d.agent_id == "zhangfei-dev"
assert d.mode == "deterministic"
# ---------------------------------------------------------------------------
# T2: Mode B — Agent 声明式交接
# ---------------------------------------------------------------------------
class TestAgentHandoff:
def test_handoff_review(self, router):
"""张飞说需要 review → 匹配司马懿"""
d = router.route(make_task(
current_agent="zhangfei-dev",
next_capability="review",
))
assert d.agent_id == "simayi-challenger"
assert d.mode == "agent_handoff"
assert "review" in d.reason
def test_handoff_excludes_current(self, router):
"""交接排除当前执行者(不能交给自己)"""
d = router.route(make_task(
current_agent="simayi-challenger",
next_capability="review",
))
# simayi 被排除,只剩 pangtongcan_review=True 且有 review 能力)
assert d.agent_id != "simayi-challenger"
def test_handoff_invalid_capability_ignored(self, router):
"""BUG-2: 非法 next_capability 被忽略,不进路由"""
d = router.route(make_task(
current_agent="zhangfei-dev",
next_capability="nonexistent_capability",
), action_type="execute")
# 不应该走 handoff,应该走 assignee 直派或其他
assert d.mode != "agent_handoff"
def test_handoff_known_but_no_match(self):
"""合法 capability 但无匹配 Agent → 降级"""
router2 = AgentRouter(agent_profiles={
"zhangfei-dev": AgentProfile(
agent_id="zhangfei-dev",
capabilities=["coding"],
),
})
d = router2.route(make_task(
assignee=None, # 无 assignee,不会走快速路径 4
current_agent="zhangfei-dev",
next_capability="review", # 合法但在 profiles 中无人匹配
))
# 无 LLM driver、无 assignee → fallback 庞统
assert d.agent_id == "pangtong-fujunshi"
assert d.mode == "delegate"
# ---------------------------------------------------------------------------
# T3: 生命周期流转
# ---------------------------------------------------------------------------
class TestLifecycle:
def test_review_action(self, router):
"""review action_type → 查 review 能力,排除 current_agent"""
d = router.route(make_task(
current_agent="zhangfei-dev",
), action_type="review")
assert d.agent_id == "simayi-challenger"
assert d.mode == "deterministic"
def test_review_excludes_executor(self, router):
"""review 排除执行者(张飞不能审张飞)"""
d = router.route(make_task(
current_agent="zhangfei-dev",
assignee="zhangfei-dev",
), action_type="review")
assert d.agent_id != "zhangfei-dev"
assert d.agent_id == "simayi-challenger"
# ---------------------------------------------------------------------------
# T4: Mode A — LLM 路由 (v3.0: LLMDriver 已移除,模糊路由 delegate 庞统)
# ---------------------------------------------------------------------------
@pytest.mark.skip(reason="v3.0 removed LLMDriver, fuzzy routing delegates to pangtong")
class TestLLMRoute:
def test_llm_returns_valid(self, router):
"""LLM 返回合法 Agent"""
mock_driver = MagicMock(spec=LLMDriver)
mock_driver.route.return_value = RouteDecision(
agent_id="simayi-challenger",
reason="LLM selected reviewer",
mode="llm_route",
confidence=0.9,
model="zhipu/glm-5.1",
latency_ms=1200,
)
router.llm_driver = mock_driver
# 无 assignee、无 next_capability、非 lifecycle → 走 LLM
d = router.route(make_task(
assignee=None, current_agent=None,
next_capability=None,
))
assert d.agent_id == "simayi-challenger"
assert d.mode == "llm_route"
assert d.confidence == 0.9
def test_llm_invalid_agent_fallback(self, router):
"""LLM 返回不存在的 Agent → fallback"""
mock_driver = MagicMock(spec=LLMDriver)
mock_driver.route.return_value = RouteDecision(
agent_id="nonexistent-agent",
reason="Bad pick",
mode="llm_route",
confidence=0.8,
latency_ms=500,
)
router.llm_driver = mock_driver
d = router.route(make_task(assignee=None))
assert d.agent_id == "pangtong-fujunshi"
assert d.mode == "fallback"
def test_llm_low_confidence_fallback(self, router):
"""LLM 置信度低于阈值 → fallback"""
mock_driver = MagicMock(spec=LLMDriver)
mock_driver.route.return_value = RouteDecision(
agent_id="simayi-challenger",
reason="Not sure",
mode="llm_route",
confidence=0.5,
latency_ms=300,
)
router.llm_driver = mock_driver
d = router.route(make_task(assignee=None))
assert d.agent_id == "pangtong-fujunshi"
assert d.mode == "fallback"
assert d.confidence == 0.5
# ---------------------------------------------------------------------------
# T5: Fallback
# ---------------------------------------------------------------------------
class TestFallback:
def test_no_llm_no_match(self, router):
"""无 LLM、无匹配 → fallback 庞统"""
d = router.route(make_task(
assignee=None, current_agent=None,
next_capability=None, task_type="unknown_type",
), action_type="")
assert d.agent_id == "pangtong-fujunshi"
assert d.mode == "delegate"
# ---------------------------------------------------------------------------
# T6: Capability 校验
# ---------------------------------------------------------------------------
class TestCapabilityValidation:
def test_known_capabilities_populated(self, router):
"""profiles 自动填充 known capabilities"""
assert "coding" in router._known_capabilities
assert "review" in router._known_capabilities
assert "escalation" in router._known_capabilities
def test_validate_known(self, router):
assert router._validate_capability("coding") is True
assert router._validate_capability("review") is True
def test_validate_unknown(self, router):
assert router._validate_capability("administration") is False
assert router._validate_capability("hack") is False
def test_known_capabilities_set(self):
"""KNOWN_CAPABILITIES 包含所有预期能力"""
assert "coding" in KNOWN_CAPABILITIES
assert "review" in KNOWN_CAPABILITIES
assert "escalation" in KNOWN_CAPABILITIES
# ---------------------------------------------------------------------------
# T7: Latency 记录
# ---------------------------------------------------------------------------
class TestLatency:
def test_deterministic_has_latency(self, router):
d = router.route(make_task(), action_type="L1_guardrail")
assert d.latency_ms >= 0
def test_handoff_has_latency(self, router):
d = router.route(make_task(
current_agent="zhangfei-dev",
next_capability="review",
))
assert d.latency_ms >= 0
+225
View File
@@ -0,0 +1,225 @@
"""F16 Skill System 单元测试
按 test-plan-v2.6.md §F16
- T1: 注册/查询(P0
- T2: 三层自由度(P0
- T3: 技能匹配(P0
- T4: 模板变量替换(P1
"""
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.skill_system import (
Skill,
SkillExecutor,
SkillFreedom,
SkillRegistry,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry():
return SkillRegistry()
@pytest.fixture
def registry_with_skills(registry):
registry.register(Skill(
id="code-review",
name="Code Review",
description="Review code quality and suggest improvements",
freedom=SkillFreedom.HIGH.value,
tags=["coding", "review"],
))
registry.register(Skill(
id="test-template",
name="Test Generator",
description="Generate test cases from code",
freedom=SkillFreedom.MEDIUM.value,
prompt_template="Write tests for {{module}} focusing on {{focus}}",
tags=["testing", "coding"],
))
registry.register(Skill(
id="deploy-script",
name="Deploy Script",
description="Run deployment script",
freedom=SkillFreedom.LOW.value,
script_path="/usr/local/bin/deploy.sh",
tags=["deployment"],
))
return registry
@pytest.fixture
def executor(registry_with_skills):
return SkillExecutor(registry=registry_with_skills)
@pytest.fixture
def skills_dir(tmp_path):
d = tmp_path / "skills"
d.mkdir()
(d / "skill1.json").write_text(json.dumps({
"id": "loaded-skill",
"name": "Loaded Skill",
"description": "Loaded from file",
"freedom": "high",
}))
(d / "skill2.json").write_text(json.dumps({
"id": "another",
"name": "Another",
"description": "Another skill",
"freedom": "medium",
"prompt_template": "Do {{action}}",
}))
return d
# ---------------------------------------------------------------------------
# T1: 注册/查询
# ---------------------------------------------------------------------------
class TestRegistry:
def test_register_and_get(self, registry):
skill = Skill(id="s1", name="Test", description="A test skill")
registry.register(skill)
assert registry.get("s1") is not None
assert registry.get("s1").name == "Test"
def test_unregister(self, registry):
registry.register(Skill(id="s1", name="T", description="D"))
assert registry.unregister("s1") is True
assert registry.get("s1") is None
def test_unregister_nonexistent(self, registry):
assert registry.unregister("nope") is False
def test_list_skills(self, registry_with_skills):
skills = registry_with_skills.list_skills()
assert len(skills) == 3
def test_list_by_freedom(self, registry_with_skills):
high = registry_with_skills.list_skills(freedom=SkillFreedom.HIGH.value)
assert len(high) == 1
assert high[0].id == "code-review"
def test_list_by_tags(self, registry_with_skills):
coding = registry_with_skills.list_skills(tags=["coding"])
assert len(coding) == 2
def test_list_enabled_only(self, registry):
registry.register(Skill(id="s1", name="On", description="D", enabled=True))
registry.register(Skill(id="s2", name="Off", description="D", enabled=False))
assert len(registry.list_skills()) == 1
def test_load_from_dir(self, skills_dir):
reg = SkillRegistry(skills_dir=skills_dir)
assert reg.count() == 2
assert reg.get("loaded-skill") is not None
# ---------------------------------------------------------------------------
# T2: 三层自由度
# ---------------------------------------------------------------------------
class TestFreedomLevels:
def test_high_freedom_prompt(self, executor):
result = executor.build_prompt("code-review")
assert "Skill: Code Review" in result
assert "Principle" in result
def test_medium_freedom_template(self, executor):
result = executor.build_prompt("test-template",
variables={"module": "auth.py", "focus": "edge cases"})
assert "auth.py" in result
assert "edge cases" in result
def test_low_freedom_script(self, executor):
result = executor.build_prompt("deploy-script")
assert "deploy.sh" in result
# ---------------------------------------------------------------------------
# T3: 技能匹配
# ---------------------------------------------------------------------------
class TestMatching:
def test_match_by_name(self, registry_with_skills):
results = registry_with_skills.match("review")
assert len(results) >= 1
assert results[0][0].id == "code-review"
assert results[0][1] > 0 # score > 0
def test_match_by_description(self, registry_with_skills):
results = registry_with_skills.match("deployment")
assert len(results) >= 1
def test_match_with_task_type(self, registry_with_skills):
results = registry_with_skills.match("test", task_type="testing")
assert len(results) >= 1
def test_match_no_results(self, registry_with_skills):
results = registry_with_skills.match("nonexistent_xyz_abc")
assert len(results) == 0
def test_match_limit(self, registry_with_skills):
results = registry_with_skills.match("code", limit=1)
assert len(results) <= 1
def test_match_excludes_disabled(self, registry):
registry.register(Skill(id="s1", name="Disabled Review",
description="review", enabled=False))
results = registry.match("review")
assert all(s.enabled for s, _ in results)
# ---------------------------------------------------------------------------
# T4: 模板变量替换 + 执行
# ---------------------------------------------------------------------------
class TestExecution:
def test_execute_high_freedom(self, executor):
result = executor.execute("code-review")
assert result["status"] == "success"
assert result["prompt"] is not None
def test_execute_medium_with_vars(self, executor):
result = executor.execute("test-template",
context={"module": "api", "focus": "errors"})
assert result["status"] == "success"
assert "api" in result["prompt"]
def test_execute_low_blocked(self, executor):
"""默认不允许脚本执行"""
result = executor.execute("deploy-script")
assert result["status"] == "error"
assert "not allowed" in result["error"]
def test_execute_low_allowed(self, registry_with_skills):
executor = SkillExecutor(registry=registry_with_skills, allow_scripts=True)
result = executor.execute("deploy-script")
assert result["status"] == "success"
def test_execute_not_found(self, executor):
result = executor.execute("nonexistent")
assert result["status"] == "error"
def test_execute_disabled(self, registry):
registry.register(Skill(id="s1", name="T", description="D", enabled=False))
executor = SkillExecutor(registry=registry)
result = executor.execute("s1")
assert result["status"] == "error"
assert "disabled" in result["error"]
def test_execution_log(self, executor):
executor.execute("code-review")
assert len(executor.execution_log) == 1
assert executor.execution_log[0]["skill_id"] == "code-review"
+427
View File
@@ -0,0 +1,427 @@
"""F9 Agent Spawner 单元测试 — classify/session 管理"""
import asyncio
import pytest
from pathlib import Path
from src.blackboard.operations import Blackboard
from src.daemon.spawner import AgentSpawner, AgentBusyError
pytestmark = pytest.mark.unit
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "blackboard.db"
@pytest.fixture
def bb(db_path):
return Blackboard(db_path)
@pytest.fixture
def spawner(db_path):
return AgentSpawner(db_path=db_path, dry_run=True)
# ---------------------------------------------------------------------------
# T1: spawn 成功(dry_run
# ---------------------------------------------------------------------------
class TestSpawnSuccess:
def test_dry_run_spawn(self, spawner):
"""dry_run 模式不实际 spawn"""
session_id = asyncio.run(
spawner.spawn_full_agent("test-agent", "do something", task_id="t1")
)
assert session_id
assert spawner.get_session(session_id) is not None
assert spawner.get_session(session_id)["agent_id"] == "test-agent"
def test_session_registered(self, spawner):
"""spawn 后 session 注册"""
asyncio.run(spawner.spawn_full_agent("agent-1", "task", task_id="t1"))
sessions = spawner.active_sessions
assert len(sessions) >= 1
def test_spawn_subagent_dry_run(self, spawner):
"""subagent dry_run"""
session_id = asyncio.run(
spawner.spawn_subagent("do task", task_id="t1")
)
assert session_id
def test_multiple_spawns(self, spawner):
"""多次 spawn 独立 session"""
ids = []
for i in range(3):
sid = asyncio.run(
spawner.spawn_full_agent(f"agent-{i}", f"task {i}")
)
ids.append(sid)
assert len(set(ids)) == 3 # 每个 session_id 唯一
# ---------------------------------------------------------------------------
# T4: session 清理
# ---------------------------------------------------------------------------
class TestSessionCleanup:
def test_cleanup_removes_session(self, spawner):
"""cleanup 删除 session"""
sid = asyncio.run(spawner.spawn_full_agent("a", "m"))
assert spawner.get_session(sid) is not None
spawner.cleanup_session(sid)
assert spawner.get_session(sid) is None
def test_cleanup_nonexistent(self, spawner):
"""清理不存在的 session 不报错"""
spawner.cleanup_session("nonexistent-id") # no error
def test_active_sessions_excludes_completed(self, spawner):
"""active_sessions 排除已完成"""
sid = asyncio.run(spawner.spawn_full_agent("a", "m"))
session = spawner.get_session(sid)
session["status"] = "completed"
active = spawner.active_sessions
assert sid not in active
# ---------------------------------------------------------------------------
# E11: Spawner Acquire-First Phase 0-4v2.8 #07.1 新增)
# ---------------------------------------------------------------------------
class TestAcquireFirst:
"""E11: #07.1 Acquire-First 重构后的 Phase 0-4 测试"""
def test_phase0_revive_before_acquire(self, spawner):
"""E11.1 Phase 0: timeout/failed 状态 → revive → acquire 成功"""
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent", "do something",
task_id="t1", use_main_session=True,
)
)
assert session_id
def test_phase0_stuck_detection(self, spawner):
"""E11.2 Phase 0: status=running + lock PID 死 → revive"""
original_check = spawner._check_session_state
call_count = [0]
def mock_check(agent_id):
call_count[0] += 1
if call_count[0] == 1:
return {"status": "running", "lock_pid_alive": False}
return {"status": "idle", "lock_pid_alive": False}
spawner._check_session_state = mock_check
revive_called = [False]
def mock_revive(agent_id):
revive_called[0] = True
return True
spawner._revive_session = mock_revive
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent", "do something",
task_id="t1", use_main_session=True,
)
)
assert revive_called[0], "Phase 0 should have called _revive_session for stuck session"
def test_phase1_counter_acquire_exclusive(self, spawner):
"""E11.3 Phase 1: counter acquire 互斥"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_concurrent_sessions=1)
spawner.counter = counter
session_id = asyncio.run(
spawner.spawn_full_agent("test-agent", "task", task_id="t1")
)
assert session_id
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent("test-agent", "task2", task_id="t2")
)
assert "counter" in exc_info.value.reason or "blocked" in exc_info.value.reason
def test_phase2_session_check_under_lock(self, spawner):
"""E11.4 Phase 2: session check 在锁保护下执行"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
spawner._check_session_state = lambda agent_id: {
"status": "idle",
"lock_pid_alive": True,
"lock_expired": False,
}
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert "session_locked" in exc_info.value.reason
assert counter.global_active == 0
def test_phase2_multiple_blockers(self, spawner):
"""E11.5 Phase 2: 多 blocker 并列收集"""
spawner._check_session_state = lambda agent_id: {
"status": "idle",
"lock_pid_alive": True,
"lock_expired": False,
"recent_compact": True,
}
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert exc_info.value.detail is not None
blockers = exc_info.value.detail.get("blockers", [])
blocker_reasons = [b[0] for b in blockers]
assert "session_locked" in blocker_reasons
assert "session_compacting" in blocker_reasons
def test_phase3_on_checks_passed_exception_rollback(self, spawner):
"""E11.6 Phase 3: on_checks_passed 抛异常 → counter 自动 release"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
def bad_callback():
raise RuntimeError("callback failed")
with pytest.raises(RuntimeError, match="callback failed"):
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1",
on_checks_passed=bad_callback,
)
)
assert counter.global_active == 0
# ---------------------------------------------------------------------------
# E13: Compact Hanging 不标 failedv2.8 #07.3 ACT-2 新增)
# ---------------------------------------------------------------------------
class TestCompactHanging:
"""E13: compact_hanging 后不标 failed,只 release counter → 任务保持 working"""
def test_compact_hanging_releases_counter(self):
"""E13.1: compact 超限 → compact_hanging → release counter → 任务保持 working"""
from src.daemon.counter import ActiveAgentCounter
from src.blackboard.models import Task as TaskModel
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
db_path = Path("/tmp/test_compact_hanging.db")
try:
bb = Blackboard(db_path)
bb.create_task(TaskModel(id="t1", title="T", status="working", assigned_by="d",
current_agent="test-agent"))
spawner = AgentSpawner(db_path=db_path, dry_run=True)
spawner.counter = counter
outcomes = []
async def mock_on_complete(aid, outcome):
outcomes.append((aid, outcome))
sid = asyncio.run(spawner.spawn_full_agent(
"test-agent", "task", task_id="t1",
on_complete=mock_on_complete,
))
counter.release("test-agent", sid)
assert counter.global_active == 0
finally:
if db_path.exists():
db_path.unlink()
def test_retry_agent_busy_releases_counter(self, spawner):
"""E13.3: _do_retry 遇 AgentBusyError → release counter → 任务保持 working"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
spawner._check_session_state = lambda agent_id: {
"status": "running",
"lock_pid_alive": True,
"lock_expired": False,
}
with pytest.raises(AgentBusyError):
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert counter.global_active == 0
# ---------------------------------------------------------------------------
# E14: AgentBusyError 分类(v2.8 #07.1 O3 新增)
# ---------------------------------------------------------------------------
class TestAgentBusyErrorClassification:
"""E14: AgentBusyError reason/detail 分类验证"""
def test_counter_blocked_reason(self, spawner):
"""E14.1: counter acquire 失败 → reason=counter_blocked"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=1, max_per_agent=1)
spawner.counter = counter
asyncio.run(counter.acquire("test-agent"))
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent("test-agent", "task", task_id="t1")
)
assert exc_info.value.reason == "counter_blocked"
assert exc_info.value.agent_id == "test-agent"
def test_session_blocker_reasons(self, spawner):
"""E14.2: session locked/running/compacting → 具体 reason + detail.blockers"""
test_cases = [
{
"state": {"status": "idle", "lock_pid_alive": True, "lock_expired": False},
"expected": "session_locked",
},
{
"state": {"status": "running", "lock_pid_alive": True, "lock_expired": False},
"expected": "session_locked",
},
{
"state": {"status": "idle", "lock_pid_alive": False, "recent_compact": True},
"expected": "session_compacting",
},
]
for i, tc in enumerate(test_cases):
state = tc["state"]
expected = tc["expected"]
spawner._check_session_state = lambda aid, s=state: s
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert expected in exc_info.value.reason, \
f"Case {i}: expected '{expected}' in '{exc_info.value.reason}'"
assert exc_info.value.detail is not None, \
f"Case {i}: expected detail to be set"
def test_session_running_in_blockers(self, spawner):
"""session_running 出现在 blockers 列表中(session_locked 优先)"""
spawner._check_session_state = lambda aid: {
"status": "running", "lock_pid_alive": True, "lock_expired": False,
}
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert "session_locked" in exc_info.value.reason
blockers = exc_info.value.detail.get("blockers", [])
blocker_reasons = [b[0] for b in blockers]
assert "session_running" in blocker_reasons
assert "session_locked" in blocker_reasons
def test_error_attributes(self):
"""E14 补充: AgentBusyError 属性验证"""
err = AgentBusyError(
"my-agent",
reason="session_locked",
detail={"blockers": [("session_locked", 12345)]},
)
assert err.agent_id == "my-agent"
assert err.reason == "session_locked"
assert err.detail["blockers"][0][0] == "session_locked"
assert "my-agent" in str(err)
assert "session_locked" in str(err)
# ---------------------------------------------------------------------------
# 司马懿评审补充:Phase 2.5 + session_stuckv2.8 #07.1 v1.1 兜底)
# ---------------------------------------------------------------------------
class TestPhase25AndStuck:
"""司马懿评审遗漏 #1 + session_stuck 遗漏补充"""
def test_phase25_stuck_fallback(self, spawner):
"""Phase 2.5: Phase 2 检测到假死 → revive → 成功 spawn"""
call_count = [0]
def mock_check(agent_id):
call_count[0] += 1
if call_count[0] <= 1:
return {"status": "idle", "lock_pid_alive": False}
if call_count[0] == 2:
return {"status": "running", "lock_pid_alive": False}
return {"status": "idle", "lock_pid_alive": False}
spawner._check_session_state = mock_check
revive_called = [False]
def mock_revive(agent_id):
revive_called[0] = True
return True
spawner._revive_session = mock_revive
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert revive_called[0], "Phase 2.5 should have revived stuck session"
assert session_id
def test_session_stuck_after_failed_revive(self, spawner):
"""Phase 2.5 revive 失败 → session_stuck"""
revive_and_check_count = [0]
def mock_check_v2(agent_id):
revive_and_check_count[0] += 1
if revive_and_check_count[0] <= 1:
return {"status": "idle", "lock_pid_alive": False}
if revive_and_check_count[0] == 2:
return {"status": "running", "lock_pid_alive": False}
return {"status": "running", "lock_pid_alive": False}
spawner._check_session_state = mock_check_v2
spawner._revive_session = lambda agent_id: True
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
task_id="t1", use_main_session=True,
)
)
assert "stuck" in exc_info.value.reason