955 lines
35 KiB
Python
955 lines
35 KiB
Python
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"]]
|