fix: filter position-granted agent favorites
This commit is contained in:
1 parent
f3e5a3bb35
commit
fa04ab8b5a
4 files changed
+496
-85
No files matched your search
+95
-44
@@ -6,20 +6,21 @@ from datetime import UTC, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import and_, case, delete, func, or_, select
|
from sqlalchemy import and_, case, delete, func, literal, not_, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.config.agent_name_desensitize import source_terms_for_needle
|
from deerflow.config.agent_name_desensitize import source_terms_for_needle
|
||||||
from deerflow.persistence.agents.base import AgentStore
|
from deerflow.persistence.agents.base import AgentStore
|
||||||
from deerflow.persistence.agents.model import (
|
from deerflow.persistence.agents.model import (
|
||||||
AgentExtrasRow,
|
AgentExtrasRow,
|
||||||
AgentFavoriteRow,
|
AgentFavoriteRow,
|
||||||
AgentPinRow,
|
AgentPinRow,
|
||||||
AgentRow,
|
AgentRow,
|
||||||
AgentSkillRow,
|
AgentSkillRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.tags.model import TagAssignmentRow
|
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||||
|
from deerflow.persistence.tags.model import TagAssignmentRow
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
|
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
|
||||||
@@ -32,9 +33,33 @@ def _row_to_dict(row: AgentRow) -> dict[str, Any]:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class AgentRepository(AgentStore):
|
class AgentRepository(AgentStore):
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _business_chain_agent_exists(agent_id_col):
|
||||||
|
return (
|
||||||
|
select(RoundtableChainRow.id)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
RoundtableChainRow.seats.like(literal('%"agent_id": "') + agent_id_col + literal('"%')),
|
||||||
|
RoundtableChainRow.seats.like(literal('%"agent_id":"') + agent_id_col + literal('"%')),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _valid_favorite_filter(cls):
|
||||||
|
return or_(
|
||||||
|
AgentFavoriteRow.origin == "user",
|
||||||
|
and_(
|
||||||
|
AgentFavoriteRow.origin == "position",
|
||||||
|
AgentRow.user_id.is_not(None),
|
||||||
|
not_(cls._business_chain_agent_exists(AgentFavoriteRow.agent_id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _visible_filter(user_id: str):
|
def _visible_filter(user_id: str):
|
||||||
@@ -52,15 +77,23 @@ class AgentRepository(AgentStore):
|
|||||||
``published=false``, so this clause cannot lean on ``published``;
|
``published=false``, so this clause cannot lean on ``published``;
|
||||||
- favorited — the "我的" listing keeps favorited agents even after
|
- favorited — the "我的" listing keeps favorited agents even after
|
||||||
the owner unpublishes them, so reads keep honoring the favorite.
|
the owner unpublishes them, so reads keep honoring the favorite.
|
||||||
"""
|
"""
|
||||||
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
|
valid_favorite_exists = (
|
||||||
return or_(
|
select(AgentFavoriteRow.id)
|
||||||
AgentRow.user_id.is_(None),
|
.where(
|
||||||
AgentRow.user_id == user_id,
|
AgentFavoriteRow.user_id == user_id,
|
||||||
AgentRow.published.is_(True),
|
AgentFavoriteRow.agent_id == AgentRow.id,
|
||||||
AgentRow.is_private.is_(True),
|
AgentRepository._valid_favorite_filter(),
|
||||||
AgentRow.id.in_(fav_subq),
|
)
|
||||||
)
|
.exists()
|
||||||
|
)
|
||||||
|
return or_(
|
||||||
|
AgentRow.user_id.is_(None),
|
||||||
|
AgentRow.user_id == user_id,
|
||||||
|
AgentRow.published.is_(True),
|
||||||
|
AgentRow.is_private.is_(True),
|
||||||
|
valid_favorite_exists,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _listing_filter(user_id: str):
|
def _listing_filter(user_id: str):
|
||||||
@@ -268,11 +301,20 @@ class AgentRepository(AgentStore):
|
|||||||
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
|
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
|
||||||
AgentRow.is_private.is_(False),
|
AgentRow.is_private.is_(False),
|
||||||
)
|
)
|
||||||
if scope == "mine":
|
if scope == "mine":
|
||||||
# owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a
|
# owned OR valid favorites. Built-in agents and business-chain
|
||||||
# single row per agent even when it is both owned and favorited.
|
# seats only enter "mine" when explicitly favorited by the user;
|
||||||
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
|
# the original position-granted behavior stays for ordinary agents.
|
||||||
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
|
valid_favorite_exists = (
|
||||||
|
select(AgentFavoriteRow.id)
|
||||||
|
.where(
|
||||||
|
AgentFavoriteRow.user_id == user_id,
|
||||||
|
AgentFavoriteRow.agent_id == AgentRow.id,
|
||||||
|
self._valid_favorite_filter(),
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
return or_(AgentRow.user_id == user_id, valid_favorite_exists)
|
||||||
if scope == "private":
|
if scope == "private":
|
||||||
return AgentRow.is_private.is_(True)
|
return AgentRow.is_private.is_(True)
|
||||||
if scope == "builtin":
|
if scope == "builtin":
|
||||||
@@ -437,18 +479,22 @@ class AgentRepository(AgentStore):
|
|||||||
|
|
||||||
# --- Favorites ("收藏") ---
|
# --- Favorites ("收藏") ---
|
||||||
|
|
||||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(AgentFavoriteRow.id)
|
select(AgentFavoriteRow)
|
||||||
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
|
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return
|
if existing.origin != "user" or existing.position_id is not None:
|
||||||
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
|
existing.origin = "user"
|
||||||
|
existing.position_id = None
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -467,12 +513,17 @@ class AgentRepository(AgentStore):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return (result.rowcount or 0) > 0
|
return (result.rowcount or 0) > 0
|
||||||
|
|
||||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
|
select(AgentFavoriteRow.agent_id)
|
||||||
)
|
.join(AgentRow, AgentRow.id == AgentFavoriteRow.agent_id)
|
||||||
return {row[0] for row in result.all()}
|
.where(
|
||||||
|
AgentFavoriteRow.user_id == user_id,
|
||||||
|
self._valid_favorite_filter(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"""Stop position-granted favorites from collecting built-ins/chain seats.
|
||||||
|
|
||||||
|
Built-in agents and agents used as business-chain seats should only enter
|
||||||
|
"mine" when a user explicitly clicks the favorite action. This migration
|
||||||
|
removes historical automatic ``origin='position'`` rows for those agents while
|
||||||
|
preserving user-created favorites (``origin='user'``) and ordinary
|
||||||
|
position-granted agent favorites.
|
||||||
|
|
||||||
|
Revision ID: 20260701_01
|
||||||
|
Revises: 20260625_04
|
||||||
|
Create Date: 2026-07-01
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
revision = "20260701_01"
|
||||||
|
down_revision = "20260625_04"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not (
|
||||||
|
_has_table("agent_favorites")
|
||||||
|
and _has_column("agent_favorites", "origin")
|
||||||
|
and _has_column("agent_favorites", "agent_id")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites = sa.table(
|
||||||
|
"agent_favorites",
|
||||||
|
sa.column("agent_id", sa.String(64)),
|
||||||
|
sa.column("origin", sa.String(16)),
|
||||||
|
)
|
||||||
|
cleanup_conditions = []
|
||||||
|
if _has_table("agents") and _has_column("agents", "user_id"):
|
||||||
|
agents = sa.table(
|
||||||
|
"agents",
|
||||||
|
sa.column("id", sa.String(64)),
|
||||||
|
sa.column("user_id", sa.String(64)),
|
||||||
|
)
|
||||||
|
cleanup_conditions.append(
|
||||||
|
favorites.c.agent_id.in_(sa.select(agents.c.id).where(agents.c.user_id.is_(None)))
|
||||||
|
)
|
||||||
|
if _has_table("roundtable_chains") and _has_column("roundtable_chains", "seats"):
|
||||||
|
chains = sa.table(
|
||||||
|
"roundtable_chains",
|
||||||
|
sa.column("seats", sa.Text()),
|
||||||
|
)
|
||||||
|
cleanup_conditions.append(
|
||||||
|
sa.select(sa.literal(1))
|
||||||
|
.select_from(chains)
|
||||||
|
.where(
|
||||||
|
sa.or_(
|
||||||
|
chains.c.seats.like(sa.literal('%"agent_id": "') + favorites.c.agent_id + sa.literal('"%')),
|
||||||
|
chains.c.seats.like(sa.literal('%"agent_id":"') + favorites.c.agent_id + sa.literal('"%')),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if not cleanup_conditions:
|
||||||
|
return
|
||||||
|
|
||||||
|
op.get_bind().execute(
|
||||||
|
favorites.delete().where(
|
||||||
|
favorites.c.origin == "position",
|
||||||
|
sa.or_(*cleanup_conditions),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Data cleanup only; removed auto-grants are not reconstructable.
|
||||||
|
pass
|
||||||
+49
-7
@@ -12,7 +12,8 @@ Resolution rule (per resource type):
|
|||||||
* For a resource type **with** bound tags → the union of resources carrying any
|
* For a resource type **with** bound tags → the union of resources carrying any
|
||||||
of those tags.
|
of those tags.
|
||||||
* For a resource type **without** bound tags → the *default set*:
|
* For a resource type **without** bound tags → the *default set*:
|
||||||
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
- ``agent`` -> built-ins first, then filtered out of
|
||||||
|
position-granted favorites
|
||||||
- ``scheduled_task`` → empty (no built-in tasks)
|
- ``scheduled_task`` → empty (no built-in tasks)
|
||||||
- ``skill`` → empty (the 广场 still shows every visible skill;
|
- ``skill`` → empty (the 广场 still shows every visible skill;
|
||||||
a position only *adds* its tagged skills to the member's "我的技能")
|
a position only *adds* its tagged skills to the member's "我的技能")
|
||||||
@@ -24,9 +25,12 @@ Resolution rule (per resource type):
|
|||||||
|
|
||||||
Materialization (``sync_user``) only ever touches rows it created itself
|
Materialization (``sync_user``) only ever touches rows it created itself
|
||||||
(``origin='position'``), so a user's own favorites / subscriptions are never
|
(``origin='position'``), so a user's own favorites / subscriptions are never
|
||||||
clobbered. ``skill`` is materialized into ``skill_favorites`` exactly like
|
clobbered. ``agent`` keeps the original position-granted behavior for ordinary
|
||||||
``agent`` → ``agent_favorites``: the tagged skills are *added* to the user's
|
tagged agents, but filters out built-ins and business-chain seats; those only
|
||||||
"我的技能" while the browse-all 广场 stays complete (it is **not** restricted).
|
enter "mine" after an explicit user favorite. ``skill`` is still materialized
|
||||||
|
into ``skill_favorites`` so tagged skills are added to the user's "mine"
|
||||||
|
collection while the browse-all square stays complete (it is **not**
|
||||||
|
restricted).
|
||||||
Only recommended-question visibility is computed live by :meth:`resolve_visible`
|
Only recommended-question visibility is computed live by :meth:`resolve_visible`
|
||||||
and is *not* materialized. (The admin-configured prompts shown to users ARE the
|
and is *not* materialized. (The admin-configured prompts shown to users ARE the
|
||||||
recommended questions — there is no separate prompt-template store.)
|
recommended questions — there is no separate prompt-template store.)
|
||||||
@@ -34,6 +38,7 @@ recommended questions — there is no separate prompt-template store.)
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -43,13 +48,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
|
|
||||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||||
from deerflow.persistence.positions.base import PositionStore
|
from deerflow.persistence.positions.base import PositionStore
|
||||||
|
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
||||||
from deerflow.persistence.skills.model import SkillFavoriteRow
|
from deerflow.persistence.skills.model import SkillFavoriteRow
|
||||||
from deerflow.persistence.tags.base import TagStore
|
from deerflow.persistence.tags.base import TagStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Materialized into "我的" tables by sync_user.
|
# Reconciled by sync_user. ``agent`` preserves ordinary position-granted rows,
|
||||||
|
# but filters built-ins and business-chain seats.
|
||||||
_MATERIALIZED_TYPES = ("agent", "skill", "scheduled_task")
|
_MATERIALIZED_TYPES = ("agent", "skill", "scheduled_task")
|
||||||
# Resolved live at query time (visibility filters), never materialized.
|
# Resolved live at query time (visibility filters), never materialized.
|
||||||
_VISIBILITY_TYPES = ("recommended_question",)
|
_VISIBILITY_TYPES = ("recommended_question",)
|
||||||
@@ -93,8 +100,12 @@ class PositionSyncService:
|
|||||||
bindings = await self._positions.get_tag_bindings(pid) if pid else {}
|
bindings = await self._positions.get_tag_bindings(pid) if pid else {}
|
||||||
tag_ids = bindings.get(resource_type) or []
|
tag_ids = bindings.get(resource_type) or []
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
return set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
|
target = set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
|
||||||
return await self._default_set(resource_type, session)
|
else:
|
||||||
|
target = await self._default_set(resource_type, session)
|
||||||
|
if resource_type == "agent":
|
||||||
|
return await self._filter_materializable_agents(target, session)
|
||||||
|
return target
|
||||||
|
|
||||||
async def _default_set(self, resource_type: str, session: AsyncSession) -> set[str]:
|
async def _default_set(self, resource_type: str, session: AsyncSession) -> set[str]:
|
||||||
if resource_type == "agent":
|
if resource_type == "agent":
|
||||||
@@ -103,6 +114,37 @@ class PositionSyncService:
|
|||||||
# scheduled_task (and any other materialized type) defaults to empty.
|
# scheduled_task (and any other materialized type) defaults to empty.
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
async def _filter_materializable_agents(self, agent_ids: set[str], session: AsyncSession) -> set[str]:
|
||||||
|
if not agent_ids:
|
||||||
|
return set()
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(AgentRow.id).where(
|
||||||
|
AgentRow.id.in_(agent_ids),
|
||||||
|
AgentRow.user_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
candidate_ids = {r[0] for r in rows}
|
||||||
|
return candidate_ids - await self._business_chain_agent_ids(session)
|
||||||
|
|
||||||
|
async def _business_chain_agent_ids(self, session: AsyncSession) -> set[str]:
|
||||||
|
rows = (await session.execute(select(RoundtableChainRow.seats))).scalars()
|
||||||
|
agent_ids: set[str] = set()
|
||||||
|
for raw in rows:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
seats = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(seats, list):
|
||||||
|
continue
|
||||||
|
for seat in seats:
|
||||||
|
if isinstance(seat, dict) and isinstance(seat.get("agent_id"), str):
|
||||||
|
agent_ids.add(seat["agent_id"])
|
||||||
|
return agent_ids
|
||||||
|
|
||||||
# --------------------------------------------------------- materialize
|
# --------------------------------------------------------- materialize
|
||||||
|
|
||||||
async def sync_user(self, user_id: str) -> None:
|
async def sync_user(self, user_id: str) -> None:
|
||||||
|
|||||||
@@ -1,33 +1,131 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import json
|
||||||
import yaml
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.gateway.routers.agents import (
|
import pytest
|
||||||
_backfill_listing_cache,
|
import pytest_asyncio
|
||||||
_load_config_extras,
|
import yaml
|
||||||
_write_agent_config,
|
from sqlalchemy import select
|
||||||
)
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from app.gateway.services import build_run_config
|
|
||||||
from deerflow.config import paths as paths_module
|
from app.gateway.routers.agents import (
|
||||||
from deerflow.config.agents_config import AgentConfig
|
_backfill_listing_cache,
|
||||||
from deerflow.persistence.agents import MemoryAgentStore
|
_load_config_extras,
|
||||||
|
_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
|
||||||
|
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
|
@pytest.fixture
|
||||||
def isolated_paths(tmp_path, monkeypatch):
|
def isolated_paths(tmp_path, monkeypatch):
|
||||||
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
||||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||||
monkeypatch.setattr(paths_module, "_paths", None)
|
monkeypatch.setattr(paths_module, "_paths", None)
|
||||||
yield tmp_path
|
yield tmp_path
|
||||||
monkeypatch.setattr(paths_module, "_paths", None)
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
|
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
|
||||||
store = MemoryAgentStore()
|
store = MemoryAgentStore()
|
||||||
chinese_name = "\u4e2d\u6587\u52a9\u624b"
|
chinese_name = "\u4e2d\u6587\u52a9\u624b"
|
||||||
|
|
||||||
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
|
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": "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})
|
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
|
||||||
@@ -35,14 +133,144 @@ async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility
|
|||||||
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
|
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
|
||||||
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
|
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
|
||||||
|
|
||||||
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
|
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
|
||||||
assert user_c_ids == {"builtin-agent", "published-b"}
|
assert user_c_ids == {"builtin-agent", "published-b"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_agent_store_only_owner_can_update_or_delete() -> None:
|
async def test_position_sync_default_does_not_collect_builtin_agents(sql_session_factory) -> None:
|
||||||
store = MemoryAgentStore()
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
@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.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||||
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
|
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user