feat(cli): U6 CLI 协同事件 Rich 渲染

- chat.py 新增 _render_collaboration_contracts 和 _render_pm_collaboration_event
- 4 种 PM 协同事件渲染:
  collaboration_contract_defined (cyan Panel)
  collaboration_notice (蓝→品红 文本)
  review_result (passed=green / failed=red Panel)
  risk_flagged (yellow Panel)
- plan_update 中提取 collaboration_contracts 并渲染
- _print_help 更新项目经理模式说明
- 优雅降级:字段缺失回退到 ?,空契约不输出,整体 try/except 不中断编排
- 新增 11 个测试(TestPMCollaborationRendering 9 + TestPrintHelpPMMode 2)
- ruff 通过,pytest 23 passed
This commit is contained in:
chiguyong 2026-06-24 14:56:52 +08:00
parent 34a4164430
commit 6016c087fe
2 changed files with 272 additions and 5 deletions

View File

@ -525,6 +525,87 @@ def _resolve_default_model(server_config: "ServerConfig") -> str:
return "default" return "default"
def _render_collaboration_contracts(contracts: list[dict]) -> None:
"""Render collaboration contracts as a Panel (U6)."""
if not contracts:
return
lines = [
f" [blue]{c.get('from_expert', '?')}[/blue] → "
f"[magenta]{c.get('to_expert', '?')}[/magenta]: "
f"{c.get('content_description', '')} "
f"[dim]({c.get('status', 'pending')})[/dim]"
for c in contracts
]
rprint(
Panel(
"\n".join(lines),
title="[bold]协作契约[/bold]",
border_style="cyan",
)
)
def _render_pm_collaboration_event(message: dict) -> bool:
"""Render PM collaboration events (U6).
Handles 4 event types: collaboration_contract_defined, collaboration_notice,
review_result, risk_flagged. Returns True if the event type was handled.
Best-effort: never raises on missing/malformed data.
"""
etype = message.get("type", "")
try:
if etype == "collaboration_contract_defined":
_render_collaboration_contracts(message.get("contracts", []))
return True
elif etype == "collaboration_notice":
from_e = message.get("from_expert", "?")
to_e = message.get("to_expert", "?")
content = message.get("content_description", "")
rprint(f" [blue]{from_e}[/blue] [dim]→[/dim] [magenta]{to_e}[/magenta]: {content}")
return True
elif etype == "review_result":
passed = bool(message.get("passed", False))
feedback = message.get("feedback", "")
phase_name = message.get("phase_name", "?")
expert = message.get("expert", "?")
rework_count = message.get("rework_count", 0)
color = "green" if passed else "red"
status_text = "验收通过" if passed else "验收未通过"
lines = [
f"[bold]阶段:[/bold] {phase_name} ({expert})",
f"[bold]结果:[/bold] [{color}]{status_text}[/{color}]",
]
if rework_count:
lines.append(f"[bold]返工次数:[/bold] {rework_count}")
if feedback:
lines.append(f"[bold]反馈:[/bold] {feedback}")
rprint(
Panel(
"\n".join(lines),
title=f"[bold]{'' if passed else ''} 验收结果[/bold]",
border_style=color,
)
)
return True
elif etype == "risk_flagged":
expert = message.get("expert", "?")
risk_desc = message.get("risk_description", "")
phase_name = message.get("phase_name", "?")
rprint(
Panel(
f"[bold]专家:[/bold] {expert}\n"
f"[bold]阶段:[/bold] {phase_name}\n"
f"[bold]风险:[/bold] {risk_desc}",
title="[bold]⚠ 风险标记[/bold]",
border_style="yellow",
)
)
return True
except Exception:
pass # Best-effort rendering; never break orchestration
return False
async def _execute_team_cli( async def _execute_team_cli(
user_input: str, user_input: str,
gateway: "LLMGateway", gateway: "LLMGateway",
@ -565,9 +646,7 @@ async def _execute_team_cli(
expert_configs = router.resolve_expert_configs(routing.specified_experts) expert_configs = router.resolve_expert_configs(routing.specified_experts)
if not expert_configs: if not expert_configs:
rprint( rprint(f"[red]无法解析专家配置: {routing.specified_experts}[/red]")
f"[red]无法解析专家配置: {routing.specified_experts}[/red]"
)
return True return True
team = ExpertTeam(pool=agent_pool, template_registry=template_registry) team = ExpertTeam(pool=agent_pool, template_registry=template_registry)
@ -578,6 +657,10 @@ async def _execute_team_cli(
async def _event_handler(message: dict) -> None: async def _event_handler(message: dict) -> None:
"""Render orchestration events with Rich (best-effort, never raises).""" """Render orchestration events with Rich (best-effort, never raises)."""
try: try:
# U6: PM collaboration events (collaboration_contract_defined,
# collaboration_notice, review_result, risk_flagged)
if _render_pm_collaboration_event(message):
return
etype = message.get("type", "") etype = message.get("type", "")
if etype == "team_formed": if etype == "team_formed":
experts = message.get("experts", []) experts = message.get("experts", [])
@ -596,7 +679,11 @@ async def _execute_team_cli(
) )
elif etype == "plan_update": elif etype == "plan_update":
phases = message.get("plan_phases", []) phases = message.get("plan_phases", [])
icon_map = {"completed": ("", "green"), "in_progress": ("", "blue"), "failed": ("", "red")} icon_map = {
"completed": ("", "green"),
"in_progress": ("", "blue"),
"failed": ("", "red"),
}
lines = [] lines = []
for ph in phases: for ph in phases:
status = ph.get("status", "pending") status = ph.get("status", "pending")
@ -615,6 +702,12 @@ async def _execute_team_cli(
border_style="cyan", border_style="cyan",
) )
) )
# U6: render collaboration contracts embedded in phases
all_contracts: list[dict] = []
for ph in phases:
all_contracts.extend(ph.get("collaboration_contracts", []))
if all_contracts:
_render_collaboration_contracts(all_contracts)
elif etype == "phase_started": elif etype == "phase_started":
rprint( rprint(
f"\n[bold blue]▶ {message.get('phase_name', '?')}[/bold blue] " f"\n[bold blue]▶ {message.get('phase_name', '?')}[/bold blue] "
@ -769,9 +862,14 @@ def _print_help() -> None:
" [cyan]/model <name>[/cyan] — Switch LLM model\n" " [cyan]/model <name>[/cyan] — Switch LLM model\n"
" [cyan]/quit[/cyan] — Exit chat\n\n" " [cyan]/quit[/cyan] — Exit chat\n\n"
"[bold]Multi-Agent[/bold]\n\n" "[bold]Multi-Agent[/bold]\n\n"
" [magenta]@team <task>[/magenta] — 专家团协作(Lead 分解 + 专家并行 + 辩论)\n" " [magenta]@team <task>[/magenta] — 专家团协作(项目经理模式Lead 制定计划 + 协作契约 + 验收 + 辩论)\n"
" [dim]@team:dev_team <task>[/dim] — 使用 dev_team 模板\n" " [dim]@team:dev_team <task>[/dim] — 使用 dev_team 模板\n"
" [dim]@team:expert1,expert2 <task>[/dim] — 指定专家\n\n" " [dim]@team:expert1,expert2 <task>[/dim] — 指定专家\n\n"
"[bold]PM Collaboration Events (during @team)[/bold]\n\n"
" [cyan]协作契约[/cyan] — Lead 制定计划时定义专家间协作关系\n"
" [cyan]协作通知[/cyan] — 专家完成后按契约通知相关专家\n"
" [cyan]验收结果[/cyan] — Lead 验收阶段输出(通过/返工/失败)\n"
" [cyan]风险标记[/cyan] — 专家标记执行中的风险\n\n"
"[bold]Interventions (during @team)[/bold]\n\n" "[bold]Interventions (during @team)[/bold]\n\n"
" [magenta]/debate <topic>[/magenta] — 手动发起辩论\n" " [magenta]/debate <topic>[/magenta] — 手动发起辩论\n"
" [cyan]/stop[/cyan] — 终止团队执行\n" " [cyan]/stop[/cyan] — 终止团队执行\n"

View File

@ -190,3 +190,172 @@ class TestInterventionSupport:
text = captured.getvalue() text = captured.getvalue()
assert "/stop" in text assert "/stop" in text
assert "终止" in text assert "终止" in text
# ---------------------------------------------------------------------------
# U6: 项目经理模式协同事件渲染测试
# ---------------------------------------------------------------------------
class TestPMCollaborationRendering:
"""U6: 项目经理模式协同事件渲染测试"""
def _capture_render(self, message: dict) -> str:
"""辅助:渲染 PM 事件并捕获输出。"""
from agentkit.cli.chat import _render_pm_collaboration_event
captured = io.StringIO()
console = Console(file=captured, width=120)
with patch(
"agentkit.cli.chat.rprint",
side_effect=lambda *a, **kw: console.print(*a, **kw),
):
_render_pm_collaboration_event(message)
return captured.getvalue()
def test_collaboration_contract_defined_renders_panel(self):
"""collaboration_contract_defined 事件渲染为 Panel"""
message = {
"type": "collaboration_contract_defined",
"contracts": [
{
"from_expert": "backend",
"to_expert": "frontend",
"content_description": "API 定义",
"status": "pending",
},
],
}
text = self._capture_render(message)
assert "协作契约" in text
assert "backend" in text
assert "frontend" in text
assert "API 定义" in text
def test_collaboration_contract_defined_empty_contracts(self):
"""collaboration_contract_defined 空契约列表不产生输出"""
message = {"type": "collaboration_contract_defined", "contracts": []}
text = self._capture_render(message)
assert text == ""
def test_collaboration_notice_renders_colored_text(self):
"""collaboration_notice 事件渲染为带颜色的文本"""
message = {
"type": "collaboration_notice",
"from_expert": "backend",
"to_expert": "frontend",
"content_description": "API 定义已就绪",
}
text = self._capture_render(message)
assert "backend" in text
assert "frontend" in text
assert "API 定义已就绪" in text
def test_review_result_passed_renders_green(self):
"""review_result (passed=True) 渲染为绿色"""
message = {
"type": "review_result",
"phase_name": "后端开发",
"passed": True,
"feedback": "",
"expert": "backend_engineer",
}
text = self._capture_render(message)
assert "验收通过" in text
assert "后端开发" in text
def test_review_result_failed_renders_red(self):
"""review_result (passed=False) 渲染为红色"""
message = {
"type": "review_result",
"phase_name": "后端开发",
"passed": False,
"feedback": "API 缺少错误处理",
"expert": "backend_engineer",
"rework_count": 1,
}
text = self._capture_render(message)
assert "验收未通过" in text
assert "API 缺少错误处理" in text
assert "返工次数" in text
def test_risk_flagged_renders_yellow_panel(self):
"""risk_flagged 事件渲染为黄色 Panel"""
message = {
"type": "risk_flagged",
"expert": "backend_engineer",
"risk_description": "数据库连接池可能不足",
"phase_name": "后端开发",
}
text = self._capture_render(message)
assert "风险标记" in text
assert "数据库连接池可能不足" in text
assert "backend_engineer" in text
def test_missing_data_graceful_degradation(self):
"""事件数据缺失时优雅降级"""
# collaboration_notice 缺少字段 → 回退到 "?"
text = self._capture_render({"type": "collaboration_notice"})
assert "?" in text
# review_result 缺少字段 → 仍渲染(默认 failed=红色)
text = self._capture_render({"type": "review_result"})
assert "验收" in text
# risk_flagged 缺少字段 → 仍渲染
text = self._capture_render({"type": "risk_flagged"})
assert "风险标记" in text
def test_unhandled_event_returns_false(self):
"""非 PM 事件返回 False"""
from agentkit.cli.chat import _render_pm_collaboration_event
result = _render_pm_collaboration_event({"type": "team_formed"})
assert result is False
def test_pm_event_returns_true_when_handled(self):
"""PM 事件返回 True"""
from agentkit.cli.chat import _render_pm_collaboration_event
for etype in (
"collaboration_contract_defined",
"collaboration_notice",
"review_result",
"risk_flagged",
):
result = _render_pm_collaboration_event({"type": etype})
assert result is True, f"{etype} should return True"
class TestPrintHelpPMMode:
"""_print_help 包含项目经理模式说明测试"""
def test_help_includes_pm_mode(self):
"""帮助文本包含项目经理模式说明"""
from agentkit.cli.chat import _print_help
captured = io.StringIO()
console = Console(file=captured, width=120)
with patch(
"agentkit.cli.chat.rprint",
side_effect=lambda *a, **kw: console.print(*a, **kw),
):
_print_help()
text = captured.getvalue()
assert "项目经理" in text
def test_help_includes_collaboration_events(self):
"""帮助文本包含协同事件说明"""
from agentkit.cli.chat import _print_help
captured = io.StringIO()
console = Console(file=captured, width=120)
with patch(
"agentkit.cli.chat.rprint",
side_effect=lambda *a, **kw: console.print(*a, **kw),
):
_print_help()
text = captured.getvalue()
assert "协作契约" in text
assert "验收结果" in text
assert "风险标记" in text