From fa04ab8b5a3a9077597ccd72c84b5c599f574a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 1 Jul 2026 14:51:14 +0800 Subject: [PATCH] fix: filter position-granted agent favorites --- .../deerflow/persistence/agents/sql.py | 139 +++++--- .../20260701_01_agent_position_favorites.py | 90 ++++++ .../deerflow/services/position_sync.py | 56 +++- .../backend/tests/test_agents_backend.py | 296 ++++++++++++++++-- 4 files changed, 496 insertions(+), 85 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 4eeddb1f..c1e47b44 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -6,20 +6,21 @@ from datetime import UTC, datetime from typing import Any 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.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.persistence.agents.base import AgentStore -from deerflow.persistence.agents.model import ( - AgentExtrasRow, - AgentFavoriteRow, - AgentPinRow, - AgentRow, - AgentSkillRow, -) -from deerflow.persistence.tags.model import TagAssignmentRow +from deerflow.persistence.agents.model import ( + AgentExtrasRow, + AgentFavoriteRow, + AgentPinRow, + AgentRow, + AgentSkillRow, +) +from deerflow.persistence.roundtable_chains.model import RoundtableChainRow +from deerflow.persistence.tags.model import TagAssignmentRow def _row_to_dict(row: AgentRow) -> dict[str, Any]: @@ -32,9 +33,33 @@ def _row_to_dict(row: AgentRow) -> dict[str, Any]: return data -class AgentRepository(AgentStore): - def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: - self._sf = session_factory +class AgentRepository(AgentStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + 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 def _visible_filter(user_id: str): @@ -52,15 +77,23 @@ class AgentRepository(AgentStore): ``published=false``, so this clause cannot lean on ``published``; - favorited — the "我的" listing keeps favorited agents even after the owner unpublishes them, so reads keep honoring the favorite. - """ - fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - return or_( - AgentRow.user_id.is_(None), - AgentRow.user_id == user_id, - AgentRow.published.is_(True), - AgentRow.is_private.is_(True), - AgentRow.id.in_(fav_subq), - ) + """ + valid_favorite_exists = ( + select(AgentFavoriteRow.id) + .where( + AgentFavoriteRow.user_id == user_id, + AgentFavoriteRow.agent_id == AgentRow.id, + AgentRepository._valid_favorite_filter(), + ) + .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 def _listing_filter(user_id: str): @@ -268,11 +301,20 @@ class AgentRepository(AgentStore): or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)), AgentRow.is_private.is_(False), ) - if scope == "mine": - # owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a - # single row per agent even when it is both owned and favorited. - fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq)) + if scope == "mine": + # owned OR valid favorites. Built-in agents and business-chain + # seats only enter "mine" when explicitly favorited by the user; + # the original position-granted behavior stays for ordinary agents. + 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": return AgentRow.is_private.is_(True) if scope == "builtin": @@ -437,18 +479,22 @@ class AgentRepository(AgentStore): # --- Favorites ("收藏") --- - async def add_favorite(self, user_id: str, agent_id: str) -> None: - async with self._sf() as session: - existing = ( - await session.execute( - select(AgentFavoriteRow.id) - .where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id) - .limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return - session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) + async def add_favorite(self, user_id: str, agent_id: str) -> None: + async with self._sf() as session: + existing = ( + await session.execute( + select(AgentFavoriteRow) + .where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + if existing.origin != "user" or existing.position_id is not None: + 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: await session.commit() except IntegrityError: @@ -467,12 +513,17 @@ class AgentRepository(AgentStore): await session.commit() return (result.rowcount or 0) > 0 - async def list_favorite_ids(self, user_id: str) -> set[str]: - async with self._sf() as session: - result = await session.execute( - select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - ) - return {row[0] for row in result.all()} + async def list_favorite_ids(self, user_id: str) -> set[str]: + async with self._sf() as session: + result = await session.execute( + select(AgentFavoriteRow.agent_id) + .join(AgentRow, AgentRow.id == AgentFavoriteRow.agent_id) + .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 with self._sf() as session: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py new file mode 100644 index 00000000..264a12bf --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py index 2a6e86e8..23b4a8d9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py @@ -12,7 +12,8 @@ Resolution rule (per resource type): * For a resource type **with** bound tags → the union of resources carrying any of those tags. * 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) - ``skill`` → empty (the 广场 still shows every visible skill; 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 (``origin='position'``), so a user's own favorites / subscriptions are never -clobbered. ``skill`` is materialized into ``skill_favorites`` exactly like -``agent`` → ``agent_favorites``: the tagged skills are *added* to the user's -"我的技能" while the browse-all 广场 stays complete (it is **not** restricted). +clobbered. ``agent`` keeps the original position-granted behavior for ordinary +tagged agents, but filters out built-ins and business-chain seats; those only +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` and is *not* materialized. (The admin-configured prompts shown to users ARE the 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 +import json import logging import uuid 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.positions.base import PositionStore +from deerflow.persistence.roundtable_chains.model import RoundtableChainRow from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow from deerflow.persistence.skills.model import SkillFavoriteRow from deerflow.persistence.tags.base import TagStore 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") # Resolved live at query time (visibility filters), never materialized. _VISIBILITY_TYPES = ("recommended_question",) @@ -93,8 +100,12 @@ class PositionSyncService: bindings = await self._positions.get_tag_bindings(pid) if pid else {} tag_ids = bindings.get(resource_type) or [] if tag_ids: - return set(await self._tags.list_targets_for_tags(resource_type, tag_ids)) - return await self._default_set(resource_type, session) + target = set(await self._tags.list_targets_for_tags(resource_type, tag_ids)) + 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]: if resource_type == "agent": @@ -103,6 +114,37 @@ class PositionSyncService: # scheduled_task (and any other materialized type) defaults to empty. 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 async def sync_user(self, user_id: str) -> None: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index 57345641..f87df609 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -1,33 +1,131 @@ -from __future__ import annotations - -import pytest -import yaml - -from app.gateway.routers.agents import ( - _backfill_listing_cache, - _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 MemoryAgentStore - - +from __future__ import annotations + +import json +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 ( + _backfill_listing_cache, + _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 -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) +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() + + +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" - +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}) @@ -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")} 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_agent_store_only_owner_can_update_or_delete() -> None: - store = MemoryAgentStore() - + 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"] + + +@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"})