From 6016c087fe77ea44d4e57d183ee36e9730eb8f4e Mon Sep 17 00:00:00 2001 From: chiguyong Date: Wed, 24 Jun 2026 14:56:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20U6=20CLI=20=E5=8D=8F=E5=90=8C?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=20Rich=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/agentkit/cli/chat.py | 108 +++++++++++++++- tests/unit/cli/test_chat_multiagent.py | 169 +++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 5 deletions(-) diff --git a/src/agentkit/cli/chat.py b/src/agentkit/cli/chat.py index 603f33e..511e33d 100644 --- a/src/agentkit/cli/chat.py +++ b/src/agentkit/cli/chat.py @@ -525,6 +525,87 @@ def _resolve_default_model(server_config: "ServerConfig") -> str: 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( user_input: str, gateway: "LLMGateway", @@ -565,9 +646,7 @@ async def _execute_team_cli( expert_configs = router.resolve_expert_configs(routing.specified_experts) if not expert_configs: - rprint( - f"[red]无法解析专家配置: {routing.specified_experts}[/red]" - ) + rprint(f"[red]无法解析专家配置: {routing.specified_experts}[/red]") return True 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: """Render orchestration events with Rich (best-effort, never raises).""" 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", "") if etype == "team_formed": experts = message.get("experts", []) @@ -596,7 +679,11 @@ async def _execute_team_cli( ) elif etype == "plan_update": phases = message.get("plan_phases", []) - icon_map = {"completed": ("✓", "green"), "in_progress": ("▶", "blue"), "failed": ("✗", "red")} + icon_map = { + "completed": ("✓", "green"), + "in_progress": ("▶", "blue"), + "failed": ("✗", "red"), + } lines = [] for ph in phases: status = ph.get("status", "pending") @@ -615,6 +702,12 @@ async def _execute_team_cli( 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": rprint( f"\n[bold blue]▶ {message.get('phase_name', '?')}[/bold blue] " @@ -769,9 +862,14 @@ def _print_help() -> None: " [cyan]/model [/cyan] — Switch LLM model\n" " [cyan]/quit[/cyan] — Exit chat\n\n" "[bold]Multi-Agent[/bold]\n\n" - " [magenta]@team [/magenta] — 专家团协作(Lead 分解 + 专家并行 + 辩论)\n" + " [magenta]@team [/magenta] — 专家团协作(项目经理模式:Lead 制定计划 + 协作契约 + 验收 + 辩论)\n" " [dim]@team:dev_team [/dim] — 使用 dev_team 模板\n" " [dim]@team:expert1,expert2 [/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" " [magenta]/debate [/magenta] — 手动发起辩论\n" " [cyan]/stop[/cyan] — 终止团队执行\n" diff --git a/tests/unit/cli/test_chat_multiagent.py b/tests/unit/cli/test_chat_multiagent.py index 855dcbf..76d33fd 100644 --- a/tests/unit/cli/test_chat_multiagent.py +++ b/tests/unit/cli/test_chat_multiagent.py @@ -190,3 +190,172 @@ class TestInterventionSupport: text = captured.getvalue() assert "/stop" 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