Compare commits

...

9 Commits

Author SHA1 Message Date
Nick Tindle
b7968a560d fix(tests): revert unnecessary signup test changes
The signup flow still ends at /marketplace via the test helper.
Only the signin tests needed updating since the sort param useEffect was removed.
2026-02-01 16:07:01 -06:00
Medyan
56788a0226 fix E2E Test Failure 2026-02-01 15:26:35 -05:00
Medyan
6119900f44 fix E2E Test Failure 2026-02-01 15:10:56 -05:00
Medyan
9688a507a3 fix format using poetry 2026-02-01 14:59:56 -05:00
Medyan
3a38bb2338 add test case for agents_sort_by_last_executed 2026-02-01 14:59:56 -05:00
Medyan
683fff4fab add comment 2026-02-01 14:59:56 -05:00
Medyan
ccb537696f fix failed test cases 2026-02-01 14:59:56 -05:00
Medyan
7838855da9 make lastExecuted default for sorting agents 2026-02-01 14:59:56 -05:00
Medyan
3ec8fd912f Sort by most recent execution time
rebase with dev branch
2026-02-01 14:59:56 -05:00
13 changed files with 296 additions and 32 deletions

View File

@@ -104,10 +104,52 @@ async def list_library_agents(
order_by = {"updatedAt": "desc"} order_by = {"updatedAt": "desc"}
try: try:
# For LAST_EXECUTED sorting, we need to fetch execution data and sort in Python
# since Prisma doesn't support sorting by nested relations
if sort_by == library_model.LibraryAgentSort.LAST_EXECUTED:
# TODO: This fetches all agents into memory for sorting, which may cause
# performance issues for users with many agents. Prisma doesn't support
# sorting by nested relations, so a dedicated lastExecutedAt column or
# raw SQL query would be needed for database-level pagination.
library_agents = await prisma.models.LibraryAgent.prisma().find_many( library_agents = await prisma.models.LibraryAgent.prisma().find_many(
where=where_clause, where=where_clause,
include=library_agent_include( include=library_agent_include(
user_id, include_nodes=False, include_executions=include_executions user_id,
include_nodes=False,
include_executions=True,
execution_limit=1,
),
)
def get_sort_key(
agent: prisma.models.LibraryAgent,
) -> tuple[int, float]:
"""
Returns a tuple for sorting: (has_no_executions, -timestamp).
Agents WITH executions come first (sorted by most recent execution),
agents WITHOUT executions come last (sorted by creation date).
"""
graph = agent.AgentGraph
if graph and graph.Executions and len(graph.Executions) > 0:
execution = graph.Executions[0]
timestamp = execution.updatedAt or execution.createdAt
return (0, -timestamp.timestamp())
return (1, -agent.createdAt.timestamp())
library_agents.sort(key=get_sort_key)
# Apply pagination after sorting
agent_count = len(library_agents)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
library_agents = library_agents[start_idx:end_idx]
else:
# Standard sorting via database
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
where=where_clause,
include=library_agent_include(
user_id, include_nodes=False, include_executions=False
), ),
order=order_by, order=order_by,
skip=(page - 1) * page_size, skip=(page - 1) * page_size,
@@ -345,6 +387,20 @@ async def get_library_agent_by_graph_id(
graph_id: str, graph_id: str,
graph_version: Optional[int] = None, graph_version: Optional[int] = None,
) -> library_model.LibraryAgent | None: ) -> library_model.LibraryAgent | None:
"""
Retrieves a library agent by its graph ID for a given user.
Args:
user_id: The ID of the user who owns the library agent.
graph_id: The ID of the agent graph to look up.
graph_version: Optional specific version of the graph to retrieve.
Returns:
The LibraryAgent if found, otherwise None.
Raises:
DatabaseError: If there's an error during retrieval.
"""
try: try:
filter: prisma.types.LibraryAgentWhereInput = { filter: prisma.types.LibraryAgentWhereInput = {
"agentGraphId": graph_id, "agentGraphId": graph_id,
@@ -628,6 +684,17 @@ async def update_library_agent(
async def delete_library_agent( async def delete_library_agent(
library_agent_id: str, user_id: str, soft_delete: bool = True library_agent_id: str, user_id: str, soft_delete: bool = True
) -> None: ) -> None:
"""
Deletes a library agent and cleans up associated schedules and webhooks.
Args:
library_agent_id: The ID of the library agent to delete.
user_id: The ID of the user who owns the library agent.
soft_delete: If True, marks the agent as deleted; if False, permanently removes it.
Raises:
NotFoundError: If the library agent is not found or doesn't belong to the user.
"""
# First get the agent to find the graph_id for cleanup # First get the agent to find the graph_id for cleanup
library_agent = await prisma.models.LibraryAgent.prisma().find_unique( library_agent = await prisma.models.LibraryAgent.prisma().find_unique(
where={"id": library_agent_id}, include={"AgentGraph": True} where={"id": library_agent_id}, include={"AgentGraph": True}
@@ -1121,6 +1188,20 @@ async def update_preset(
async def set_preset_webhook( async def set_preset_webhook(
user_id: str, preset_id: str, webhook_id: str | None user_id: str, preset_id: str, webhook_id: str | None
) -> library_model.LibraryAgentPreset: ) -> library_model.LibraryAgentPreset:
"""
Sets or removes a webhook connection for a preset.
Args:
user_id: The ID of the user who owns the preset.
preset_id: The ID of the preset to update.
webhook_id: The ID of the webhook to connect, or None to disconnect.
Returns:
The updated LibraryAgentPreset.
Raises:
NotFoundError: If the preset is not found or doesn't belong to the user.
"""
current = await prisma.models.AgentPreset.prisma().find_unique( current = await prisma.models.AgentPreset.prisma().find_unique(
where={"id": preset_id}, where={"id": preset_id},
include=AGENT_PRESET_INCLUDE, include=AGENT_PRESET_INCLUDE,

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta, timezone
import prisma.enums import prisma.enums
import prisma.models import prisma.models
@@ -9,6 +9,7 @@ from backend.data.db import connect
from backend.data.includes import library_agent_include from backend.data.includes import library_agent_include
from . import db from . import db
from . import model as library_model
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -225,3 +226,183 @@ async def test_add_agent_to_library_not_found(mocker):
mock_store_listing_version.return_value.find_unique.assert_called_once_with( mock_store_listing_version.return_value.find_unique.assert_called_once_with(
where={"id": "version123"}, include={"AgentGraph": True} where={"id": "version123"}, include={"AgentGraph": True}
) )
@pytest.mark.asyncio
async def test_list_library_agents_sort_by_last_executed(mocker):
"""
Test LAST_EXECUTED sorting behavior:
- Agents WITH executions come first, sorted by most recent execution (updatedAt)
- Agents WITHOUT executions come last, sorted by creation date
"""
now = datetime.now(timezone.utc)
# Agent 1: Has execution that finished 1 hour ago
agent1_execution = prisma.models.AgentGraphExecution(
id="exec1",
agentGraphId="agent1",
agentGraphVersion=1,
userId="test-user",
createdAt=now - timedelta(hours=2),
updatedAt=now - timedelta(hours=1), # Finished 1 hour ago
executionStatus=prisma.enums.AgentExecutionStatus.COMPLETED,
isDeleted=False,
isShared=False,
)
agent1_graph = prisma.models.AgentGraph(
id="agent1",
version=1,
name="Agent With Recent Execution",
description="Has execution finished 1 hour ago",
userId="test-user",
isActive=True,
createdAt=now - timedelta(days=5),
Executions=[agent1_execution],
)
library_agent1 = prisma.models.LibraryAgent(
id="lib1",
userId="test-user",
agentGraphId="agent1",
agentGraphVersion=1,
settings="{}", # type: ignore
isCreatedByUser=True,
isDeleted=False,
isArchived=False,
createdAt=now - timedelta(days=5),
updatedAt=now - timedelta(days=5),
isFavorite=False,
useGraphIsActiveVersion=True,
AgentGraph=agent1_graph,
)
# Agent 2: Has execution that finished 3 hours ago
agent2_execution = prisma.models.AgentGraphExecution(
id="exec2",
agentGraphId="agent2",
agentGraphVersion=1,
userId="test-user",
createdAt=now - timedelta(hours=5),
updatedAt=now - timedelta(hours=3), # Finished 3 hours ago
executionStatus=prisma.enums.AgentExecutionStatus.COMPLETED,
isDeleted=False,
isShared=False,
)
agent2_graph = prisma.models.AgentGraph(
id="agent2",
version=1,
name="Agent With Older Execution",
description="Has execution finished 3 hours ago",
userId="test-user",
isActive=True,
createdAt=now - timedelta(days=3),
Executions=[agent2_execution],
)
library_agent2 = prisma.models.LibraryAgent(
id="lib2",
userId="test-user",
agentGraphId="agent2",
agentGraphVersion=1,
settings="{}", # type: ignore
isCreatedByUser=True,
isDeleted=False,
isArchived=False,
createdAt=now - timedelta(days=3),
updatedAt=now - timedelta(days=3),
isFavorite=False,
useGraphIsActiveVersion=True,
AgentGraph=agent2_graph,
)
# Agent 3: No executions, created 1 day ago (should come after agents with executions)
agent3_graph = prisma.models.AgentGraph(
id="agent3",
version=1,
name="Agent Without Executions (Newer)",
description="No executions, created 1 day ago",
userId="test-user",
isActive=True,
createdAt=now - timedelta(days=1),
Executions=[],
)
library_agent3 = prisma.models.LibraryAgent(
id="lib3",
userId="test-user",
agentGraphId="agent3",
agentGraphVersion=1,
settings="{}", # type: ignore
isCreatedByUser=True,
isDeleted=False,
isArchived=False,
createdAt=now - timedelta(days=1),
updatedAt=now - timedelta(days=1),
isFavorite=False,
useGraphIsActiveVersion=True,
AgentGraph=agent3_graph,
)
# Agent 4: No executions, created 2 days ago
agent4_graph = prisma.models.AgentGraph(
id="agent4",
version=1,
name="Agent Without Executions (Older)",
description="No executions, created 2 days ago",
userId="test-user",
isActive=True,
createdAt=now - timedelta(days=2),
Executions=[],
)
library_agent4 = prisma.models.LibraryAgent(
id="lib4",
userId="test-user",
agentGraphId="agent4",
agentGraphVersion=1,
settings="{}", # type: ignore
isCreatedByUser=True,
isDeleted=False,
isArchived=False,
createdAt=now - timedelta(days=2),
updatedAt=now - timedelta(days=2),
isFavorite=False,
useGraphIsActiveVersion=True,
AgentGraph=agent4_graph,
)
# Return agents in random order to verify sorting works
mock_library_agents = [
library_agent3,
library_agent1,
library_agent4,
library_agent2,
]
# Mock prisma calls
mock_agent_graph = mocker.patch("prisma.models.AgentGraph.prisma")
mock_agent_graph.return_value.find_many = mocker.AsyncMock(return_value=[])
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
mock_library_agent.return_value.find_many = mocker.AsyncMock(
return_value=mock_library_agents
)
# Call function with LAST_EXECUTED sort
result = await db.list_library_agents(
"test-user",
sort_by=library_model.LibraryAgentSort.LAST_EXECUTED,
)
# Verify sorting order:
# 1. Agent 1 (execution finished 1 hour ago) - most recent execution
# 2. Agent 2 (execution finished 3 hours ago) - older execution
# 3. Agent 3 (no executions, created 1 day ago) - newer creation
# 4. Agent 4 (no executions, created 2 days ago) - older creation
assert len(result.agents) == 4
assert (
result.agents[0].id == "lib1"
), "Agent with most recent execution should be first"
assert result.agents[1].id == "lib2", "Agent with older execution should be second"
assert (
result.agents[2].id == "lib3"
), "Agent without executions (newer) should be third"
assert (
result.agents[3].id == "lib4"
), "Agent without executions (older) should be last"

View File

@@ -442,6 +442,7 @@ class LibraryAgentSort(str, Enum):
CREATED_AT = "createdAt" CREATED_AT = "createdAt"
UPDATED_AT = "updatedAt" UPDATED_AT = "updatedAt"
LAST_EXECUTED = "lastExecuted"
class LibraryAgentUpdateRequest(pydantic.BaseModel): class LibraryAgentUpdateRequest(pydantic.BaseModel):

View File

@@ -28,7 +28,7 @@ async def list_library_agents(
None, description="Search term to filter agents" None, description="Search term to filter agents"
), ),
sort_by: library_model.LibraryAgentSort = Query( sort_by: library_model.LibraryAgentSort = Query(
library_model.LibraryAgentSort.UPDATED_AT, library_model.LibraryAgentSort.LAST_EXECUTED,
description="Criteria to sort results by", description="Criteria to sort results by",
), ),
page: int = Query( page: int = Query(

View File

@@ -112,7 +112,7 @@ async def test_get_library_agents_success(
mock_db_call.assert_called_once_with( mock_db_call.assert_called_once_with(
user_id=test_user_id, user_id=test_user_id,
search_term="test", search_term="test",
sort_by=library_model.LibraryAgentSort.UPDATED_AT, sort_by=library_model.LibraryAgentSort.LAST_EXECUTED,
page=1, page=1,
page_size=15, page_size=15,
) )

View File

@@ -119,7 +119,9 @@ def library_agent_include(
if include_executions: if include_executions:
agent_graph_include["Executions"] = { agent_graph_include["Executions"] = {
"where": {"userId": user_id}, "where": {"userId": user_id},
"order_by": {"createdAt": "desc"}, "order_by": {
"updatedAt": "desc"
}, # Uses updatedAt because it reflects when the executioncompleted or last progressed
"take": execution_limit, "take": execution_limit,
} }

View File

@@ -23,10 +23,13 @@ export function LibrarySortMenu({ setLibrarySort }: Props) {
<Select onValueChange={handleSortChange}> <Select onValueChange={handleSortChange}>
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-base underline underline-offset-4 shadow-none"> <SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-base underline underline-offset-4 shadow-none">
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" /> <ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
<SelectValue placeholder="Last Modified" /> <SelectValue placeholder="Last Executed" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value={LibraryAgentSort.lastExecuted}>
Last Executed
</SelectItem>
<SelectItem value={LibraryAgentSort.createdAt}> <SelectItem value={LibraryAgentSort.createdAt}>
Creation Date Creation Date
</SelectItem> </SelectItem>

View File

@@ -11,12 +11,14 @@ export function useLibrarySortMenu({ setLibrarySort }: Props) {
const getSortLabel = (sort: LibraryAgentSort) => { const getSortLabel = (sort: LibraryAgentSort) => {
switch (sort) { switch (sort) {
case LibraryAgentSort.lastExecuted:
return "Last Executed";
case LibraryAgentSort.createdAt: case LibraryAgentSort.createdAt:
return "Creation Date"; return "Creation Date";
case LibraryAgentSort.updatedAt: case LibraryAgentSort.updatedAt:
return "Last Modified"; return "Last Modified";
default: default:
return "Last Modified"; return "Last Executed";
} }
}; };

View File

@@ -2,7 +2,7 @@
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort"; import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { parseAsStringEnum, useQueryState } from "nuqs"; import { parseAsStringEnum, useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
const sortParser = parseAsStringEnum(Object.values(LibraryAgentSort)); const sortParser = parseAsStringEnum(Object.values(LibraryAgentSort));
@@ -11,14 +11,7 @@ export function useLibraryListPage() {
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [librarySortRaw, setLibrarySortRaw] = useQueryState("sort", sortParser); const [librarySortRaw, setLibrarySortRaw] = useQueryState("sort", sortParser);
// Ensure sort param is always present in URL (even if default) const librarySort = librarySortRaw || LibraryAgentSort.lastExecuted;
useEffect(() => {
if (!librarySortRaw) {
setLibrarySortRaw(LibraryAgentSort.updatedAt, { shallow: false });
}
}, [librarySortRaw, setLibrarySortRaw]);
const librarySort = librarySortRaw || LibraryAgentSort.updatedAt;
const setLibrarySort = useCallback( const setLibrarySort = useCallback(
(value: LibraryAgentSort) => { (value: LibraryAgentSort) => {

View File

@@ -3361,7 +3361,7 @@
"schema": { "schema": {
"$ref": "#/components/schemas/LibraryAgentSort", "$ref": "#/components/schemas/LibraryAgentSort",
"description": "Criteria to sort results by", "description": "Criteria to sort results by",
"default": "updatedAt" "default": "lastExecuted"
}, },
"description": "Criteria to sort results by" "description": "Criteria to sort results by"
}, },
@@ -8239,7 +8239,7 @@
}, },
"LibraryAgentSort": { "LibraryAgentSort": {
"type": "string", "type": "string",
"enum": ["createdAt", "updatedAt"], "enum": ["createdAt", "updatedAt", "lastExecuted"],
"title": "LibraryAgentSort", "title": "LibraryAgentSort",
"description": "Possible sort options for sorting library agents." "description": "Possible sort options for sorting library agents."
}, },

View File

@@ -612,6 +612,7 @@ export type LibraryAgentPresetUpdatable = Partial<
export enum LibraryAgentSortEnum { export enum LibraryAgentSortEnum {
CREATED_AT = "createdAt", CREATED_AT = "createdAt",
UPDATED_AT = "updatedAt", UPDATED_AT = "updatedAt",
LAST_EXECUTED = "lastExecuted",
} }
/* *** CREDENTIALS *** */ /* *** CREDENTIALS *** */

View File

@@ -85,7 +85,7 @@ export class LibraryPage extends BasePage {
async selectSortOption( async selectSortOption(
page: Page, page: Page,
sortOption: "Creation Date" | "Last Modified", sortOption: "Last Executed" | "Creation Date" | "Last Modified",
): Promise<void> { ): Promise<void> {
const { getRole } = getSelectors(page); const { getRole } = getSelectors(page);
await getRole("combobox").click(); await getRole("combobox").click();

View File

@@ -182,7 +182,7 @@ test("logged in user is redirected from /login to /library", async ({
await hasUrl(page, "/marketplace"); await hasUrl(page, "/marketplace");
await page.goto("/login"); await page.goto("/login");
await hasUrl(page, "/library?sort=updatedAt"); await hasUrl(page, "/library");
}); });
test("logged in user is redirected from /signup to /library", async ({ test("logged in user is redirected from /signup to /library", async ({
@@ -195,5 +195,5 @@ test("logged in user is redirected from /signup to /library", async ({
await hasUrl(page, "/marketplace"); await hasUrl(page, "/marketplace");
await page.goto("/signup"); await page.goto("/signup");
await hasUrl(page, "/library?sort=updatedAt"); await hasUrl(page, "/library");
}); });