diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 52498fdc..7ff50ab6 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -4,6 +4,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field from sqlmodel import Session, select from app.agents.branching import ( @@ -22,6 +23,8 @@ from app.capability_scope import normalize_capability_scope from app.db import get_session from app.db.models import ( + A2ATaskEvent, + A2ATaskRun, AgentEvent, AgentProfile, AgentResourceBinding, @@ -79,6 +82,43 @@ MCP_APP_RESOURCE_MAX_BYTES = 10 * 1024 * 1024 +class A2ATaskEventRead(BaseModel): + sequence: int + event_type: str + data: dict[str, Any] = Field(default_factory=dict) + created_at: str + + +class A2ATaskRunRead(BaseModel): + id: str + direction: str + remote_task_id: str | None = None + context_id: str | None = None + codex_session_id: str | None = None + status: str + endpoint_url: str + protocol_version: str + cancel_requested: bool + recovery_attempts: int + artifacts: list[dict[str, Any]] = Field(default_factory=list) + error: dict[str, Any] = Field(default_factory=dict) + created_at: str + started_at: str | None = None + finished_at: str | None = None + updated_at: str + events: list[A2ATaskEventRead] = Field(default_factory=list) + + +class CodexA2AAdapterRead(BaseModel): + enabled: bool + endpoint_url: str + agent_card_url: str + command: str + workspace_root: str + timeout_seconds: float + token_configured: bool + + def tool_read(row: Tool, metadata: dict[str, Any] | None = None) -> ToolRead: config = dict(row.config_json or {}) return ToolRead( @@ -317,6 +357,97 @@ def probe_tool( ) +@router.get( + "/a2a/codex-adapter", + response_model=CodexA2AAdapterRead, + dependencies=[Depends(require_agent_scope_viewer)], +) +def get_codex_a2a_adapter() -> CodexA2AAdapterRead: + settings = get_settings() + return CodexA2AAdapterRead( + enabled=bool(settings.codex_a2a_enabled), + endpoint_url="/api/a2a/codex", + agent_card_url="/.well-known/agent-card.json", + command=settings.codex_a2a_command, + workspace_root=settings.codex_a2a_workspace_root, + timeout_seconds=settings.codex_a2a_timeout_seconds, + token_configured=bool(settings.codex_a2a_token), + ) + + +@router.get( + "/{tool_id}/a2a-runs", + response_model=list[A2ATaskRunRead], + dependencies=[Depends(require_agent_scope_viewer)], +) +def list_a2a_task_runs( + tool_id: str, + tenant_id: str = Query(...), + agent_id: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_session), +) -> list[A2ATaskRunRead]: + row = _get_tool(db, tenant_id, tool_id) + _ensure_tool_visible(db, tenant_id, row, agent_id) + if row.tool_type != "a2a": + raise HTTPException(status_code=400, detail="仅 A2A 工具包含持久化任务记录") + runs = list( + db.exec( + select(A2ATaskRun) + .where( + A2ATaskRun.tenant_id == tenant_id, + A2ATaskRun.tool_id == tool_id, + ) + .order_by(A2ATaskRun.created_at.desc()) + .limit(limit) + ).all() + ) + if not runs: + return [] + events_by_run: dict[str, list[A2ATaskEventRead]] = {run.id: [] for run in runs} + events = list( + db.exec( + select(A2ATaskEvent) + .where(A2ATaskEvent.run_id.in_([run.id for run in runs])) + .order_by(A2ATaskEvent.run_id, A2ATaskEvent.sequence) + ).all() + ) + for event in events: + events_by_run.setdefault(event.run_id, []).append( + A2ATaskEventRead( + sequence=event.sequence, + event_type=event.event_type, + data=dict(event.data_json or {}), + created_at=event.created_at.isoformat(), + ) + ) + return [_a2a_task_run_read(run, events_by_run.get(run.id, [])) for run in runs] + + +@router.post("/{tool_id}/a2a-runs/{run_id}:cancel", response_model=A2ATaskRunRead) +def cancel_a2a_task_run( + tool_id: str, + run_id: str, + tenant_id: str = Query(...), + agent_id: str | None = Query(default=None), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> A2ATaskRunRead: + row = _get_tool(db, tenant_id, tool_id) + ensure_agent_scope_manager(db, tenant_id, agent_id, current_user) + _ensure_tool_visible(db, tenant_id, row, agent_id) + run = db.get(A2ATaskRun, run_id) + if run is None or run.tenant_id != tenant_id or run.tool_id != tool_id: + raise HTTPException(status_code=404, detail="A2A 任务不存在") + if run.status not in {"completed", "failed", "canceled", "cancelled", "rejected"}: + run.cancel_requested = True + run.updated_at = utc_now() + db.add(run) + db.commit() + db.refresh(run) + return _a2a_task_run_read(run, []) + + @router.get( "/{tool_id}", response_model=ToolRead, dependencies=[Depends(require_agent_scope_viewer)] ) @@ -470,6 +601,31 @@ def _get_tool(db: Session, tenant_id: str, tool_id: str) -> Tool: return row +def _a2a_task_run_read( + run: A2ATaskRun, + events: list[A2ATaskEventRead], +) -> A2ATaskRunRead: + return A2ATaskRunRead( + id=run.id, + direction=run.direction, + remote_task_id=run.remote_task_id, + context_id=run.context_id, + codex_session_id=run.codex_session_id, + status=run.status, + endpoint_url=run.endpoint_url, + protocol_version=run.protocol_version, + cancel_requested=run.cancel_requested, + recovery_attempts=run.recovery_attempts, + artifacts=list(run.artifacts_json or []), + error=dict(run.error_json or {}), + created_at=run.created_at.isoformat(), + started_at=run.started_at.isoformat() if run.started_at else None, + finished_at=run.finished_at.isoformat() if run.finished_at else None, + updated_at=run.updated_at.isoformat(), + events=events, + ) + + def _visible_tool_rows( db: Session, tenant_id: str, diff --git a/backend/app/tools/mcp_client.py b/backend/app/tools/mcp_client.py index 3d36daf9..89564959 100644 --- a/backend/app/tools/mcp_client.py +++ b/backend/app/tools/mcp_client.py @@ -264,9 +264,43 @@ def list_tools(self) -> list[dict[str, Any]]: def list_tools_with_capabilities(self) -> tuple[list[dict[str, Any]], dict[str, Any]]: with self: self._initialize() - result = self._request("tools/list", {}) - tools = result.get("tools") if isinstance(result, dict) else None - return tools if isinstance(tools, list) else [], dict(self.initialize_result) + tools = self._list_all_pages("tools/list", "tools") + return tools, dict(self.initialize_result) + + def _list_all_pages(self, method: str, result_key: str) -> list[dict[str, Any]]: + """Collect a cursor-paginated MCP list result. + + MCP servers may paginate ``tools/list`` even when the first version of a + server returned every tool in one response. Discovery must therefore + follow ``nextCursor`` on every refresh; otherwise tools added later can + remain permanently invisible once they move beyond the first page. + """ + + items: list[dict[str, Any]] = [] + cursor: str | None = None + seen_cursors: set[str] = set() + while True: + params = {"cursor": cursor} if cursor is not None else {} + result = self._request(method, params) + if not isinstance(result, dict): + raise MCPClientError(f"MCP {method} 返回内容不是 object。") + page = result.get(result_key) + if page is not None and not isinstance(page, list): + raise MCPClientError(f"MCP {method} 的 {result_key} 不是数组。") + items.extend(item for item in (page or []) if isinstance(item, dict)) + + raw_cursor = result.get("nextCursor") + if raw_cursor is None: + # Tolerate snake_case implementations while keeping the MCP + # specification's nextCursor as the canonical form. + raw_cursor = result.get("next_cursor") + next_cursor = str(raw_cursor).strip() if raw_cursor is not None else "" + if not next_cursor: + return items + if next_cursor in seen_cursors: + raise MCPClientError(f"MCP {method} 返回了重复分页游标:{next_cursor}") + seen_cursors.add(next_cursor) + cursor = next_cursor def read_resource(self, uri: str) -> dict[str, Any]: with self: diff --git a/backend/tests/test_enterprise_auth_guards.py b/backend/tests/test_enterprise_auth_guards.py index ee764d09..ee70b33f 100644 --- a/backend/tests/test_enterprise_auth_guards.py +++ b/backend/tests/test_enterprise_auth_guards.py @@ -42,7 +42,9 @@ def test_enterprise_read_endpoints_require_authentication() -> None: "/api/enterprise/memories?tenant_id=tenant_demo", "/api/enterprise/tools?tenant_id=tenant_demo", "/api/enterprise/tools/buckets?tenant_id=tenant_demo", + "/api/enterprise/tools/a2a/codex-adapter", "/api/enterprise/tools/tool_demo?tenant_id=tenant_demo", + "/api/enterprise/tools/tool_demo/a2a-runs?tenant_id=tenant_demo", "/api/enterprise/mcp-servers?tenant_id=tenant_demo", "/api/enterprise/mcp-servers/server_demo?tenant_id=tenant_demo", "/api/enterprise/general-skills?tenant_id=tenant_demo", diff --git a/backend/tests/test_mcp_client_stdio.py b/backend/tests/test_mcp_client_stdio.py index 9cbb8253..031ce534 100644 --- a/backend/tests/test_mcp_client_stdio.py +++ b/backend/tests/test_mcp_client_stdio.py @@ -12,6 +12,7 @@ from app.tools.mcp_client import ( MCPClientError, + _MCPSession, _PipeReader, _read_response, _send_json, @@ -22,11 +23,50 @@ ) +class _PagedToolSession(_MCPSession): + def __init__(self, *, repeat_cursor: bool = False) -> None: + super().__init__({}, timeout_seconds=1) + self.repeat_cursor = repeat_cursor + self.requests: list[tuple[str, dict[str, object]]] = [] + + def _request(self, method: str, params: dict[str, object]): # type: ignore[override] + self.requests.append((method, params)) + if method == "initialize": + return {"capabilities": {"tools": {"listChanged": True}}} + if method != "tools/list": + raise AssertionError(f"unexpected method: {method}") + if params.get("cursor") is None: + return {"tools": [{"name": "existing"}], "nextCursor": "page-2"} + return { + "tools": [{"name": "newly_added"}], + "nextCursor": "page-2" if self.repeat_cursor else None, + } + + def _notify(self, method: str, params: dict[str, object]) -> None: # type: ignore[override] + self.requests.append((method, params)) + + class _WindowsAnonymousPipe(io.StringIO): def fileno(self) -> int: raise OSError(10038, "在一个非套接字上尝试了一个操作") +def test_tools_list_discovers_new_tools_from_all_cursor_pages() -> None: + session = _PagedToolSession() + + tools, initialize_result = session.list_tools_with_capabilities() + + assert [tool["name"] for tool in tools] == ["existing", "newly_added"] + assert initialize_result["capabilities"]["tools"]["listChanged"] is True + assert ("tools/list", {}) in session.requests + assert ("tools/list", {"cursor": "page-2"}) in session.requests + + +def test_tools_list_rejects_repeated_cursor_instead_of_looping() -> None: + with pytest.raises(MCPClientError, match="重复分页游标"): + _PagedToolSession(repeat_cursor=True).list_tools_with_capabilities() + + class _FakeProcess: def __init__(self, exit_code: int | None = None) -> None: self.exit_code = exit_code diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index b14f2c39..3b1ad1c8 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -14,14 +14,25 @@ _normalize_probe_url, _read_execution_policy, _tool_config, + cancel_a2a_task_run, delete_tool, + get_codex_a2a_adapter, + list_a2a_task_runs, list_tools, ) from app.api.tools import ( probe_tool as _probe_tool, ) from app.config import get_settings -from app.db.models import AgentProfile, AgentResourceBinding, Tenant, Tool, User +from app.db.models import ( + A2ATaskEvent, + A2ATaskRun, + AgentProfile, + AgentResourceBinding, + Tenant, + Tool, + User, +) from app.security.internal_service import INTERNAL_SERVICE_HEADER, internal_service_token from app.tools.tool_schema import ToolExecutionPolicy, ToolProbeRequest @@ -66,6 +77,89 @@ def test_delete_tool_removes_tenant_tool() -> None: assert db.get(Tool, tool.id) is None +def test_a2a_run_listing_includes_events_and_cancel_is_persisted() -> None: + with _test_session() as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add( + AgentProfile( + id="agent_overall", tenant_id="tenant_demo", name="开放广场", is_overall=True + ) + ) + tool = Tool( + id="tool_a2a", + tenant_id="tenant_demo", + name="codex.remote", + display_name="Codex", + tool_type="a2a", + method="POST", + url="https://codex.example.test/api/a2a", + ) + db.add(tool) + db.flush() + ensure_open_gallery_binding(db, "tenant_demo", "tool", tool.id, "active") + run = A2ATaskRun( + id="a2arun_demo", + tenant_id="tenant_demo", + tool_id=tool.id, + endpoint_url=tool.url, + remote_task_id="remote_1", + status="working", + artifacts_json=[{"name": "report.md"}], + ) + db.add(run) + db.add( + A2ATaskEvent( + tenant_id="tenant_demo", + run_id=run.id, + sequence=1, + event_type="task.created", + data_json={"status": "working"}, + ) + ) + db.commit() + + rows = list_a2a_task_runs(tool.id, "tenant_demo", None, 20, db) + + assert len(rows) == 1 + assert rows[0].remote_task_id == "remote_1" + assert rows[0].artifacts == [{"name": "report.md"}] + assert [(event.sequence, event.event_type) for event in rows[0].events] == [ + (1, "task.created") + ] + + cancelled = cancel_a2a_task_run( + tool.id, + run.id, + "tenant_demo", + None, + db, + _admin_user(), + ) + + assert cancelled.cancel_requested is True + assert db.get(A2ATaskRun, run.id).cancel_requested is True + + +def test_codex_a2a_adapter_status_never_returns_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "app.api.tools.get_settings", + lambda: SimpleNamespace( + codex_a2a_enabled=True, + codex_a2a_command="codex", + codex_a2a_workspace_root="/srv/codex", + codex_a2a_timeout_seconds=1800.0, + codex_a2a_token="secret-token", + ), + ) + + result = get_codex_a2a_adapter() + + assert result.enabled is True + assert result.command == "codex" + assert result.token_configured is True + assert "token" not in result.model_dump(exclude={"token_configured"}) + + def test_tool_config_namespaces_execution_and_preserves_existing_policy() -> None: created = _tool_config( {"tool": "sum"}, @@ -87,12 +181,12 @@ def test_tool_config_namespaces_execution_and_preserves_existing_policy() -> Non def test_tool_config_rejects_untyped_execution_and_reads_invalid_legacy_safely() -> None: config = _tool_config( - {"tool": "sum", "execution": {"timeout_seconds": 999}}, + {"tool": "sum", "execution": {"timeout_seconds": 3601}}, None, ) assert config == {"tool": "sum"} - assert _read_execution_policy({"execution": {"timeout_seconds": 999}}) is None + assert _read_execution_policy({"execution": {"timeout_seconds": 3601}}) is None def test_delete_tool_is_tenant_scoped() -> None: diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 41e4060b..804c65ab 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -3108,5 +3108,67 @@ "需求澄清": "Requirements Clarification", "采购需求": "Procurement Requirements", "里程碑": "Milestones", - "指标口径": "Metric Definitions" + "指标口径": "Metric Definitions", + "加载 A2A 任务记录失败": "Failed to load A2A task history", + "已提交取消请求": "Cancellation requested", + "取消 A2A 任务失败": "Failed to cancel the A2A task", + "A2A 持久化任务": "Persistent A2A Tasks", + "标准 A2A Agent": "Standard A2A Agent", + "· 最长": "· Maximum", + "无凭证": "No credential", + "任务、事件和产物均持久化": "Tasks, events, and artifacts are persisted", + "尚无 A2A 调用记录。测试或正式调用后,长任务状态会保留在这里。": "No A2A calls yet. Long-running task state will appear here after a test or production call.", + "个产物": " artifacts", + "· 恢复 {1} 次": "· {1} recovery attempts", + "持久化状态": "Persisted State", + "等待输入": "Awaiting Input", + "每个工具独立生效,支持 1–3600 秒;A2A 长任务会在此时间内持续订阅或轮询。": "Configured per tool from 1 to 3600 seconds. A2A long-running tasks continue subscribing or polling within this period.", + "自动发现 Agent Card;优先流式订阅,断线后回退轮询。任务、事件和产物由 StaffDeck 持久化。": "Automatically discovers the Agent Card, prefers streaming subscriptions, and falls back to polling after disconnects. StaffDeck persists tasks, events, and artifacts.", + "Codex Adapter 未启用": "Codex Adapter is disabled", + "读取 /.well-known/agent-card.json": "Read /.well-known/agent-card.json", + "发现失败时终止而非继续尝试": "Stop when discovery fails", + "优先 SendStreamingMessage": "Prefer SendStreamingMessage", + "工作中任务使用 SubscribeToTask": "Use SubscribeToTask for working tasks", + "Agent Card URL(可选)": "Agent Card URL (optional)", + "轮询间隔(秒)": "Polling interval (seconds)", + "保留协议扩展字段;结构化选项会同步写入这里。": "Keep protocol extension fields here. Structured options are synchronized into this JSON.", + "当前用户": "Current User", + "已停用渠道": "Channel disabled", + "切换状态失败": "Failed to change status", + "邀请成员绑定飞书身份": "Invite Members to Link Feishu Identities", + "每位成员需用自己的飞书账号向当前机器人发送一次性绑定指令。": "Each member must send a one-time linking command to this bot from their own Feishu account.", + "{1} 已绑定": "{1} linked", + "选择内部成员": "Select Internal Member", + "生成绑定指令": "Generate Linking Command", + "所有内部成员均已绑定当前飞书应用": "All internal members are linked to this Feishu app", + "{1} 的": "{1}'s", + "请让{1}使用自己的{2}账号向当前机器人发送以上指令。": "Ask {1} to send the command above to this bot from their own {2} account.", + "该成员": "this member", + "命令": "Command", + "已配置凭证": "Credential configured", + "个事件 ·": " events ·", + "已提交": "Submitted", + "A2A 长任务连接": "A2A Long-running Task Connection", + "连接本机 Codex": "Connect Local Codex", + "发现 Agent Card": "Discover Agent Card", + "强制 Agent Card": "Require Agent Card", + "流式消息": "Streaming Messages", + "订阅远程任务": "Subscribe to Remote Tasks", + "高级配置 JSON": "Advanced Configuration JSON", + "拥有者": "Owner", + "协作者": "Collaborator", + "加载协作者失败": "Failed to load collaborators", + "已添加协作者": "Collaborator added", + "添加协作者失败": "Failed to add collaborator", + "已移除协作者": "Collaborator removed", + "移除协作者失败": "Failed to remove collaborator", + "协作者管理": "Collaborator Management", + "协作者可配置/轮换凭证、管理挂载员工、启停渠道,但不能删除渠道或管理其他协作者。": "Collaborators can configure or rotate credentials, manage assigned agents, and enable or disable channels, but cannot delete channels or manage other collaborators.", + "暂无协作者": "No collaborators", + "授权人:": "Granted by:", + "选择成员": "Select Member", + "无可添加的成员(协作者须为当前租户内部成员,且排除创建者与管理员)": "No eligible members. Collaborators must be internal tenant members other than the creator or administrators.", + "已启用渠道": "Channel enabled", + "绑定{1}{2}身份": "Link {1} {2} identity", + "更新于": "Updated" } diff --git a/frontend-enterprise/src/pages/ToolsPage.tsx b/frontend-enterprise/src/pages/ToolsPage.tsx index a95599cd..9fb594fc 100644 --- a/frontend-enterprise/src/pages/ToolsPage.tsx +++ b/frontend-enterprise/src/pages/ToolsPage.tsx @@ -2,7 +2,7 @@ import { ApiOutlined, CheckOutlined, ExperimentOutlined, ToolOutlined } from '.. import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Copy, FlaskConical, Users } from 'lucide-react'; +import { Activity, Copy, FlaskConical, RotateCcw, TerminalSquare, Users, XCircle } from 'lucide-react'; import { pinyin } from 'pinyin-pro'; import { api, TENANT_ID } from '../api/client'; @@ -71,7 +71,9 @@ import { isTeamScope, readEmployeeScope } from '../lib/agent-scope-storage'; import { StatusBadge } from './scheduled-tasks/StatusBadge'; import type { AgentProfileRead, + A2ATaskRunRead, CapabilityScope, + CodexA2AAdapterRead, ToolRead, MCPServerRead, MCPServerConnection, @@ -1335,10 +1337,107 @@ export function ToolTestPage({ currentUser, onLogout }: ToolPageProps = {}) { {tool && } + {tool?.tool_type === 'a2a' && } ); } +const A2A_TERMINAL_STATES = new Set(['completed', 'failed', 'canceled', 'cancelled', 'rejected']); + +function A2ARunsPanel({ tool }: { tool: ToolRead }) { + const [runs, setRuns] = useState([]); + const [adapter, setAdapter] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + const agentQuery = currentAgentQuery(); + + const load = async () => { + setLoading(true); + try { + const [nextRuns, nextAdapter] = await Promise.all([ + api.get(`/api/enterprise/tools/${tool.id}/a2a-runs?tenant_id=${TENANT_ID}${agentQuery}&limit=20`), + api.get(`/api/enterprise/tools/a2a/codex-adapter?tenant_id=${TENANT_ID}${agentQuery}`), + ]); + setRuns(nextRuns); + setAdapter(nextAdapter); + } catch (error) { + notify.error(error instanceof Error ? error.message : '加载 A2A 任务记录失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + // Tool identity is the stable boundary for this panel. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tool.id]); + + async function cancel(run: A2ATaskRunRead) { + try { + await api.post(`/api/enterprise/tools/${tool.id}/a2a-runs/${run.id}:cancel?tenant_id=${TENANT_ID}${agentQuery}`, {}); + notify.success('已提交取消请求'); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '取消 A2A 任务失败'); + } + } + + const codexEndpoint = adapter ? new URL(adapter.endpoint_url, window.location.origin).toString() : ''; + const isCodexConnection = Boolean(codexEndpoint && tool.url.replace(/\/$/, '') === codexEndpoint.replace(/\/$/, '')); + + return ( + A2A 持久化任务} + extra={ void load()} disabled={loading}>刷新} + loading={loading && runs.length === 0} + bodyClassName="flex flex-col gap-[14px]" + > +
+
+
+ + {isCodexConnection ? 'Codex CLI Adapter' : '标准 A2A Agent'} + {isCodexConnection && {adapter?.enabled ? '已连接' : '未启用'}} +
+

{tool.url}

+ {isCodexConnection && adapter &&

命令 {adapter.command} · 最长 {adapter.timeout_seconds}s · {adapter.token_configured ? '已配置凭证' : '无凭证'}

} +
+
任务、事件和产物均持久化
+
+ + {runs.length === 0 ? ( +
尚无 A2A 调用记录。测试或正式调用后,长任务状态会保留在这里。
+ ) : runs.map((run) => { + const open = expanded === run.id; + const terminal = A2A_TERMINAL_STATES.has(run.status); + return ( +
+ + {open &&
+
事件时间线
{run.events.map((event) =>
#{event.sequence}
{event.event_type}{formatDateTime(event.created_at)}
)}
+
持久化状态
+
} +
+ ); + })} +
+ ); +} + +function a2aStatusLabel(status: string): string { + return ({ submitted: '已提交', working: '执行中', running: '执行中', completed: '已完成', failed: '失败', canceled: '已取消', cancelled: '已取消', rejected: '已拒绝', 'input-required': '等待输入' } as Record)[status] || status; +} + type McpFormValues = { name: string; display_name: string; @@ -1957,20 +2056,18 @@ function ToolFormFields({ - {values.tool_type === 'a2a' && -