diff --git a/backend/server/routers/system_router.py b/backend/server/routers/system_router.py index 81c1b5b96..68424477a 100644 --- a/backend/server/routers/system_router.py +++ b/backend/server/routers/system_router.py @@ -57,6 +57,8 @@ async def discovery(): "browser_login": True, "api_key_auth": True, "remote_config": True, + "agent_list": True, + "agent_show": True, "kb_upload": knowledge_enabled, "kb_list": knowledge_enabled, "kb_files": knowledge_enabled, diff --git a/backend/test/integration/api/test_system_router_api.py b/backend/test/integration/api/test_system_router_api.py index 7903c3fc3..2246d9af5 100644 --- a/backend/test/integration/api/test_system_router_api.py +++ b/backend/test/integration/api/test_system_router_api.py @@ -97,6 +97,8 @@ async def test_discovery_declares_cli_knowledge_capabilities(test_client): expected = knowledge_capability_enabled() assert capabilities["features"]["knowledge"] is expected cli_capabilities = capabilities["cli"] + assert cli_capabilities["agent_list"] is True + assert cli_capabilities["agent_show"] is True for capability in ("kb_list", "kb_files", "kb_query", "kb_open", "kb_find"): assert cli_capabilities.get(capability) is expected, capability assert "kb_parse" not in cli_capabilities diff --git a/backend/test/unit/routers/test_system_router.py b/backend/test/unit/routers/test_system_router.py index e5957308f..bada94f57 100644 --- a/backend/test/unit/routers/test_system_router.py +++ b/backend/test/unit/routers/test_system_router.py @@ -26,6 +26,8 @@ def test_discovery_endpoint_is_public(monkeypatch): assert payload["capabilities"]["features"]["knowledge"] is True assert payload["capabilities"]["cli"]["browser_login"] is True assert payload["capabilities"]["cli"]["api_key_auth"] is True + assert payload["capabilities"]["cli"]["agent_list"] is True + assert payload["capabilities"]["cli"]["agent_show"] is True assert payload["capabilities"]["cli"]["kb_upload"] is True assert payload["endpoints"]["cli_auth_sessions"] == "/api/auth/cli/sessions" assert payload["endpoints"]["readiness"] == "/api/system/ready" diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index 0f3c84279..5f19b6fa7 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -16,6 +16,7 @@ beta2 延续 beta1 的存储与数据库迁移边界。从 v0.7.1 或更早版 - Dashboard、用户管理分页、知识库 OCR 配置和数据库 schema 迁移入口完成收敛,减少大数据量和升级场景中的不一致。 - pnpm 升级到 11.24.0、uv 升级到 0.12.6,并刷新 Web、docs、backend 与 CLI 锁文件;生产依赖审计无已知漏洞。 - Vue 图标依赖从已废弃的 `lucide-vue-next` 迁移到官方 `@lucide/vue` 1.34.0,现有图标名称、尺寸和样式保持兼容。 +- CLI 新增 `yuxi agent list` 与 `yuxi agent show `:登录用户可以列出服务端授权可见的主 Agent、描述和默认标识,并查看指定 Agent 的模型、Skills、系统提示词、工具等角色过滤后的配置;命令支持 remote 选择和 JSON 输出,服务端 discovery 同步声明对应能力。 ## v0.7.2.beta1 (2026-08-23) diff --git a/docs/develop-guides/decisions/implemented/2026-08-27-cli-agent-inspection.md b/docs/develop-guides/decisions/implemented/2026-08-27-cli-agent-inspection.md new file mode 100644 index 000000000..cef7ae1bf --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-08-27-cli-agent-inspection.md @@ -0,0 +1,38 @@ +# CLI 查看可用 Agent + +状态:implemented +类型:feature +Owner:packages/yuxi-cli/src/yuxi_cli/agent.py + +## 问题 + +CLI 登录用户可以按已知 slug 调用 Agent,却无法先发现当前账号可调用的 Agent,也无法在终端核对指定 Agent 的配置。Agent 可见性与角色配置过滤已经由 `GET /api/agent`、`GET /api/agent/{slug}` 和 `AgentRepository` 拥有,CLI 不能复制权限判断或绕过服务端返回内容。 + +## 决策 + +新增 `yuxi agent list` 与 `yuxi agent show `。CLI 通过现有认证 API 读取当前用户可见的主 Agent:列表展示默认标识、名称、slug 和描述;详情展示基础信息,以及 `config_json.context` 中的模型、Skills、系统提示词、工具和其余配置。两个命令支持与知识库查询命令一致的 `--remote` 和无 ANSI 原始 `--json` 输出,并通过 discovery 中的专用能力声明拒绝不支持该契约的旧服务端。 + +CLI 不推测运行时资源解析结果。`tools`、`knowledges`、`mcps` 和 `skills` 未配置时展示为使用全部可用资源,显式空列表展示为不启用;`subagents` 未配置或为空列表时均展示为使用全部可见子 Agent。详情接口仍允许按既有授权读取可见子 Agent,列表保持服务端现有的主 Agent 范围。 + +## 替代方案 + +- 新增 CLI 专用后端路由:会复制现有 Agent 序列化与授权边界,没有新的服务端语义,拒绝。 +- CLI 拉取管理接口或本地配置后自行判断可见性:会绕过 repository 权限 Owner,拒绝。 +- 只打印完整 JSON:不满足终端用户快速识别默认 Agent 与关键配置的需求;保留为显式选项。 + +## 后果 + +Agent 可见性、详情过滤和不存在或无权访问时的 404 继续由现有后端 Owner 决定。CLI 把 slug 编码为单个 URL 路径段,仅展示服务端授权后的响应,并在所有输出模式下对畸形响应、未登录状态和缺失能力声明显式失败。人类可读输出移除服务端文本中的终端控制字符,避免远端字段改变本地终端状态。 + +人类可读输出只摘要稳定关键字段,其余字段保留在“其他配置”和 `--json` 中,避免维护平行 schema。后端扩展 Agent 查询契约时,需要同步 discovery 能力、CLI 展示与负向测试。 + +## 验证 + +- `cd packages/yuxi-cli && UV_PYTHON=3.13 uv run --group test pytest -q`:111 passed。 +- `uvx ruff format/check`(Agent 新实现与测试):通过。 +- `cd backend && UV_PYTHON=3.13 uv run --group test pytest test/unit -m "not slow"`:1590 passed。 +- API 容器 unit fallback `uv run --no-sync --group test pytest test/unit -m "not slow" -q`:1569 passed,40 skipped;常规同步命令因容器内 editable 文件权限失败。 +- 真实 integration 权限过滤测试:1 passed;隐藏 Agent 的真实 HTTP 请求返回 404。 +- `python3 scripts/verify_engineering_contracts.py` 与 `python3 -m unittest scripts.test_verify_engineering_contracts`:通过,后者 61 passed。 +- `cd docs && pnpm run build`:通过;保留既有 VitePress/Rolldown 警告。 +- 独立 Reviewer 已复核功能修复与最新 `main` 的最终 diff,无新增代码问题。 diff --git a/docs/intro/cli.md b/docs/intro/cli.md index 6ecf71152..ce83b8f96 100644 --- a/docs/intro/cli.md +++ b/docs/intro/cli.md @@ -96,6 +96,22 @@ yuxi chat --remote production --no-open 关闭终端中的进程后,本地页面也会停止。当前页面支持纯文本对话、新建会话、`/state` 查看线程状态和 `/approve` 继续工具审批;附件和 `ask_user_question` 仍需使用正式 Web 界面。 +## 查看可用 Agent + +列出当前账号有权调用的主 Agent: + +```bash +yuxi agent list +``` + +列表中的 `*` 表示默认 Agent。使用 slug 查看服务端已授权返回的详细配置,包括绑定的模型、Skills、系统提示词、工具、MCP、知识库和子 Agent: + +```bash +yuxi agent show default-chatbot +``` + +未显式绑定的资源显示为“默认(全部可用)”。`tools`、`knowledges`、`mcps` 和 `skills` 的显式空列表显示为“无”;`subagents` 的空列表仍按服务端契约显示为“默认(全部可用)”。这两条命令都支持 `--remote ` 切换实例,以及 `--json` 输出完整服务端响应。 + ## 上传知识库文件 上传需要当前账号可以管理知识库。省略 `--kb-id` 时,CLI 会列出当前实例中支持文档上传的知识库供选择: diff --git a/packages/yuxi-cli/README.md b/packages/yuxi-cli/README.md index f9d6bec30..5ff9662cd 100644 --- a/packages/yuxi-cli/README.md +++ b/packages/yuxi-cli/README.md @@ -10,5 +10,6 @@ First-stage scope: - `whoami`, `status`, and `logout` - server discovery and compatibility check for Yuxi `>=0.7.1` - `yuxi chat` for a temporary local browser chat with streamed Agent output; `/state` reads thread state and `/approve` resumes a pending tool approval +- `yuxi agent list` and `yuxi agent show ` for inspecting agents visible to the logged-in user - `yuxi kb upload` for knowledge base file uploads - `yuxi agent eval` for running existing Langfuse dataset experiments with a logged-in remote diff --git a/packages/yuxi-cli/src/yuxi_cli/agent.py b/packages/yuxi-cli/src/yuxi_cli/agent.py new file mode 100644 index 000000000..e78a64257 --- /dev/null +++ b/packages/yuxi-cli/src/yuxi_cli/agent.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from typing import Any + +from rich.console import Console +from rich.table import Table +from rich.text import Text + +from yuxi_cli.client import YuxiClient +from yuxi_cli.config import ConfigStore, Remote +from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible + + +class AgentError(Exception): + """Agent 查询命令错误。""" + + +_TERMINAL_CONTROL_TRANSLATION = { + codepoint: None + for codepoint in (*range(32), 127, *range(128, 160)) + if codepoint not in (9, 10) +} + + +def run_agent_list( + store: ConfigStore, + remote_name: str | None, + console: Console, + *, + as_json: bool = False, + client_factory: type[YuxiClient] = YuxiClient, +) -> dict: + """列出当前用户可调用的主 Agent。""" + remote = _require_remote(store, remote_name) + with client_factory(remote) as client: + _ensure_capability(client, "cli.agent_list") + data = client.list_agents() + _render_agent_list(data, console, as_json=as_json) + return data + + +def run_agent_show( + store: ConfigStore, + remote_name: str | None, + agent_slug: str, + console: Console, + *, + as_json: bool = False, + client_factory: type[YuxiClient] = YuxiClient, +) -> dict: + """展示当前用户可见的指定 Agent 配置。""" + remote = _require_remote(store, remote_name) + with client_factory(remote) as client: + _ensure_capability(client, "cli.agent_show") + data = client.get_agent(agent_slug) + _render_agent_detail(data, console, as_json=as_json) + return data + + +def _require_remote(store: ConfigStore, remote_name: str | None) -> Remote: + """返回已登录的 remote。""" + remote = store.load().get_remote(remote_name) + if not remote.api_key: + raise AgentError(f"remote 尚未登录: {remote.name}") + return remote + + +def _ensure_capability(client: YuxiClient, capability: str) -> None: + """确认服务端声明了命令所需能力。""" + try: + ensure_server_compatible(client.discovery(), capability) + except ServerCompatibilityError as exc: + raise AgentError(str(exc)) from exc + + +def _render_agent_list(data: dict, console: Console, *, as_json: bool) -> None: + """渲染 Agent 列表或原始 JSON。""" + agents = _validate_agent_list(data) + if as_json: + _print_json(data, console) + return + + if not agents: + console.print("没有可调用的 Agent") + return + + table = Table(show_header=True, header_style="bold") + table.add_column("Default", width=7, justify="center") + table.add_column("Name") + table.add_column("Slug") + table.add_column("Description") + for agent in agents: + table.add_row( + "*" if agent.get("is_default") else "", + _text(agent.get("name")), + _text(agent.get("slug") or agent.get("agent_id")), + _text(agent.get("description")), + ) + console.print(table) + + +def _render_agent_detail(data: dict, console: Console, *, as_json: bool) -> None: + """渲染 Agent 详情或原始 JSON。""" + agent, config_json, context = _validate_agent_detail(data) + if as_json: + _print_json(data, console) + return + + details = Table(show_header=False, box=None, pad_edge=False) + details.add_column(style="bold", no_wrap=True) + details.add_column() + details.add_row("Name", _text(agent.get("name"))) + details.add_row("Slug", _text(agent.get("slug") or agent.get("agent_id"))) + details.add_row("Description", _text(agent.get("description"))) + details.add_row("Default", "yes" if agent.get("is_default") else "no") + details.add_row("Backend", _text(agent.get("backend_id"))) + details.add_row("Model", _text(context.get("model"), default="系统默认")) + details.add_row("Skills", _selection(context.get("skills"))) + details.add_row("Tools", _selection(context.get("tools"))) + details.add_row("MCP servers", _selection(context.get("mcps"))) + details.add_row("Knowledge bases", _selection(context.get("knowledges"))) + details.add_row( + "Subagents", + _selection(context.get("subagents"), empty_means_default=True), + ) + console.print(details) + + console.print("\n[bold]System prompt[/bold]") + console.print(_text(context.get("system_prompt"))) + + known_fields = { + "model", + "skills", + "tools", + "mcps", + "knowledges", + "subagents", + "system_prompt", + } + other_context = { + key: value for key, value in context.items() if key not in known_fields + } + other_config = { + key: value for key, value in config_json.items() if key != "context" + } + if other_context or other_config: + console.print("\n[bold]Other configuration[/bold]") + _print_json({"context": other_context, **other_config}, console) + + +def _selection(value: Any, *, empty_means_default: bool = False) -> Text: + """区分默认资源范围、显式空列表与具体选择。""" + if value is None or (empty_means_default and value == []): + return Text("默认(全部可用)") + if isinstance(value, list): + value = ", ".join(str(item) for item in value) if value else "无" + return Text(_safe_terminal_text(str(value))) + + +def _text(value: Any, *, default: str = "-") -> Text: + """把服务端字段转为不含终端控制字符的纯文本。""" + text = _safe_terminal_text(str(value)).strip() if value is not None else "" + return Text(text or default) + + +def _safe_terminal_text(value: str) -> str: + """移除可改变终端状态的 C0、DEL 与 C1 控制字符。""" + return value.translate(_TERMINAL_CONTROL_TRANSLATION) + + +def _validate_agent_list(data: dict) -> list[dict]: + """校验 Agent 列表响应的最小结构。""" + agents = data.get("agents") + if not isinstance(agents, list) or any( + not isinstance(agent, dict) for agent in agents + ): + raise AgentError("远程 Agent 列表响应格式无效") + return agents + + +def _validate_agent_detail(data: dict) -> tuple[dict, dict, dict]: + """校验 Agent 详情响应并返回渲染所需结构。""" + agent = data.get("agent") + if not isinstance(agent, dict): + raise AgentError("远程 Agent 详情响应格式无效") + config_json = agent.get("config_json") + if config_json is None: + config_json = {} + if not isinstance(config_json, dict): + raise AgentError("远程 Agent 配置响应格式无效") + context = config_json.get("context") + if context is None: + context = {} + if not isinstance(context, dict): + raise AgentError("远程 Agent context 响应格式无效") + return agent, config_json, context + + +def _print_json(data: dict, console: Console) -> None: + """输出保留中文的 JSON。""" + console.file.write(json.dumps(data, ensure_ascii=False, default=str) + "\n") diff --git a/packages/yuxi-cli/src/yuxi_cli/client.py b/packages/yuxi-cli/src/yuxi_cli/client.py index e4d499acd..4bda90eba 100644 --- a/packages/yuxi-cli/src/yuxi_cli/client.py +++ b/packages/yuxi-cli/src/yuxi_cli/client.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from urllib.parse import urlencode +from urllib.parse import quote, urlencode import httpx @@ -112,6 +112,14 @@ def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict) -> def list_external_databases(self) -> dict: return self._request("GET", "/knowledge/databases/external") + def list_agents(self) -> dict: + """读取当前用户可调用的主 Agent。""" + return self._request("GET", "/agent") + + def get_agent(self, agent_slug: str) -> dict: + """按 slug 读取当前用户可见的 Agent 配置。""" + return self._request("GET", f"/agent/{quote(agent_slug, safe='')}") + def list_external_files( self, kb_id: str, diff --git a/packages/yuxi-cli/src/yuxi_cli/main.py b/packages/yuxi-cli/src/yuxi_cli/main.py index cc7c84a0d..666a4d464 100644 --- a/packages/yuxi-cli/src/yuxi_cli/main.py +++ b/packages/yuxi-cli/src/yuxi_cli/main.py @@ -6,6 +6,7 @@ from rich.console import Console from yuxi_cli import __version__ +from yuxi_cli.agent import AgentError, run_agent_list, run_agent_show from yuxi_cli.agent_eval import AgentEvalError, AgentEvalOptions, run_langfuse_agent_experiment from yuxi_cli.chat_web import ChatWebError, run_web_chat from yuxi_cli.client import ClientError @@ -370,3 +371,34 @@ def eval_agent( run_langfuse_agent_experiment(store, remote, options, console) except (ConfigError, ClientError, AgentEvalError) as exc: _handle_error(exc) + + +@agent_app.command("list") +def list_agents( + remote: str | None = typer.Option(None, "--remote", help="Remote name."), + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +): + """List agents visible to the current user.""" + store = _store() + try: + if not as_json: + _print_remote_context(store, remote) + run_agent_list(store, remote, console, as_json=as_json) + except (ConfigError, ClientError, AgentError) as exc: + _handle_error(exc) + + +@agent_app.command("show") +def show_agent( + agent_slug: str = typer.Argument(..., help="Yuxi agent slug."), + remote: str | None = typer.Option(None, "--remote", help="Remote name."), + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +): + """Show one visible agent and its configuration.""" + store = _store() + try: + if not as_json: + _print_remote_context(store, remote) + run_agent_show(store, remote, agent_slug, console, as_json=as_json) + except (ConfigError, ClientError, AgentError) as exc: + _handle_error(exc) diff --git a/packages/yuxi-cli/tests/test_agent_commands.py b/packages/yuxi-cli/tests/test_agent_commands.py new file mode 100644 index 000000000..a521457df --- /dev/null +++ b/packages/yuxi-cli/tests/test_agent_commands.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import ClassVar + +import pytest +from rich.console import Console + +from yuxi_cli.agent import AgentError, run_agent_list, run_agent_show +from yuxi_cli.client import ClientError +from yuxi_cli.config import ConfigStore +from yuxi_cli.discovery import MIN_SERVER_VERSION + + +class FakeAgentClient: + """记录 Agent 查询并返回固定的可见配置。""" + + omit_caps: ClassVar[set[str]] = set() + calls: ClassVar[list[tuple[str, tuple]]] = [] + + def __init__(self, remote): + self.remote = remote + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return None + + @classmethod + def reset(cls) -> None: + cls.omit_caps = set() + cls.calls = [] + + def discovery(self): + capabilities = {"agent_list": True, "agent_show": True} + for name in self.omit_caps: + capabilities.pop(name, None) + return {"version": MIN_SERVER_VERSION, "capabilities": {"cli": capabilities}} + + def list_agents(self): + self.calls.append(("list_agents", ())) + return { + "agents": [ + { + "name": "默认助手", + "slug": "default-chatbot", + "description": "通用 Agent", + "is_default": True, + }, + { + "name": "Research [red]Agent[/red]", + "slug": "research-agent", + "description": "深度研究", + "is_default": False, + }, + ] + } + + def get_agent(self, agent_slug): + self.calls.append(("get_agent", (agent_slug,))) + return { + "agent": { + "name": "研究助手", + "slug": agent_slug, + "description": "调研与核验", + "backend_id": "ChatbotAgent", + "is_default": False, + "config_json": { + "context": { + "model": "openai:gpt-5", + "skills": ["deep-research"], + "tools": ["web_search", "read_file"], + "mcps": [], + "subagents": [], + "system_prompt": "先核验证据。\n再给结论。", + "max_execution_steps": 100, + } + }, + } + } + + +def _console(*, force_terminal: bool = False) -> Console: + return Console( + file=io.StringIO(), + force_terminal=force_terminal, + width=140, + highlight=False, + ) + + +def _store(tmp_path: Path, *, authenticated: bool = True) -> ConfigStore: + store = ConfigStore(tmp_path / "config.toml") + if authenticated: + config = store.load() + config.get_remote("local").api_key = "yxkey_test" + store.save(config) + return store + + +def _output(console: Console) -> str: + return console.file.getvalue() + + +@pytest.fixture(autouse=True) +def _reset_fake(): + FakeAgentClient.reset() + yield + FakeAgentClient.reset() + + +def test_agent_list_renders_visible_agents_and_default_marker(tmp_path): + console = _console() + + run_agent_list(_store(tmp_path), None, console, client_factory=FakeAgentClient) + + output = _output(console) + assert "default-chatbot" in output + assert "通用 Agent" in output + assert "*" in output + assert "Research [red]Agent[/red]" in output + assert FakeAgentClient.calls == [("list_agents", ())] + + +def test_agent_list_json_outputs_server_payload(tmp_path): + console = _console(force_terminal=True) + + run_agent_list( + _store(tmp_path), None, console, as_json=True, client_factory=FakeAgentClient + ) + + output = _output(console) + assert "\x1b" not in output + assert json.loads(output)["agents"][0]["is_default"] is True + + +def test_agent_list_human_output_removes_terminal_control_sequences(tmp_path): + class UnsafeClient(FakeAgentClient): + def list_agents(self): + return { + "agents": [ + { + "name": "\x1b]8;;https://example.test\x07Agent\x1b]8;;\x07", + "slug": "unsafe", + "description": "\x1b[31mred\x1b[0m", + } + ] + } + + console = _console(force_terminal=True) + run_agent_list(_store(tmp_path), None, console, client_factory=UnsafeClient) + + output = _output(console) + assert "\x1b" not in output + assert "\x07" not in output + assert "Agent" in output + + +def test_agent_list_reports_empty_result(tmp_path): + class EmptyClient(FakeAgentClient): + def list_agents(self): + return {"agents": []} + + console = _console() + run_agent_list(_store(tmp_path), None, console, client_factory=EmptyClient) + + assert "没有可调用的 Agent" in _output(console) + + +def test_agent_show_renders_key_and_remaining_configuration(tmp_path): + console = _console() + + run_agent_show( + _store(tmp_path), + None, + "research-agent", + console, + client_factory=FakeAgentClient, + ) + + output = _output(console) + assert "openai:gpt-5" in output + assert "deep-research" in output + assert "web_search, read_file" in output + mcp_line = next(line for line in output.splitlines() if "MCP servers" in line) + subagents_line = next(line for line in output.splitlines() if "Subagents" in line) + assert "无" in mcp_line + assert "默认(全部可用)" in subagents_line + assert "先核验证据。\n再给结论。" in output + assert '"max_execution_steps": 100' in output + assert FakeAgentClient.calls == [("get_agent", ("research-agent",))] + + +def test_agent_show_json_outputs_server_payload(tmp_path): + console = _console(force_terminal=True) + + run_agent_show( + _store(tmp_path), + None, + "research-agent", + console, + as_json=True, + client_factory=FakeAgentClient, + ) + + output = _output(console) + assert "\x1b" not in output + agent = json.loads(output)["agent"] + assert "config_json" in agent + assert "system_prompt" in agent["config_json"]["context"] + + +def test_agent_commands_require_login(tmp_path): + with pytest.raises(AgentError, match="尚未登录"): + run_agent_list( + _store(tmp_path, authenticated=False), + None, + _console(), + client_factory=FakeAgentClient, + ) + + +@pytest.mark.parametrize( + ("command", "capability"), [("list", "agent_list"), ("show", "agent_show")] +) +def test_agent_commands_reject_missing_server_capability(tmp_path, command, capability): + FakeAgentClient.omit_caps = {capability} + + with pytest.raises(AgentError, match=f"cli.{capability}"): + if command == "list": + run_agent_list( + _store(tmp_path), None, _console(), client_factory=FakeAgentClient + ) + else: + run_agent_show( + _store(tmp_path), + None, + "research-agent", + _console(), + client_factory=FakeAgentClient, + ) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ({"agent": []}, "详情响应格式无效"), + ({"agent": {"config_json": []}}, "配置响应格式无效"), + ({"agent": {"config_json": {"context": []}}}, "context 响应格式无效"), + ], +) +def test_agent_show_rejects_malformed_payload(tmp_path, payload, message): + class InvalidClient(FakeAgentClient): + def get_agent(self, _agent_slug): + return payload + + with pytest.raises(AgentError, match=message): + run_agent_show( + _store(tmp_path), + None, + "research-agent", + _console(), + client_factory=InvalidClient, + ) + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_agent_list_rejects_malformed_payload_in_all_output_modes(tmp_path, as_json): + class InvalidClient(FakeAgentClient): + def list_agents(self): + return {"agents": {"slug": "wrong"}} + + with pytest.raises(AgentError, match="列表响应格式无效"): + run_agent_list( + _store(tmp_path), + None, + _console(), + as_json=as_json, + client_factory=InvalidClient, + ) + + +def test_agent_show_json_rejects_malformed_payload(tmp_path): + class InvalidClient(FakeAgentClient): + def get_agent(self, _agent_slug): + return {"agent": {"config_json": {"context": []}}} + + with pytest.raises(AgentError, match="context 响应格式无效"): + run_agent_show( + _store(tmp_path), + None, + "research-agent", + _console(), + as_json=True, + client_factory=InvalidClient, + ) + + +def test_agent_show_preserves_not_found_error(tmp_path): + class MissingClient(FakeAgentClient): + def get_agent(self, _agent_slug): + raise ClientError("智能体不存在", status_code=404) + + with pytest.raises(ClientError, match="智能体不存在") as exc_info: + run_agent_show( + _store(tmp_path), + None, + "hidden-agent", + _console(), + client_factory=MissingClient, + ) + + assert exc_info.value.status_code == 404 diff --git a/packages/yuxi-cli/tests/test_client.py b/packages/yuxi-cli/tests/test_client.py index 6ade2e51e..b21c86f50 100644 --- a/packages/yuxi-cli/tests/test_client.py +++ b/packages/yuxi-cli/tests/test_client.py @@ -1,8 +1,9 @@ from __future__ import annotations import httpx +import pytest -from yuxi_cli.client import YuxiClient, _iter_sse_events +from yuxi_cli.client import ClientError, YuxiClient, _iter_sse_events from yuxi_cli.config import Remote @@ -124,6 +125,63 @@ def test_list_external_databases_uses_external_path(monkeypatch): assert calls[-1]["path"] == "/knowledge/databases/external" +def test_list_agents_uses_visible_agent_path(monkeypatch): + client, calls = _patched_client(monkeypatch) + try: + client.list_agents() + finally: + client.close() + assert calls[-1]["method"] == "GET" + assert calls[-1]["path"] == "/agent" + + +def test_get_agent_uses_slug_path(monkeypatch): + client, calls = _patched_client(monkeypatch) + try: + client.get_agent("research-agent") + finally: + client.close() + assert calls[-1]["method"] == "GET" + assert calls[-1]["path"] == "/agent/research-agent" + + +def test_get_agent_keeps_slug_in_one_path_segment(): + remote = Remote(name="local", url="http://localhost:5173", api_key="yxkey_test") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.raw_path == b"/api/agent/..%2Fsystem%2Finfo%3Ffull%3Dtrue" + return httpx.Response(404, json={"detail": "智能体不存在"}) + + client = YuxiClient(remote) + client.client.close() + client.client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(ClientError, match="智能体不存在"): + client.get_agent("../system/info?full=true") + finally: + client.close() + + +def test_get_agent_preserves_server_not_found_response(): + remote = Remote(name="local", url="http://localhost:5173", api_key="yxkey_test") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/agent/hidden-agent" + assert request.headers["Authorization"] == "Bearer yxkey_test" + return httpx.Response(404, json={"detail": "智能体不存在"}) + + client = YuxiClient(remote) + client.client.close() + client.client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(ClientError, match="智能体不存在") as exc_info: + client.get_agent("hidden-agent") + finally: + client.close() + + assert exc_info.value.status_code == 404 + + def test_list_external_files_passes_query_params(monkeypatch): client, calls = _patched_client(monkeypatch) try: diff --git a/packages/yuxi-cli/tests/test_main.py b/packages/yuxi-cli/tests/test_main.py index b4dc960f4..9472a5178 100644 --- a/packages/yuxi-cli/tests/test_main.py +++ b/packages/yuxi-cli/tests/test_main.py @@ -23,6 +23,22 @@ def test_agent_eval_help_is_registered(): assert "--auth-token" not in output +def test_agent_inspection_commands_are_registered(): + result = CliRunner().invoke(app, ["agent", "--help"]) + output = Text.from_ansi(result.output).plain + + assert result.exit_code == 0 + assert "list" in output + assert "show" in output + assert "eval" in output + + show_help = CliRunner().invoke(app, ["agent", "show", "--help"]) + show_output = Text.from_ansi(show_help.output).plain + assert show_help.exit_code == 0 + assert "agent_slug" in show_output + assert "--json" in show_output + + def test_kb_upload_help_is_registered(): result = CliRunner().invoke(app, ["kb", "upload", "--help"]) output = Text.from_ansi(result.output).plain