From 346db5ce604fd97541c7bd43c508b08f980c70f2 Mon Sep 17 00:00:00 2001 From: Shelley Pham Date: Tue, 7 Oct 2025 13:30:13 -0700 Subject: [PATCH] feat: add status to runs api [PRO-1529] (#5214) --- fern/openapi.json | 21 ++++ letta/server/rest_api/routers/v1/runs.py | 25 ++++- tests/managers/test_run_manager.py | 134 +++++++++++++++++++++++ tests/test_run_status_conversion.py | 80 ++++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 tests/test_run_status_conversion.py diff --git a/fern/openapi.json b/fern/openapi.json index 5a86162e..d04395fb 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -10185,6 +10185,27 @@ "description": "The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.", "deprecated": true }, + { + "name": "statuses", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Filter runs by status. Can specify multiple statuses.", + "title": "Statuses" + }, + "description": "Filter runs by status. Can specify multiple statuses." + }, { "name": "background", "in": "query", diff --git a/letta/server/rest_api/routers/v1/runs.py b/letta/server/rest_api/routers/v1/runs.py index 913a2a89..083b09db 100644 --- a/letta/server/rest_api/routers/v1/runs.py +++ b/letta/server/rest_api/routers/v1/runs.py @@ -29,6 +29,20 @@ from letta.settings import settings router = APIRouter(prefix="/runs", tags=["runs"]) +def convert_statuses_to_enum(statuses: Optional[List[str]]) -> Optional[List[RunStatus]]: + """Convert a list of status strings to RunStatus enum values. + + Args: + statuses: List of status strings or None + + Returns: + List of RunStatus enum values or None if input is None + """ + if statuses is None: + return None + return [RunStatus(status) for status in statuses] + + @router.get("/", response_model=List[Run], operation_id="list_runs") async def list_runs( server: "SyncServer" = Depends(get_letta_server), @@ -38,6 +52,7 @@ async def list_runs( description="The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.", deprecated=True, ), + statuses: Optional[List[str]] = Query(None, description="Filter runs by status. Can specify multiple statuses."), background: Optional[bool] = Query(None, description="If True, filters for runs that were created in background mode."), stop_reason: Optional[StopReasonType] = Query(None, description="Filter runs by stop reason."), before: Optional[str] = Query( @@ -65,9 +80,10 @@ async def list_runs( actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) runs_manager = RunManager() - statuses = None - if active: + # Handle backwards compatibility: if statuses not provided but active=True, filter by active statuses + if statuses is None and active: statuses = [RunStatus.created, RunStatus.running] + if agent_id: # NOTE: we are deprecating agent_ids so this will the primary path soon agent_ids = [agent_id] @@ -80,10 +96,13 @@ async def list_runs( # Use the new order parameter sort_ascending = order == "asc" + # Convert string statuses to RunStatus enum + parsed_statuses = convert_statuses_to_enum(statuses) + runs = await runs_manager.list_runs( actor=actor, agent_ids=agent_ids, - statuses=statuses, + statuses=parsed_statuses, limit=limit, before=before, after=after, diff --git a/tests/managers/test_run_manager.py b/tests/managers/test_run_manager.py index ffefd733..3994735c 100644 --- a/tests/managers/test_run_manager.py +++ b/tests/managers/test_run_manager.py @@ -1146,3 +1146,137 @@ async def test_get_run_request_config_nonexistent_run(server: SyncServer, defaul # # Try to record response duration for non-existent job - should not raise exception but log warning # await server.job_manager.record_response_duration("nonexistent_job_id", 2_000_000_000, default_user) # + + +# ====================================================================================================================== +# convert_statuses_to_enum Tests +# ====================================================================================================================== + + +def test_convert_statuses_to_enum_with_none(): + """Test that convert_statuses_to_enum returns None when input is None.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + result = convert_statuses_to_enum(None) + assert result is None + + +def test_convert_statuses_to_enum_with_single_status(): + """Test converting a single status string to RunStatus enum.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + result = convert_statuses_to_enum(["completed"]) + assert result == [RunStatus.completed] + assert len(result) == 1 + + +def test_convert_statuses_to_enum_with_multiple_statuses(): + """Test converting multiple status strings to RunStatus enums.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + result = convert_statuses_to_enum(["created", "running", "completed"]) + assert result == [RunStatus.created, RunStatus.running, RunStatus.completed] + assert len(result) == 3 + + +def test_convert_statuses_to_enum_with_all_statuses(): + """Test converting all possible status strings.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + all_statuses = ["created", "running", "completed", "failed", "cancelled"] + result = convert_statuses_to_enum(all_statuses) + assert result == [RunStatus.created, RunStatus.running, RunStatus.completed, RunStatus.failed, RunStatus.cancelled] + assert len(result) == 5 + + +def test_convert_statuses_to_enum_with_empty_list(): + """Test converting an empty list.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + result = convert_statuses_to_enum([]) + assert result == [] + + +def test_convert_statuses_to_enum_with_invalid_status(): + """Test that invalid status strings raise ValueError.""" + from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + with pytest.raises(ValueError): + convert_statuses_to_enum(["invalid_status"]) + + +@pytest.mark.asyncio +async def test_list_runs_with_multiple_statuses(server: SyncServer, sarah_agent, default_user): + """Test listing runs with multiple status filters.""" + # Create runs with different statuses + run_created = await server.run_manager.create_run( + pydantic_run=PydanticRun( + status=RunStatus.created, + agent_id=sarah_agent.id, + metadata={"type": "created"}, + ), + actor=default_user, + ) + run_running = await server.run_manager.create_run( + pydantic_run=PydanticRun( + status=RunStatus.running, + agent_id=sarah_agent.id, + metadata={"type": "running"}, + ), + actor=default_user, + ) + run_completed = await server.run_manager.create_run( + pydantic_run=PydanticRun( + status=RunStatus.completed, + agent_id=sarah_agent.id, + metadata={"type": "completed"}, + ), + actor=default_user, + ) + run_failed = await server.run_manager.create_run( + pydantic_run=PydanticRun( + status=RunStatus.failed, + agent_id=sarah_agent.id, + metadata={"type": "failed"}, + ), + actor=default_user, + ) + + # Test filtering by multiple statuses + active_runs = await server.run_manager.list_runs( + actor=default_user, statuses=[RunStatus.created, RunStatus.running], agent_id=sarah_agent.id + ) + assert len(active_runs) == 2 + assert all(run.status in [RunStatus.created, RunStatus.running] for run in active_runs) + + # Test filtering by terminal statuses + terminal_runs = await server.run_manager.list_runs( + actor=default_user, statuses=[RunStatus.completed, RunStatus.failed], agent_id=sarah_agent.id + ) + assert len(terminal_runs) == 2 + assert all(run.status in [RunStatus.completed, RunStatus.failed] for run in terminal_runs) + + +@pytest.mark.asyncio +async def test_list_runs_with_no_status_filter_returns_all(server: SyncServer, sarah_agent, default_user): + """Test that not providing statuses parameter returns all runs.""" + # Create runs with different statuses + await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.created, agent_id=sarah_agent.id), actor=default_user) + await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.running, agent_id=sarah_agent.id), actor=default_user) + await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.completed, agent_id=sarah_agent.id), actor=default_user) + await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.failed, agent_id=sarah_agent.id), actor=default_user) + await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.cancelled, agent_id=sarah_agent.id), actor=default_user) + + # List all runs without status filter + all_runs = await server.run_manager.list_runs(actor=default_user, agent_id=sarah_agent.id) + + # Should return all 5 runs + assert len(all_runs) >= 5 + + # Verify we have all statuses represented + statuses_found = {run.status for run in all_runs} + assert RunStatus.created in statuses_found + assert RunStatus.running in statuses_found + assert RunStatus.completed in statuses_found + assert RunStatus.failed in statuses_found + assert RunStatus.cancelled in statuses_found diff --git a/tests/test_run_status_conversion.py b/tests/test_run_status_conversion.py new file mode 100644 index 00000000..cace820d --- /dev/null +++ b/tests/test_run_status_conversion.py @@ -0,0 +1,80 @@ +""" +Unit tests for the convert_statuses_to_enum function in the runs API router. + +These tests verify that status string conversion to RunStatus enums works correctly. +""" + +import pytest + +from letta.schemas.enums import RunStatus +from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum + + +def test_convert_statuses_to_enum_with_none(): + """Test that convert_statuses_to_enum returns None when input is None.""" + result = convert_statuses_to_enum(None) + assert result is None + + +def test_convert_statuses_to_enum_with_single_status(): + """Test converting a single status string to RunStatus enum.""" + result = convert_statuses_to_enum(["completed"]) + assert result == [RunStatus.completed] + assert len(result) == 1 + + +def test_convert_statuses_to_enum_with_multiple_statuses(): + """Test converting multiple status strings to RunStatus enums.""" + result = convert_statuses_to_enum(["created", "running", "completed"]) + assert result == [RunStatus.created, RunStatus.running, RunStatus.completed] + assert len(result) == 3 + + +def test_convert_statuses_to_enum_with_all_statuses(): + """Test converting all possible status strings.""" + all_statuses = ["created", "running", "completed", "failed", "cancelled"] + result = convert_statuses_to_enum(all_statuses) + assert result == [RunStatus.created, RunStatus.running, RunStatus.completed, RunStatus.failed, RunStatus.cancelled] + assert len(result) == 5 + + +def test_convert_statuses_to_enum_with_empty_list(): + """Test converting an empty list.""" + result = convert_statuses_to_enum([]) + assert result == [] + + +def test_convert_statuses_to_enum_with_invalid_status(): + """Test that invalid status strings raise ValueError.""" + with pytest.raises(ValueError): + convert_statuses_to_enum(["invalid_status"]) + + +def test_convert_statuses_to_enum_preserves_order(): + """Test that the order of statuses is preserved.""" + input_statuses = ["failed", "created", "completed", "running"] + result = convert_statuses_to_enum(input_statuses) + assert result == [RunStatus.failed, RunStatus.created, RunStatus.completed, RunStatus.running] + + +def test_convert_statuses_to_enum_with_duplicate_statuses(): + """Test that duplicate statuses are preserved.""" + input_statuses = ["completed", "completed", "running"] + result = convert_statuses_to_enum(input_statuses) + assert result == [RunStatus.completed, RunStatus.completed, RunStatus.running] + assert len(result) == 3 + + +def test_convert_statuses_to_enum_case_sensitivity(): + """Test that the function is case-sensitive and requires exact matches.""" + with pytest.raises(ValueError): + convert_statuses_to_enum(["COMPLETED"]) + + with pytest.raises(ValueError): + convert_statuses_to_enum(["Completed"]) + + +def test_convert_statuses_to_enum_with_mixed_valid_invalid(): + """Test that if any status is invalid, the entire conversion fails.""" + with pytest.raises(ValueError): + convert_statuses_to_enum(["completed", "invalid", "running"])