Files

955 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from uuid import uuid4
import pytest
import pytest_asyncio
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.agents import (
AgentsListResponse,
_agent_list_cache,
_agent_list_inflight,
_agent_owner_name_cache,
_backfill_listing_cache,
_cached_paginated_agent_list,
_load_config_extras,
_resolve_usernames,
_write_agent_config,
)
from app.gateway.services import build_run_config
from deerflow.config import paths as paths_module
from deerflow.config.agents_config import AgentConfig, normalize_agent_chat_link
from deerflow.persistence.agents import AgentRepository, MemoryAgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.base import Base
from deerflow.persistence.positions.memory import MemoryPositionStore
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.tags.memory import MemoryTagStore
from deerflow.services.position_sync import PositionSyncService
@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
yield tmp_path
monkeypatch.setattr(paths_module, "_paths", None)
@pytest_asyncio.fixture
async def sql_session_factory(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'agent-tests.db'}")
async with engine.begin() as conn:
# Importing PositionSyncService above registers the agent / skill /
# scheduled-task rows it materializes into.
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
try:
yield sf
finally:
await engine.dispose()
async def _add_sql_agent(sf, *, agent_id: str, user_id: str | None) -> None:
async with sf() as session:
session.add(
AgentRow(
id=agent_id,
name=agent_id,
description="",
user_id=user_id,
published=True,
is_private=False,
square_id="",
)
)
await session.commit()
@pytest.mark.asyncio
async def test_sidebar_agent_query_returns_only_lightweight_visible_fields(sql_session_factory):
await _add_sql_agent(sql_session_factory, agent_id="owned-agent", user_id="user-1")
await _add_sql_agent(sql_session_factory, agent_id="public-agent", user_id=None)
agents = await AgentRepository(sql_session_factory).list_sidebar("user-1", limit=3)
assert agents == [
{"id": "owned-agent", "name": "owned-agent"},
{"id": "public-agent", "name": "public-agent"},
]
assert all(set(item) == {"id", "name"} for item in agents)
@pytest.mark.asyncio
async def test_memory_sidebar_query_returns_only_lightweight_fields():
store = MemoryAgentStore()
await store.create({"id": "owned-agent", "name": "我的智能体", "user_id": "user-1"})
agents = await store.list_sidebar("user-1", limit=3)
assert agents == [{"id": "owned-agent", "name": "我的智能体"}]
async def _add_sql_favorite(sf, *, user_id: str, agent_id: str, origin: str) -> None:
async with sf() as session:
session.add(
AgentFavoriteRow(
id=uuid4().hex,
user_id=user_id,
agent_id=agent_id,
origin=origin,
position_id="default" if origin == "position" else None,
)
)
await session.commit()
async def _add_sql_chain(sf, *, chain_id: str, agent_ids: list[str]) -> None:
async with sf() as session:
session.add(
RoundtableChainRow(
id=chain_id,
user_id="chain-owner",
title=chain_id,
description="",
seats=json.dumps(
[{"agent_id": agent_id, "name": agent_id, "description": ""} for agent_id in agent_ids],
ensure_ascii=False,
),
)
)
await session.commit()
async def _sql_favorite_rows(sf, user_id: str) -> set[tuple[str, str]]:
async with sf() as session:
rows = (
await session.execute(
select(AgentFavoriteRow.agent_id, AgentFavoriteRow.origin).where(
AgentFavoriteRow.user_id == user_id
)
)
).all()
return {(agent_id, origin) for agent_id, origin in rows}
async def _default_positions() -> MemoryPositionStore:
positions = MemoryPositionStore()
await positions.create_position(
{
"id": "default",
"name": "Default",
"description": "",
"is_default": True,
}
)
return positions
@pytest.mark.asyncio
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
store = MemoryAgentStore()
chinese_name = "\u4e2d\u6587\u52a9\u624b"
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
assert user_c_ids == {"builtin-agent", "published-b"}
@pytest.mark.asyncio
async def test_position_sync_default_does_not_collect_builtin_agents(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-manual",
origin="user",
)
sync = PositionSyncService(
sql_session_factory,
await _default_positions(),
MemoryTagStore(),
)
await sync.sync_user("user-a")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("builtin-manual", "user")
}
@pytest.mark.asyncio
async def test_position_sync_tagged_agents_are_not_auto_favorited(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-tagged", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="custom-tagged", user_id="owner")
await _add_sql_agent(sql_session_factory, agent_id="chain-tagged", user_id="owner")
await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-tagged"])
positions = await _default_positions()
tags = MemoryTagStore()
await tags.create_tag({"id": "agent-tag", "name": "Agent tag", "tag_type": "agent"})
await tags.set_target_tags("agent", "builtin-tagged", ["agent-tag"])
await tags.set_target_tags("agent", "custom-tagged", ["agent-tag"])
await tags.set_target_tags("agent", "chain-tagged", ["agent-tag"])
await positions.set_tag_bindings("default", {"agent": ["agent-tag"]})
sync = PositionSyncService(sql_session_factory, positions, tags)
await sync.sync_user("user-a")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("custom-tagged", "position")
}
@pytest.mark.asyncio
async def test_mine_scope_ignores_auto_agent_favorites_but_keeps_manual_builtin(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="custom-auto", user_id="owner")
await _add_sql_agent(sql_session_factory, agent_id="chain-auto", user_id="owner")
await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-auto"])
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-manual",
origin="user",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="custom-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="chain-auto",
origin="position",
)
store = AgentRepository(sql_session_factory)
rows, total = await store.list_paginated(
user_id="user-a",
scope="mine",
search=None,
tag_ids=None,
page=1,
page_size=20,
)
assert total == 2
assert {row["id"] for row in rows} == {"builtin-manual", "custom-auto"}
assert await store.list_favorite_ids("user-a") == {"builtin-manual", "custom-auto"}
@pytest.mark.asyncio
async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-agent", user_id=None)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-agent",
origin="position",
)
store = AgentRepository(sql_session_factory)
assert await store.list_favorite_ids("user-a") == set()
await store.add_favorite("user-a", "builtin-agent")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("builtin-agent", "user")
}
assert await store.list_favorite_ids("user-a") == {"builtin-agent"}
rows, total = await store.list_paginated(
user_id="user-a",
scope="mine",
search=None,
tag_ids=None,
page=1,
page_size=20,
)
assert total == 1
assert [row["id"] for row in rows] == ["builtin-agent"]
async def _add_sql_hidden_agent(sf, *, agent_id: str, user_id: str) -> None:
"""他人创建、普通列表不可见的条目(未发布 / 私密广场)。"""
async with sf() as session:
session.add(
AgentRow(
id=agent_id,
name=agent_id,
description="",
user_id=user_id,
published=not agent_id.startswith("hidden-unpublished"),
is_private=agent_id.startswith("hidden-private"),
square_id="",
)
)
await session.commit()
@pytest.mark.asyncio
async def test_sql_list_paginated_admin_pages_every_agent(sql_session_factory) -> None:
"""管理端分页(GET /api/agents?admin=true&page=N 的 store 支撑):
admin=True 跳过可见性过滤 — 内置 / 他人未发布 / 私密广场条目全部参与分页,
搜索在服务端过滤,越界页返回空页而非报错。"""
await _add_sql_agent(sql_session_factory, agent_id="builtin-1", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="owned-a", user_id="user-a")
await _add_sql_hidden_agent(sql_session_factory, agent_id="hidden-unpublished", user_id="user-b")
await _add_sql_hidden_agent(sql_session_factory, agent_id="hidden-private", user_id="user-b")
store = AgentRepository(sql_session_factory)
# 非 admin 对照:user-a 只看得到自己 + 内置(user-b 的两条不可见)。
_, visible_total = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=1,
page_size=20,
)
assert visible_total == 2
page1, total = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=1,
page_size=2,
admin=True,
sort="time_asc",
)
page2, total2 = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=2,
page_size=2,
admin=True,
sort="time_asc",
)
assert total == 4 and total2 == 4
assert len(page1) == 2 and len(page2) == 2
assert {r["id"] for r in page1} | {r["id"] for r in page2} == {
"builtin-1",
"owned-a",
"hidden-unpublished",
"hidden-private",
}
# 越界页:空页但 total 不变(前端据此收敛回末页)。
page3, total3 = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=3,
page_size=2,
admin=True,
)
assert total3 == 4 and page3 == []
# 服务端搜索:管理端目录同样按名称过滤。
hits, hits_total = await store.list_paginated(
user_id="user-a",
scope=None,
search="hidden",
tag_ids=None,
page=1,
page_size=20,
admin=True,
)
assert hits_total == 2
assert {r["id"] for r in hits} == {"hidden-unpublished", "hidden-private"}
@pytest.mark.asyncio
async def test_memory_store_admin_pagination() -> None:
"""内存版 store 的 admin 分页行为与 SQL 版一致(单元测试/回退路径)。"""
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-1", "name": "Built In"})
await store.create({"id": "owned-a", "name": "Owned A", "user_id": "user-a"})
await store.create(
{"id": "hidden-b", "name": "Hidden B", "user_id": "user-b", "published": False}
)
await store.create(
{
"id": "private-b",
"name": "Private B",
"user_id": "user-b",
# 内存版 _is_visible 不看 is_private,这里保持未发布,
# 使「非 admin 对照 = 内置 + 自己」在两种 store 语义下都成立。
"published": False,
"is_private": True,
}
)
_, visible_total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None, page=1, page_size=20
)
assert visible_total == 2
page1, total = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=1,
page_size=3,
admin=True,
sort="time_asc",
)
page2, total2 = await store.list_paginated(
user_id="user-a",
scope=None,
search=None,
tag_ids=None,
page=2,
page_size=3,
admin=True,
sort="time_asc",
)
assert total == 4 and total2 == 4
assert len(page1) == 3 and len(page2) == 1
assert {r["id"] for r in page1} | {r["id"] for r in page2} == {
"builtin-1",
"owned-a",
"hidden-b",
"private-b",
}
async def _add_sql_hidden_flagged_agent(sf, *, agent_id: str, user_id: str | None) -> None:
"""创建时即 hidden=True 的条目(绕过 API,直接落库)。"""
async with sf() as session:
session.add(
AgentRow(
id=agent_id,
name=agent_id,
description="",
user_id=user_id,
published=True,
is_private=False,
square_id="",
hidden=True,
)
)
await session.commit()
@pytest.mark.asyncio
async def test_sql_hidden_agent_vanishes_from_non_admin_listings_but_stays_usable(
sql_session_factory,
) -> None:
"""管理端隐藏开关的 store 语义:hidden=True 后从所有非 admin 列表消失
(list_visible / 各分页 scope / selector / 私密广场),但单条访问
(get_visible)不受影响,admin 列表仍可见且可按 hidden 筛选。"""
await _add_sql_agent(sql_session_factory, agent_id="builtin-1", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="owned-a", user_id="user-a")
await _add_sql_agent(sql_session_factory, agent_id="published-b", user_id="user-b")
await _add_sql_hidden_flagged_agent(sql_session_factory, agent_id="flagged-1", user_id="user-b")
store = AgentRepository(sql_session_factory)
# list_visible(默认列表):flagged-1 消失
visible_ids = {r["id"] for r in await store.list_visible("user-a")}
assert visible_ids == {"builtin-1", "owned-a", "published-b"}
# 非_admin 分页(默认 scope 与 square/mine scope 一致排除)
for scope in (None, "square", "mine"):
rows, total = await store.list_paginated(
user_id="user-a", scope=scope, search=None, tag_ids=None, page=1, page_size=20,
)
assert "flagged-1" not in {r["id"] for r in rows}, scope
# 属主自己也看不到自己被隐藏的智能体(非 admin 一律过滤)
rows, _ = await store.list_paginated(
user_id="user-b", scope="mine", search=None, tag_ids=None, page=1, page_size=20,
)
assert "flagged-1" not in {r["id"] for r in rows}
# selector(featured)与私密广场同样排除
await store.set_featured("flagged-1", 1)
featured_ids = {r["id"] for r in await store.list_featured("user-a")}
assert "flagged-1" not in featured_ids
assert await store.update_any("flagged-1", {"is_private": True}) is not None
private_ids = {r["id"] for r in await store.list_private()}
assert "flagged-1" not in private_ids
# 单条访问不受影响(隐藏不是禁用:既有会话/详情继续可用)
assert await store.get_visible("flagged-1", "user-a") is not None
assert await store.get_visible("flagged-1", "user-b") is not None
# admin:全量可见,且可按 hidden 筛选
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None,
page=1, page_size=20, admin=True,
)
assert total == 4 and "flagged-1" in {r["id"] for r in rows}
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None,
page=1, page_size=20, admin=True, hidden=True,
)
assert total == 1 and {r["id"] for r in rows} == {"flagged-1"}
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None,
page=1, page_size=20, admin=True, hidden=False,
)
assert total == 3 and "flagged-1" not in {r["id"] for r in rows}
# 开关往返:取消隐藏后回到列表。注:flagged-1 中途被挪进了私密广场
#(is_private=True),普通列表本就不含私密条目,先移回公共广场再断言。
assert (await store.update_any("flagged-1", {"hidden": False}))["hidden"] is False
await store.update_any("flagged-1", {"is_private": False})
visible_ids = {r["id"] for r in await store.list_visible("user-a")}
assert "flagged-1" in visible_ids
@pytest.mark.asyncio
async def test_memory_hidden_agent_filtered_from_non_admin_listings() -> None:
"""内存版 store 的 hidden 行为与 SQL 版一致。"""
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-1", "name": "Built In"})
await store.create({"id": "owned-a", "name": "Owned A", "user_id": "user-a"})
await store.create(
{"id": "flagged-1", "name": "Flagged", "user_id": "user-b", "published": True, "hidden": True}
)
assert {r["id"] for r in await store.list_visible("user-a")} == {"builtin-1", "owned-a"}
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None, page=1, page_size=20,
)
assert total == 2 and "flagged-1" not in {r["id"] for r in rows}
# 单条访问 + admin 可见 + hidden 筛选
assert await store.get_visible("flagged-1", "user-a") is not None
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None,
page=1, page_size=20, admin=True,
)
assert total == 3 and "flagged-1" in {r["id"] for r in rows}
rows, total = await store.list_paginated(
user_id="user-a", scope=None, search=None, tag_ids=None,
page=1, page_size=20, admin=True, hidden=True,
)
assert total == 1 and {r["id"] for r in rows} == {"flagged-1"}
# 开关往返
assert (await store.update_any("flagged-1", {"hidden": False}))["hidden"] is False
assert "flagged-1" in {r["id"] for r in await store.list_visible("user-a")}
@pytest.mark.asyncio
async def test_agent_store_only_owner_can_update_or_delete() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
updated = await store.update("private-a", "user-a", {"name": "Renamed"})
assert updated is not None
assert updated["name"] == "Renamed"
assert await store.delete("private-a", "user-b") is False
assert await store.delete("private-a", "user-a") is True
@pytest.mark.asyncio
async def test_delete_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# delete_builtin refuses to touch a user-owned agent
assert await store.delete_builtin("owned-agent") is False
assert await store.get_any("owned-agent") is not None
# delete_builtin succeeds on a built-in
assert await store.delete_builtin("builtin-agent") is True
assert await store.get_any("builtin-agent") is None
# And no-ops on a missing id
assert await store.delete_builtin("does-not-exist") is False
@pytest.mark.asyncio
async def test_update_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# update_builtin succeeds on a built-in agent
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
assert updated is not None
assert updated["name"] == "Renamed by admin"
# update_builtin refuses to touch a user-owned agent (returns None)
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
# And on a missing agent
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
@pytest.mark.asyncio
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
"""Route helper: admin gets edit access to any agent; non-admin still 403."""
from fastapi import HTTPException
from app.gateway.routers.agents import _get_editable_or_raise
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# Owner edits their own agent → returns ('owner', record)
record, mode = await _get_editable_or_raise(
store, "owned-agent", "user-a", is_admin=False
)
assert mode == "owner" and record["id"] == "owned-agent"
# Admin edits a built-in agent → returns ('admin', record)
record, mode = await _get_editable_or_raise(
store, "builtin-agent", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "builtin-agent"
# Non-admin tries to edit a built-in → 403
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Admin edits someone else's custom agent → returns ('admin', record).
# Behavior changed: admins manage the full agent catalog (built-in + any
# user's agents), not just built-ins. Non-admins are still owner-only.
await store.create(
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
)
record, mode = await _get_editable_or_raise(
store, "published-other", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "published-other"
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
assert config["configurable"]["thread_id"] == "thread-1"
assert config["configurable"]["agent_id"] == "agent-123"
assert "agent_name" not in config["configurable"]
def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
agent_id = "agent-001"
_write_agent_config(
agent_id,
name="中文助手",
description="desc",
skills=["s1", "s2"],
model="gpt-x",
tool_groups=["tg-a"],
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["id"] == agent_id
assert raw["name"] == "中文助手"
assert raw["description"] == "desc"
assert raw["skills"] == ["s1", "s2"]
assert raw["model"] == "gpt-x"
assert raw["model_prompt_suffix_enabled"] is True
assert raw["tool_groups"] == ["tg-a"]
def test_write_agent_config_persists_prompt_suffix_switch(isolated_paths) -> None:
_write_agent_config(
"agent-suffix-off",
name="助手",
description="",
model_prompt_suffix_enabled=False,
)
assert _load_config_extras("agent-suffix-off")["model_prompt_suffix_enabled"] is False
def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
agent_id = "agent-002"
_write_agent_config(
agent_id,
name="agent-002",
description="",
skills=["s1"],
model=None,
tool_groups=None,
)
extras = _load_config_extras(agent_id)
assert extras == {
"skills": ["s1"],
"model": None,
"model_prompt_suffix_enabled": True,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"llmwiki_knowledge_base_ids": None,
"chat_link": None,
}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
assert extras == {
"skills": None,
"model": None,
"model_prompt_suffix_enabled": True,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"llmwiki_knowledge_base_ids": None,
"chat_link": None,
}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
agent_id = "agent-cq-1"
# Structured {title, prompt} questions round-trip as clean dict mappings.
questions = [
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
{"title": "上传文件", "prompt": "如何上传文件?"},
]
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == questions
extras = _load_config_extras(agent_id)
assert [q.model_dump() for q in extras["common_questions"]] == questions
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
agent_id = "agent-cq-legacy"
_write_agent_config(
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == [
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
]
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
agent_id = "agent-cq-2"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "common_questions" not in raw
assert _load_config_extras(agent_id)["common_questions"] is None
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
agent_id = "agent-cq-3"
_write_agent_config(agent_id, name="x", description="", common_questions=[])
assert _load_config_extras(agent_id)["common_questions"] == []
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
agent_id = "agent-003"
_write_agent_config(agent_id, name="x", description="", skills=[])
extras = _load_config_extras(agent_id)
assert extras["skills"] == []
def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
agent_id = "agent-004"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "skills" not in raw
assert "model" not in raw
assert "tool_groups" not in raw
assert "chat_link" not in raw
def test_write_agent_config_persists_chat_link_and_drops_blank(isolated_paths) -> None:
agent_id = "agent-chat-link"
link = {
"url": "https://example.test/desk",
"open_mode": "self",
"route_params": [
{"key": "userName", "paramType": "login", "source": "userName"},
{"key": "theme", "paramType": "theme", "themeValue": "red=red;dark-blue=dark"},
],
}
_write_agent_config(agent_id, name="助手", description="", chat_link=link)
extras = _load_config_extras(agent_id)
assert extras["chat_link"]["url"] == "https://example.test/desk"
assert extras["chat_link"]["open_mode"] == "self"
assert extras["chat_link"]["route_params"][0]["key"] == "userName"
assert extras["chat_link"]["route_params"][1]["paramType"] == "theme"
_write_agent_config(agent_id, name="助手", description="", chat_link=None)
raw = yaml.safe_load((paths_module.get_paths().agent_dir(agent_id) / "config.yaml").read_text(encoding="utf-8"))
assert "chat_link" not in raw
assert _load_config_extras(agent_id)["chat_link"] is None
def test_normalize_agent_chat_link_rejects_unsafe_urls() -> None:
assert normalize_agent_chat_link({"url": ""}) is None
assert normalize_agent_chat_link({"url": "javascript:alert(1)"}, strict=False) is None
with pytest.raises(ValueError):
normalize_agent_chat_link({"url": "javascript:alert(1)"}, strict=True)
kept = normalize_agent_chat_link({"url": "/page/workspace/agents", "open_mode": "blank"})
assert kept is not None
assert kept["url"] == "/page/workspace/agents"
loaded = AgentConfig(name="x", chat_link={"url": "javascript:alert(1)"})
assert loaded.chat_link is None
@pytest.mark.asyncio
async def test_backfill_listing_cache_mirrors_chat_link() -> None:
store = MemoryAgentStore()
link = {"url": "https://example.test/a", "open_mode": "blank", "route_params": []}
cfg = AgentConfig(id="a1", name="Agent", chat_link=link)
await _backfill_listing_cache(store, [("a1", cfg)])
extras = await store.get_extras_for(["a1"])
assert extras["a1"]["chat_link"]["url"] == "https://example.test/a"
await _backfill_listing_cache(store, [("a1", cfg)])
assert (await store.get_extras_for(["a1"]))["a1"]["chat_link"]["url"] == "https://example.test/a"
@pytest.mark.asyncio
async def test_backfill_listing_cache_mirrors_config_values() -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
await _backfill_listing_cache(store, [("a1", cfg)])
extras = await store.get_extras_for(["a1"])
assert extras["a1"]["skills"] == ["s1", "s2"]
assert extras["a1"]["tool_groups"] == ["g1"]
assert extras["a1"]["model"] == "m1"
@pytest.mark.asyncio
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg)])
# On a second run with identical config, no re-write should occur.
calls = {"replace": 0, "set": 0}
orig_replace = store.replace_skills
orig_set = store.set_extras
async def _replace(agent_id, skills):
calls["replace"] += 1
await orig_replace(agent_id, skills)
async def _set(agent_id, *, tool_groups, model):
calls["set"] += 1
await orig_set(agent_id, tool_groups=tool_groups, model=model)
monkeypatch.setattr(store, "replace_skills", _replace)
monkeypatch.setattr(store, "set_extras", _set)
await _backfill_listing_cache(store, [("a1", cfg)])
assert calls == {"replace": 0, "set": 0}
# Changing the config triggers a re-write.
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg2)])
assert calls["replace"] == 1
@pytest.mark.asyncio
async def test_paginated_agent_list_coalesces_concurrent_reads_and_returns_copies() -> None:
_agent_list_cache.clear()
_agent_list_inflight.clear()
calls = 0
async def loader() -> AgentsListResponse:
nonlocal calls
calls += 1
await asyncio.sleep(0.01)
return AgentsListResponse(agents=[], total=3, page=1, page_size=20)
first, second = await asyncio.gather(
_cached_paginated_agent_list(("user", 1), loader),
_cached_paginated_agent_list(("user", 1), loader),
)
assert calls == 1
assert {first[1], second[1]} == {"未命中", "合并等待"}
first[0].total = 99
cached, cache_status = await _cached_paginated_agent_list(("user", 1), loader)
assert cache_status == "命中"
assert cached.total == 3
assert calls == 1
@pytest.mark.asyncio
async def test_agent_owner_usernames_are_batched_and_cached(monkeypatch) -> None:
from app.gateway import deps as deps_module
_agent_owner_name_cache.clear()
class Provider:
def __init__(self):
self.calls = []
async def get_users_by_ids(self, user_ids):
self.calls.append(list(user_ids))
return [SimpleNamespace(id="owner-1", email="alice@example.com")]
provider = Provider()
monkeypatch.setattr(deps_module, "get_local_provider", lambda: provider)
assert await _resolve_usernames({"owner-1", "deleted-user"}) == {"owner-1": "alice"}
assert await _resolve_usernames({"owner-1", "deleted-user"}) == {"owner-1": "alice"}
assert provider.calls == [["deleted-user", "owner-1"]]