From ac2b6415ea69797b86dfee8ac0493959ce702e84 Mon Sep 17 00:00:00 2001 From: yang rui <142162413+ruirui6946@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:09:41 +0800 Subject: [PATCH] feat(agents): support Unicode display names for custom agents (#5324) * feat(agents): support Unicode display names for custom agents * fix(agents): preserve and validate Unicode display names * fix(agents): tolerate invalid stored labels and reject invisible names * fix(agents): identify agent in invalid display name warning * style(frontend): format agent display name fallback --------- Co-authored-by: Willem Jiang --- README.md | 14 +++ backend/app/gateway/routers/agents.py | 11 +- .../harness/deerflow/config/AGENTS.md | 18 +++ .../harness/deerflow/config/agents_config.py | 20 ++- .../deerflow/persistence/agents/base.py | 17 ++- .../packages/harness/deerflow/tools/AGENTS.md | 2 +- .../tools/builtins/setup_agent_tool.py | 10 +- backend/tests/test_agent_display_name.py | 111 +++++++++++++++++ backend/tests/test_agent_store_sql.py | 10 ++ backend/tests/test_custom_agent.py | 44 +++++++ backend/tests/test_update_agent_tool.py | 12 ++ frontend/AGENTS.md | 6 + .../[agent_name]/chats/[thread_id]/page.tsx | 4 +- .../components/workspace/agent-welcome.tsx | 4 +- .../workspace/agents/agent-card.tsx | 9 +- .../agents/agent-settings-dialog.tsx | 27 +++- frontend/src/core/agents/types.ts | 3 + frontend/src/core/i18n/locales/en-US.ts | 13 +- frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 11 +- frontend/tests/e2e/agent-chat.spec.ts | 73 +++++++++++ frontend/tests/e2e/utils/mock-api.ts | 1 + .../agents/agent-display-name.dom.test.tsx | 115 ++++++++++++++++++ 23 files changed, 518 insertions(+), 20 deletions(-) create mode 100644 backend/tests/test_agent_display_name.py create mode 100644 frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx diff --git a/README.md b/README.md index c72b13e88..2bb3d161a 100644 --- a/README.md +++ b/README.md @@ -1255,6 +1255,20 @@ The chat header also shows a context-window gauge when the selected model has a ### Sub-Agents +Custom Agents support an optional Unicode display name, including Chinese and +emoji. Open an agent's **Agent settings → Display name** to set it (up to 100 +Unicode code points), or leave it blank to show the existing identifier. Control +characters and bidirectional formatting controls are rejected; ordinary multilingual +text and emoji are supported. Invisible-only names and invisible formatting +characters such as zero-width spaces are rejected. Invalid display names in +older or hand-edited storage fall back to the agent identifier when read; +a warning identifies the affected agent. +Re-bootstrapping preserves valid display names. The gallery, +chat header, and welcome page use this label; URLs and API calls continue to use +the stable English `name`. API callers can pass `display_name` to agent creation +or update requests; an omitted update preserves it and `null` clears it. The +same optional field is supported in the agent's `config.yaml`. + Sub-agents are an optimization, not the default response to a complex request. The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions — when delegation has clear net benefit from real parallel latency, specialist capability, or context isolation. It keeps interdependent scopes and overlapping side effects out of parallel dispatch; a bounded sequential chain can still run in one sub-agent when specialist or context-isolation benefit clearly wins. The lead uses the fewest useful sub-agents and re-evaluates later batches instead of fanning out solely because a task is large or multi-step. Sub-agents report back structured results, and the lead agent verifies and synthesizes them into a coherent output. Deterministic tool receipts cover both direct tool messages and state-updating `Command` results such as delegated `task` responses; when the receipt ledger reaches its context budget, it retains the newest actions and their original receipt IDs. Operators can disable this provenance layer with `verification.receipts_enabled: false`. Their configured skills are resolved from the same user-scoped catalog as the lead agent, so user-owned custom skills remain available without exposing another user's version. Their internal AI and tool messages stay scoped to the delegated graph instead of entering the parent chat stream. Reloaded thread history enforces the same boundary: callback-captured sub-agent AI responses remain available in run-event diagnostics but are excluded from the parent transcript, while the parent `task` result remains attached to its subtask card. Long-running sub-agents compact older history when summarization is enabled and re-inject the summary as guarded, hidden durable context before continuing, so recent assistant/tool activity remains grounded in the task. Provider/model request failures are reported as failed sub-agent tasks rather than successful results, so the lead agent and Web UI can react to them correctly. Concurrent parent runs also receive independent server-side sub-agent execution IDs, so a provider that reuses a tool-call ID cannot make one run poll, cancel, or clean up another run's background task. Collapsed sub-agent cards show the effective model and, when the provider returns usage metadata, a cumulative token total that updates after each completed sub-agent LLM call and persists after a reload. When token usage tracking is enabled, completed sub-agent usage is attributed back to the dispatching step from that run's terminal tool-message metadata rather than a process-global provider-ID cache. diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index d4086bb95..5f3069b52 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -12,6 +12,7 @@ from deerflow.agents.memory.manager import get_memory_manager from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_config import ( AgentConfig, + AgentDisplayName, AgentModelSettings, list_custom_agents, load_agent_config, @@ -40,6 +41,7 @@ class AgentResponse(BaseModel): """Response model for a custom agent.""" name: str = Field(..., description="Agent name (hyphen-case)") + display_name: AgentDisplayName | None = Field(default=None, description="Optional Unicode display name; name remains the stable identifier") description: str = Field(default="", description="Agent description") model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") @@ -61,6 +63,7 @@ class AgentCreateRequest(BaseModel): """Request body for creating a custom agent.""" name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)") + display_name: AgentDisplayName | None = None description: str = Field(default="", description="Agent description") model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") @@ -75,6 +78,7 @@ class AgentCreateRequest(BaseModel): class AgentUpdateRequest(BaseModel): """Request body for updating a custom agent.""" + display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it") description: str | None = Field(default=None, description="Updated description") model: str | None = Field(default=None, description="Updated model override") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") @@ -189,6 +193,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False return AgentResponse( name=agent_cfg.name, + display_name=agent_cfg.display_name, description=agent_cfg.description, model=agent_cfg.model, tool_groups=agent_cfg.tool_groups, @@ -328,6 +333,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: # Config document — only the fields the caller set, matching the historical # writer (an omitted field stays absent rather than being materialized). config_data: dict = {"name": normalized_name} + if request.display_name: + config_data["display_name"] = request.display_name if request.description: config_data["description"] = request.description if request.tool_groups is not None: @@ -415,7 +422,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: # Use model_fields_set to distinguish "field omitted" from "explicitly set to null". # This is critical for skills where None means "inherit all" (not "don't change"). fields_set = request.model_fields_set - config_changed = bool(fields_set & ({"description", "tool_groups", "skills", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS))) + config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS))) updated: dict | None = None if config_changed: @@ -423,6 +430,8 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: "name": agent_cfg.name, "description": request.description if "description" in fields_set else agent_cfg.description, } + if "display_name" in fields_set: + updated["display_name"] = request.display_name or None new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups if new_tool_groups is not None: diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 9dc181c70..f2bd85c1c 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -1,5 +1,23 @@ ### Configuration System +Custom Agent `AgentConfig.display_name` is an optional, whitespace-trimmed Unicode +label of at most 100 Unicode code points. C0/C1 controls and bidirectional +formatting controls (U+202A–U+202E, U+2066–U+2069) are rejected before trimming. +Also reject soft hyphen, Arabic letter mark, U+200B, U+200E–U+200F, +U+2028–U+2029, U+2060–U+2065 and U+FEFF. Labels consisting only of +marks, separators or other invisible characters are invalid; ZWNJ/ZWJ remain +supported inside ordinary text and emoji. +It is stored in the existing config document by +both agent stores; it never participates in paths, routing, or authorization. +Gateway create/update/response models share its validation. It remains outside +`MANAGED_AGENT_CONFIG_FIELDS` so `update_agent` preserves it. `setup_agent` +explicitly carries forward the existing owner's display name when re-bootstrapping; +the Gateway explicitly overrides it when supplied, including null to clear. +Both stores use `parse_agent_config` to ignore only an invalid stored +`display_name` on read, logging the agent identifier without the invalid value +and without rewriting storage. Other config errors still +raise, and API create/update validation remains strict. + **Main Configuration** (`config.yaml`): Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** directory. diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index 66872a8b2..081b1b7ed 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -9,10 +9,11 @@ per-user layout. import logging import re +import unicodedata from pathlib import Path -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, field_validator, model_validator from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id @@ -24,6 +25,20 @@ AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") MAX_AGENT_OUTPUT_TOKENS = 200_000 +def _validate_display_name(value: object) -> object: + # Check before trimming so leading/trailing controls cannot disappear. + # Keep ordinary RTL text, ZWNJ in Persian/Indic text and ZWJ in emoji. + if isinstance(value, str): + if re.search(r"[\x00-\x1f\x7f-\x9f\u00ad\u061c\u200b\u200e-\u200f\u2028-\u202e\u2060-\u2069\ufeff]", value): + raise ValueError("Display name must not contain control characters or invisible formatting controls") + if value.strip() and all(unicodedata.category(char)[0] in {"C", "M", "Z"} for char in value): + raise ValueError("Display name must contain visible text") + return value + + +AgentDisplayName = Annotated[str, StringConstraints(strip_whitespace=True, max_length=100), BeforeValidator(_validate_display_name)] + + def _blank_to_none(value: str | None) -> str | None: """Normalize a whitespace-only string to ``None``; leave real values untouched. @@ -192,6 +207,7 @@ class AgentConfig(BaseModel): """Configuration for a custom agent.""" name: str + display_name: AgentDisplayName | None = None description: str = "" model: str | None = None tool_groups: list[str] | None = None diff --git a/backend/packages/harness/deerflow/persistence/agents/base.py b/backend/packages/harness/deerflow/persistence/agents/base.py index d0782c872..99ba826b4 100644 --- a/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/backend/packages/harness/deerflow/persistence/agents/base.py @@ -26,11 +26,16 @@ bucket semantics, distinct from the AUTO/None sentinel used by the async from __future__ import annotations import abc +import logging from collections.abc import Hashable from typing import Any, Literal +from pydantic import ValidationError + from deerflow.config.agents_config import AgentConfig +logger = logging.getLogger(__name__) + def parse_agent_config(data: dict[str, Any], name: str) -> AgentConfig: """Build an :class:`AgentConfig` from a raw config *document*, shared by both backends. @@ -44,7 +49,17 @@ def parse_agent_config(data: dict[str, Any], name: str) -> AgentConfig: data["name"] = name known_fields = set(AgentConfig.model_fields.keys()) data = {k: v for k, v in data.items() if k in known_fields} - return AgentConfig(**data) + try: + return AgentConfig(**data) + except ValidationError as exc: + if not any(error["loc"] == ("display_name",) for error in exc.errors()): + raise + # A cosmetic value in old/hand-edited storage must not make the agent + # inaccessible. Retry only without that field: other errors still fail. + data.pop("display_name", None) + config = AgentConfig(**data) + logger.warning("Ignoring invalid stored agent display_name for agent %r", name) + return config # Delete outcome, mirroring the agents router's result: diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 57e6c4db5..445cf409d 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -7,7 +7,7 @@ - `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware` - `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary - `view_image` - Read image bytes for vision-capable models; live sandbox bytes win for the same sandbox generation, replacement-sandbox recovery uses only SHA-256-verified synchronized host bytes, and async tool invocation drains blocking reads before cancellation may release the sandbox lease - - `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`. + - `setup_agent` - Bootstrap-only: persist a custom agent's `SOUL.md` and `config.yaml`. Re-bootstrapping preserves the owner's existing `display_name`. Bound only when `is_bootstrap=True`. - `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`. 4. **Subagent tool** (if enabled): - `task` - Delegate to subagent (`prompt`, `subagent_type`, optional `acceptance_criteria`, and an optional model-visible `description` used only as a short progress label). Execution never depends on `description`; lifecycle display falls back to `prompt` when a provider omits it. Subagent reports are self-reports: the docstring directs the lead to expect `[rN]` receipt citations and verifiable handles while `verification.receipts_enabled` (and explicitly qualifies that disabled receipts mean no citations and no citation verdict), to read the delegation ledger's citation cross-check as execution evidence only, and to attach `acceptance_criteria` for objectively checkable outcomes (canonical forms `file: exists|non-empty`, `file_written:`, `tests_passed:`); criteria are handed to the executor and appended to the subagent's task message as untrusted data (see `subagents/report_contract.py`). diff --git a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py index c30823914..715712dce 100644 --- a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -58,11 +58,19 @@ def setup_agent( # this is an upsert. user_id = resolve_runtime_user_id(runtime) config_data: dict = {"name": agent_name} + store = get_agent_store() + try: + existing = store.get(agent_name, user_id=user_id) + except FileNotFoundError: + pass # First bootstrap has no user-authored label to preserve. + else: + if existing.display_name is not None: + config_data["display_name"] = existing.display_name if description: config_data["description"] = description if skills is not None: config_data["skills"] = skills - get_agent_store().update(agent_name, config_data, soul, user_id=user_id) + store.update(agent_name, config_data, soul, user_id=user_id) else: # Default agent (no agent_name): SOUL.md lives at the global base # dir. It is not a custom-agent record, so it stays file-based diff --git a/backend/tests/test_agent_display_name.py b/backend/tests/test_agent_display_name.py new file mode 100644 index 000000000..9d311590d --- /dev/null +++ b/backend/tests/test_agent_display_name.py @@ -0,0 +1,111 @@ +"""Display labels survive bootstrap and accept only safe Unicode text.""" + +from types import SimpleNamespace + +import pytest +import yaml +from pydantic import ValidationError +from sqlalchemy import create_engine + +from app.gateway.routers.agents import AgentCreateRequest, AgentUpdateRequest +from deerflow.config.agents_config import AgentConfig +from deerflow.persistence.agents.base import parse_agent_config +from deerflow.persistence.agents.file import FileAgentStore +from deerflow.persistence.agents.model import AgentRow +from deerflow.persistence.agents.sql import SqlAgentStore +from deerflow.persistence.base import Base +from deerflow.tools.builtins.setup_agent_tool import setup_agent + + +@pytest.mark.parametrize("backend", ["file", "sql"]) +@pytest.mark.parametrize("display_name", ["代码审查助手", None]) +def test_bootstrap_preserves_owner_display_name(tmp_path, monkeypatch, backend, display_name): + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + if backend == "file": + store = FileAgentStore() + else: + url = f"sqlite:///{tmp_path}/agents.db" + engine = create_engine(url) + Base.metadata.create_all(engine, tables=[AgentRow.__table__]) + engine.dispose() + store = SqlAgentStore(url) + monkeypatch.setattr("deerflow.tools.builtins.setup_agent_tool.get_agent_store", lambda: store) + owner = "test-user-autouse" + store.create("reviewer", {"display_name": display_name}, "old soul", user_id=owner) + store.create("reviewer", {"display_name": "Other owner"}, "other soul", user_id="other") + result = setup_agent.func( + soul="new soul", + description="rebootstrapped", + skills=["test-skill"], + runtime=SimpleNamespace(context={"agent_name": "reviewer"}, tool_call_id="test"), + ) + assert result.update["created_agent_name"] == "reviewer" + config = store.get("reviewer", user_id=owner) + assert config.display_name == display_name + assert config.description == "rebootstrapped" + assert config.skills == ["test-skill"] + assert store.get_soul("reviewer", user_id=owner) == "new soul" + assert store.get("reviewer", user_id="other").display_name == "Other owner" + + +@pytest.mark.parametrize("model", [AgentConfig, AgentCreateRequest, AgentUpdateRequest]) +@pytest.mark.parametrize("codepoint", [*range(0x20), *range(0x7F, 0xA0), 0xAD, 0x61C, 0x200B, 0x200E, 0x200F, *range(0x2028, 0x202F), *range(0x2060, 0x206A), 0xFEFF]) +def test_display_name_rejects_controls(model, codepoint): + for value in [f"a{chr(codepoint)}b", f"{chr(codepoint)}name", f"name{chr(codepoint)}"]: + with pytest.raises(ValidationError): + model(name="reviewer", display_name=value) + + +@pytest.mark.parametrize("model", [AgentConfig, AgentCreateRequest, AgentUpdateRequest]) +@pytest.mark.parametrize("value", ["🦌" * 100, "代码审查助手", "مراجع الكود", "می\u200cروم", "👩‍💻", "e\u0301"]) +def test_display_name_accepts_multilingual_text(model, value): + assert model(name="reviewer", display_name=f" {value} ").display_name == value + with pytest.raises(ValidationError): + model(name="reviewer", display_name="🦌" * 101) + + +@pytest.mark.parametrize("value", ["\u200b" * 3, "\u200c\u200d", "\ufe0f", "\u0301"]) +def test_invisible_only_labels_are_rejected(value): + with pytest.raises(ValidationError): + AgentCreateRequest(name="reviewer", display_name=value) + + +@pytest.mark.parametrize("backend", ["file", "sql"]) +@pytest.mark.parametrize("value", ["x" * 150, 123, "\u200b", "a\u200fb", ["invalid"]]) +def test_invalid_stored_label_does_not_hide_or_break_agent(tmp_path, monkeypatch, backend, value): + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + owner = "test-user-autouse" + raw = {"display_name": value, "description": "healthy", "model": "gpt-x"} + if backend == "file": + store = FileAgentStore() + config_file = tmp_path / "users" / owner / "agents" / "reviewer" / "config.yaml" + config_file.parent.mkdir(parents=True) + config_file.write_text(yaml.safe_dump(raw), encoding="utf-8") + else: + url = f"sqlite:///{tmp_path}/agents.db" + engine = create_engine(url) + Base.metadata.create_all(engine, tables=[AgentRow.__table__]) + engine.dispose() + store = SqlAgentStore(url) + store.create("reviewer", raw, "old soul", user_id=owner) + config = store.get("reviewer", user_id=owner) + assert config.display_name is None + assert config.description == "healthy" + assert config.model == "gpt-x" + assert store.list(user_id=owner)[0].name == "reviewer" + assert store.list(user_id=owner)[0].display_name is None + # Reads must not rewrite user-authored storage. + if backend == "file": + assert yaml.safe_load(config_file.read_text(encoding="utf-8")) == raw + else: + with store._Session() as session: + assert session.query(AgentRow).one().config == raw + monkeypatch.setattr("deerflow.tools.builtins.setup_agent_tool.get_agent_store", lambda: store) + result = setup_agent.func(soul="new soul", description="rebootstrapped", runtime=SimpleNamespace(context={"agent_name": "reviewer"}, tool_call_id="test")) + assert result.update["created_agent_name"] == "reviewer" + assert store.get_soul("reviewer", user_id=owner) == "new soul" + + +def test_stored_label_tolerance_does_not_mask_other_errors(): + with pytest.raises(ValidationError): + parse_agent_config({"display_name": 123, "description": []}, "reviewer") diff --git a/backend/tests/test_agent_store_sql.py b/backend/tests/test_agent_store_sql.py index 09398ad88..e18fa1f0d 100644 --- a/backend/tests/test_agent_store_sql.py +++ b/backend/tests/test_agent_store_sql.py @@ -44,6 +44,16 @@ def test_create_and_get_round_trips_config_and_soul(store): assert store.get_soul("reviewer", user_id="u1") == "You review." +def test_display_name_is_config_data_not_storage_identity(store): + store.create("reviewer", {"name": "reviewer", "display_name": "代码审查助手"}, "Soul", user_id="u1") + assert store.get("reviewer", user_id="u1").display_name == "代码审查助手" + assert store.list(user_id="u1")[0].display_name == "代码审查助手" + assert not store.exists("reviewer", user_id="u2") + store.update("reviewer", {"name": "reviewer", "display_name": "新名称"}, None, user_id="u1") + assert store.get("reviewer", user_id="u1").display_name == "新名称" + assert store.get_soul("reviewer", user_id="u1") == "Soul" + + def test_name_is_stored_lowercase_and_excluded_from_document(store): store.create("Mixed", {"name": "Mixed", "description": "d"}, "s", user_id="u1") # Stored lowercase (matches the on-disk layout), and the JSON document does diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index 285b3af6f..c8b037908 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -581,6 +581,50 @@ def disabled_agent_client(tmp_path): class TestAgentsAPI: + @pytest.mark.parametrize("display_name", ["x" * 150, 123, "\u200b" * 3]) + def test_invalid_stored_display_name_falls_back_in_api(self, agent_client, display_name): + from deerflow.persistence.agents.file import FileAgentStore + + FileAgentStore().create("reviewer", {"display_name": display_name, "description": "healthy"}, "Soul") + response = agent_client.get("/api/agents/reviewer") + assert response.status_code == 200 + assert response.json()["display_name"] is None + assert response.json()["description"] == "healthy" + assert agent_client.get("/api/agents").json()["agents"][0]["name"] == "reviewer" + response = agent_client.put("/api/agents/reviewer", json={"display_name": "已修复"}) + assert response.status_code == 200 + assert response.json()["display_name"] == "已修复" + + @pytest.mark.parametrize("display_name", ["a\u202eb", "line1\nline2", "z\x00ero", "\u200b" * 3, "a\u200fb", "a\u2028b", "\u200c\u200d"]) + def test_invalid_display_name_cannot_be_persisted(self, agent_client, display_name): + assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": display_name}).status_code == 422 + assert agent_client.get("/api/agents").json()["agents"] == [] + assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "🦌" * 100}).status_code == 201 + assert agent_client.put("/api/agents/reviewer", json={"display_name": display_name}).status_code == 422 + assert agent_client.get("/api/agents/reviewer").json()["display_name"] == "🦌" * 100 + + def test_display_name_round_trip_keeps_stable_identity(self, agent_client): + response = agent_client.post("/api/agents", json={"name": "code-reviewer", "display_name": " 代码审查助手 "}) + assert response.status_code == 201 + assert response.json()["display_name"] == "代码审查助手" + assert response.json()["name"] == "code-reviewer" + assert agent_client.get("/api/agents").json()["agents"][0]["display_name"] == "代码审查助手" + response = agent_client.put("/api/agents/code-reviewer", json={"description": "Updated"}) + assert response.json()["display_name"] == "代码审查助手" + response = agent_client.put("/api/agents/code-reviewer", json={"display_name": "审查员 🦌"}) + assert response.json()["display_name"] == "审查员 🦌" + assert agent_client.get("/api/agents/code-reviewer").json()["display_name"] == "审查员 🦌" + response = agent_client.put("/api/agents/code-reviewer", json={"display_name": None}) + assert response.json()["display_name"] is None + assert response.json()["name"] == "code-reviewer" + + def test_display_name_validation_does_not_relax_agent_identifier(self, agent_client): + assert agent_client.post("/api/agents", json={"name": "中文"}).status_code == 422 + assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "名" * 101}).status_code == 422 + assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "名" * 100}).status_code == 201 + assert agent_client.put("/api/agents/reviewer", json={"display_name": "名" * 101}).status_code == 422 + assert agent_client.get("/api/agents/reviewer").json()["display_name"] == "名" * 100 + def test_list_agents_empty(self, agent_client): response = agent_client.get("/api/agents") assert response.status_code == 200 diff --git a/backend/tests/test_update_agent_tool.py b/backend/tests/test_update_agent_tool.py index 059f668a8..80fd42ffc 100644 --- a/backend/tests/test_update_agent_tool.py +++ b/backend/tests/test_update_agent_tool.py @@ -342,6 +342,18 @@ def test_update_agent_preserves_github_block_on_description_change(tmp_path, pat assert cfg["github"] == github_block +def test_update_agent_preserves_display_name_on_description_change(tmp_path, patched_paths): + agent_dir = _seed_agent(tmp_path) + config_path = agent_dir / "config.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config["display_name"] = "代码审查助手" + config_path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8") + update_agent.func(runtime=_runtime(), description="refined desc") + updated = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert updated["display_name"] == "代码审查助手" + assert updated["name"] == "test-agent" + + def test_update_agent_preserves_model_behavior_on_description_change(tmp_path, patched_paths): """UI/API-owned model behavior must survive agent self-edits. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 091622e70..0248f1f9c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -75,6 +75,12 @@ More specific `AGENTS.md` files under `src/` contain the frontend sections split ## Code Style +Custom Agent `display_name` is an optional Unicode UI label, edited in +`AgentSettingsDialog`. Use it with a fallback to `name` for gallery/chat text; +keep `name` for React identity, URLs, requests, and runtime `agent_name`. +The 100-code-point budget uses `[...value.trim()].length`, matching Pydantic; +do not use HTML `maxLength`, which counts UTF-16 code units instead. + - **Imports**: Enforced ordering (builtin → external → internal → parent → sibling), alphabetized, newlines between groups. Use inline type imports: `import { type Foo }`. - **Unused variables**: Prefix with `_`. - **Class names**: Use `cn()` from `@/lib/utils` for conditional Tailwind classes. diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index dd00aa769..0f940b53b 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -275,7 +275,9 @@ export default function AgentChatPage() {
- {agent?.name ?? agent_name} + {agent?.display_name?.length + ? agent.display_name + : (agent?.name ?? agent_name)}
diff --git a/frontend/src/components/workspace/agent-welcome.tsx b/frontend/src/components/workspace/agent-welcome.tsx index 7d30b9b34..5a856e133 100644 --- a/frontend/src/components/workspace/agent-welcome.tsx +++ b/frontend/src/components/workspace/agent-welcome.tsx @@ -14,7 +14,9 @@ export function AgentWelcome({ agent: Agent | null | undefined; agentName: string; }) { - const displayName = agent?.name ?? agentName; + const displayName = agent?.display_name?.length + ? agent.display_name + : (agent?.name ?? agentName); const description = agent?.description; return ( diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx index d2f9e2c0f..419304632 100644 --- a/frontend/src/components/workspace/agents/agent-card.tsx +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -108,6 +108,9 @@ function TruncatedBadge({ } export function AgentCard({ agent }: AgentCardProps) { + const displayName = agent.display_name?.length + ? agent.display_name + : agent.name; const { t } = useI18n(); const router = useRouter(); const deleteAgent = useDeleteAgent(); @@ -138,9 +141,9 @@ export function AgentCard({ agent }: AgentCardProps) {
- + - {agent.name} + {displayName} {agent.model && ( @@ -214,7 +217,7 @@ export function AgentCard({ agent }: AgentCardProps) { - {/* Model settings — mounted only while open so its form state always + {/* Agent settings — mounted only while open so its form state always re-seeds from the latest agent props (avoids stale values on reopen). */} {settingsOpen && ( 100) { + toast.error(t.agents.settingsDisplayNameTooLong); + return; + } const parsedSettings = parseAgentModelSettingsDraft({ temperature, maxTokens, @@ -134,6 +139,7 @@ export function AgentSettingsDialog({ await updateAgent.mutateAsync({ name: agent.name, request: { + display_name: displayName.trim() || null, model: model === DEFAULT_MODEL_VALUE ? null : model, model_settings: parsedSettings.modelSettings, thinking_enabled: supportsThinking @@ -165,6 +171,25 @@ export function AgentSettingsDialog({
+
+ + setDisplayName(event.target.value)} + placeholder={agent.name} + aria-describedby="agent-display-name-hint" + /> +

+ {t.agents.settingsDisplayNameHint} ({agent.name}){" · "} + {[...displayName.trim()].length}/100 +

+
{/* Default model */}
diff --git a/frontend/src/core/agents/types.ts b/frontend/src/core/agents/types.ts index c3a38898e..c6118210c 100644 --- a/frontend/src/core/agents/types.ts +++ b/frontend/src/core/agents/types.ts @@ -7,6 +7,7 @@ export type ReasoningEffort = "low" | "medium" | "high"; export interface Agent { name: string; + display_name?: string | null; description: string; model: string | null; tool_groups: string[] | null; @@ -20,6 +21,7 @@ export interface Agent { export interface CreateAgentRequest { name: string; + display_name?: string | null; description?: string; model?: string | null; tool_groups?: string[] | null; @@ -32,6 +34,7 @@ export interface CreateAgentRequest { } export interface UpdateAgentRequest { + display_name?: string | null; description?: string | null; model?: string | null; tool_groups?: string[] | null; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 06d1dbee5..e12874435 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -649,10 +649,15 @@ export const enUS: Translations = { agentCreated: "Agent created!", startChatting: "Start chatting", backToGallery: "Back to Gallery", - settings: "Model settings", - settingsTitle: "Model settings", + settings: "Agent settings", + settingsTitle: "Agent settings", + settingsDisplayName: "Display name", + settingsDisplayNameTooLong: + "Display name must be at most 100 Unicode code points.", + settingsDisplayNameHint: + "Supports Unicode. Leave blank to use the agent identifier", settingsDescription: - "Choose the default model and generation parameters for this agent. Changes take effect on the next message.", + "Choose a display name and model defaults for this agent. Model changes take effect on the next message.", settingsModel: "Default model", settingsModelDefault: "Use global default", settingsTemperature: "Temperature", @@ -664,7 +669,7 @@ export const enUS: Translations = { settingsThinkingOff: "Off", settingsReasoningEffort: "Reasoning effort", settingsInherit: "Inherit", - settingsSaved: "Model settings saved", + settingsSaved: "Agent settings saved", settingsInvalidTemperature: "Temperature must be between 0 and 2", settingsInvalidMaxTokens: "Max output tokens must be a positive integer up to 200,000", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index cec496b41..954020247 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -531,6 +531,9 @@ export interface Translations { backToGallery: string; settings: string; settingsTitle: string; + settingsDisplayName: string; + settingsDisplayNameTooLong: string; + settingsDisplayNameHint: string; settingsDescription: string; settingsModel: string; settingsModelDefault: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 300e7003a..b23ce4a4b 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -615,10 +615,13 @@ export const zhCN: Translations = { agentCreated: "智能体已创建!", startChatting: "开始对话", backToGallery: "返回 Gallery", - settings: "模型设置", - settingsTitle: "模型设置", + settings: "智能体设置", + settingsTitle: "智能体设置", + settingsDisplayName: "显示名称", + settingsDisplayNameTooLong: "显示名称不能超过 100 个 Unicode 码点。", + settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识", settingsDescription: - "为该智能体选择默认模型和生成参数,修改在下一条消息生效。", + "为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。", settingsModel: "默认模型", settingsModelDefault: "使用全局默认", settingsTemperature: "温度", @@ -630,7 +633,7 @@ export const zhCN: Translations = { settingsThinkingOff: "关闭", settingsReasoningEffort: "推理强度", settingsInherit: "继承", - settingsSaved: "模型设置已保存", + settingsSaved: "智能体设置已保存", settingsInvalidTemperature: "温度必须在 0 到 2 之间", settingsInvalidMaxTokens: "最大输出 token 必须为不超过 200,000 的正整数", }, diff --git a/frontend/tests/e2e/agent-chat.spec.ts b/frontend/tests/e2e/agent-chat.spec.ts index b0976579b..cf0ba1203 100644 --- a/frontend/tests/e2e/agent-chat.spec.ts +++ b/frontend/tests/e2e/agent-chat.spec.ts @@ -21,6 +21,79 @@ const MOCK_AGENTS = [ ]; test.describe("Agent chat", () => { + test("display name length counts emoji as code points", async ({ page }) => { + mockLangGraphAPI(page, { agents: [MOCK_AGENTS[0]!] }); + let savedName: string | undefined; + await page.route("**/api/agents/test-agent", async (route) => { + if (route.request().method() !== "PUT") return route.fallback(); + const request = route.request().postDataJSON() as { + display_name: string; + }; + savedName = request.display_name; + await route.fulfill({ json: { ...MOCK_AGENTS[0], ...request } }); + }); + await page.goto("/workspace/agents"); + await page.getByTitle("Agent settings", { exact: true }).click(); + const input = page.getByLabel("Display name", { exact: true }); + await input.fill("🦌".repeat(101)); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect( + page.getByText("Display name must be at most 100 Unicode code points."), + ).toBeVisible(); + expect(savedName).toBeUndefined(); + await expect(page.getByRole("dialog")).toBeVisible(); + await input.fill("🦌".repeat(100)); + await expect(input).toHaveValue("🦌".repeat(100)); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => savedName).toBe("🦌".repeat(100)); + await expect(page.getByRole("dialog")).toBeHidden(); + }); + + test("Unicode display names keep the stable agent route and run context", async ({ + page, + }, testInfo) => { + let streamBody: Record | undefined; + const agent = { ...MOCK_AGENTS[0]!, display_name: "" }; + mockLangGraphAPI(page, { + agents: [agent], + runStreamHandler: async (route) => { + streamBody = route.request().postDataJSON() as Record; + await handleRunStream(route); + }, + }); + await page.route("**/api/agents/test-agent", async (route) => { + if (route.request().method() !== "PUT") return route.fallback(); + const request = route.request().postDataJSON() as { + display_name: string; + }; + expect(request.display_name).toBe("代码审查助手"); + agent.display_name = request.display_name; + await route.fulfill({ json: agent }); + }); + await page.goto("/workspace/agents"); + await page.getByTitle("Agent settings", { exact: true }).click(); + await page.getByLabel("Display name", { exact: true }).fill("代码审查助手"); + await page.screenshot({ + path: testInfo.outputPath("display-name-settings.png"), + }); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(page.getByText("代码审查助手", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Chat", exact: true }).click(); + await page.waitForURL("**/workspace/agents/test-agent/chats/**"); + await expect( + page.getByText("代码审查助手", { exact: true }).first(), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("display-name-gallery.png"), + }); + const textarea = page.getByPlaceholder(/how can i assist you/i); + await textarea.fill("Review this code"); + await textarea.press("Enter"); + await expect.poll(() => streamBody).toBeDefined(); + expect(streamBody).toMatchObject({ context: { agent_name: "test-agent" } }); + }); + test("agent gallery page loads and shows agents", async ({ page }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index ecf840e16..53c3c615c 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -49,6 +49,7 @@ export type MockThread = { export type MockAgent = { name: string; + display_name?: string | null; description?: string; system_prompt?: string; tool_groups?: string[] | null; diff --git a/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx b/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx new file mode 100644 index 000000000..e05e15596 --- /dev/null +++ b/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { toast } from "sonner"; + +import { AgentWelcome } from "@/components/workspace/agent-welcome"; +import { AgentSettingsDialog } from "@/components/workspace/agents/agent-settings-dialog"; +import type { Agent } from "@/core/agents"; +import { enUS } from "@/core/i18n/locales/en-US"; + +const { mutateAsync } = rs.hoisted(() => ({ + mutateAsync: rs.fn().mockResolvedValue({}), +})); +rs.mock("@/core/agents", () => ({ + useUpdateAgent: () => ({ mutateAsync, isPending: false }), +})); +rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) })); +rs.mock("@/core/subagents", () => ({ + useSubagents: () => ({ subagents: [] }), +})); +rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) })); +rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } })); + +const agent: Agent = { + name: "reviewer", + display_name: "代码审查助手", + description: "", + model: null, + tool_groups: null, + skills: null, +}; +afterEach(() => { + cleanup(); + mutateAsync.mockClear(); +}); + +describe("custom agent display names", () => { + it("accepts 100 astral code points without an HTML code-unit limit", async () => { + render(); + const input = screen.getByLabelText("Display name"); + expect(input.hasAttribute("maxlength")).toBe(false); + fireEvent.change(input, { target: { value: "🦌".repeat(100) } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ display_name: "🦌".repeat(100) }), + }), + ), + ); + }); + + it("rejects 101 code points before saving", () => { + render(); + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "🦌".repeat(101) }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(mutateAsync).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalled(); + }); + + it("shows Unicode names and falls back for legacy or cleared names", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("代码审查助手")).toBeTruthy(); + for (const display_name of [undefined, null, ""]) { + rerender( + , + ); + expect(screen.getByText("reviewer")).toBeTruthy(); + } + }); + + it("saves a display name using the stable identifier", async () => { + render(); + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "审查员 🦌" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + name: "reviewer", + request: expect.objectContaining({ display_name: "审查员 🦌" }), + }), + ), + ); + }); + + it("clears a blank name without renaming the agent", async () => { + render(); + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: " " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + name: "reviewer", + request: expect.objectContaining({ display_name: null }), + }), + ), + ); + }); +});