mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 07:28:44 +00:00
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 <willem.jiang@gmail.com>
This commit is contained in:
parent
5e85f8a183
commit
ac2b6415ea
14
README.md
14
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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:<path> exists|non-empty`, `file_written:<path>`, `tests_passed:<command>`); criteria are handed to the executor and appended to the subagent's task message as untrusted data (see `subagents/report_contract.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
|
||||
|
||||
111
backend/tests/test_agent_display_name.py
Normal file
111
backend/tests/test_agent_display_name.py
Normal file
@ -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")
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -275,7 +275,9 @@ export default function AgentChatPage() {
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
|
||||
{agent?.name ?? agent_name}
|
||||
{agent?.display_name?.length
|
||||
? agent.display_name
|
||||
: (agent?.name ?? agent_name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) {
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<TruncatedTooltip text={agent.name}>
|
||||
<TruncatedTooltip text={displayName}>
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name}
|
||||
{displayName}
|
||||
</CardTitle>
|
||||
</TruncatedTooltip>
|
||||
{agent.model && (
|
||||
@ -214,7 +217,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* 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 && (
|
||||
<AgentSettingsDialog
|
||||
|
||||
@ -48,7 +48,7 @@ interface AgentSettingsDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a custom agent's model behavior (issue #4336): default model plus the
|
||||
* Edits a custom agent's display name and model behavior: default model plus the
|
||||
* per-agent temperature / max_tokens overrides and thinking / reasoning
|
||||
* defaults. Persists through `PUT /api/agents/{name}`; changes take effect on
|
||||
* the agent's next run.
|
||||
@ -62,6 +62,7 @@ export function AgentSettingsDialog({
|
||||
const { models } = useModels();
|
||||
const { subagents } = useSubagents();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
|
||||
|
||||
const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE);
|
||||
const [temperature, setTemperature] = useState(
|
||||
@ -117,6 +118,10 @@ export function AgentSettingsDialog({
|
||||
}, [selectableSubagents, selectedSubagents]);
|
||||
|
||||
async function handleSave() {
|
||||
if ([...displayName.trim()].length > 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({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-1">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="agent-display-name" className="text-sm font-medium">
|
||||
{t.agents.settingsDisplayName}
|
||||
</label>
|
||||
<Input
|
||||
id="agent-display-name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={agent.name}
|
||||
aria-describedby="agent-display-name-hint"
|
||||
/>
|
||||
<p
|
||||
id="agent-display-name-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t.agents.settingsDisplayNameHint} ({agent.name}){" · "}
|
||||
{[...displayName.trim()].length}/100
|
||||
</p>
|
||||
</div>
|
||||
{/* Default model */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 的正整数",
|
||||
},
|
||||
|
||||
@ -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<string, unknown> | undefined;
|
||||
const agent = { ...MOCK_AGENTS[0]!, display_name: "" };
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [agent],
|
||||
runStreamHandler: async (route) => {
|
||||
streamBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
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 });
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
|
||||
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(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
|
||||
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(
|
||||
<AgentWelcome agent={agent} agentName="reviewer" />,
|
||||
);
|
||||
expect(screen.getByText("代码审查助手")).toBeTruthy();
|
||||
for (const display_name of [undefined, null, ""]) {
|
||||
rerender(
|
||||
<AgentWelcome
|
||||
agent={{ ...agent, display_name }}
|
||||
agentName="reviewer"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("reviewer")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves a display name using the stable identifier", async () => {
|
||||
render(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
|
||||
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(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
|
||||
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 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user