From 41df4e2e133a1df242ba5a0738d5f952a722f5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gil=20Assun=C3=A7=C3=A3o?= Date: Fri, 29 May 2026 14:12:58 +0100 Subject: [PATCH 1/2] feat(api): add endpoint to retrieve session object metadata * Implement GET /sessions/{session_id}/objects/{file_id} to return file object metadata. * Include lastModified timestamp for active sessions. * Ensure compatibility with LibreChat's getSessionInfo. * Add tests for the new endpoint to validate functionality and error handling. --- src/api/files.py | 69 +++++++++++++++++ tests/unit/test_librechat_contract.py | 103 +++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/api/files.py b/src/api/files.py index 7ea2e8b..ae291a5 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -572,6 +572,75 @@ async def download_file_options(session_id: str, file_id: str): ) +@router.get("/sessions/{session_id}/objects/{file_id}") +async def get_session_object( + session_id: str, + file_id: str, + kind: str | None = Query(None, description="Resource kind: 'skill', 'agent', or 'user'"), + id: str | None = Query(None, description="Resource id (userId / agentId / skillId)"), + version: int | None = Query(None, description="Resource version (only meaningful for kind=skill)"), + file_service: FileServiceDep = None, + session_service: SessionServiceDep = None, +): + """Return file object metadata for a session - LibreChat compatible. + + Called by LibreChat's ``getSessionInfo`` (process.js) to check whether a + previously-uploaded file is still live in the code environment. The + response must include ``lastModified`` — LibreChat uses it to decide + whether to re-upload. + + Query params ``kind``/``id``/``version`` are accepted for parity with + LibreChat's ``buildCodeEnvDownloadQuery`` but are not enforced today. + """ + try: + file_info = await file_service.get_file_info(session_id, file_id) + if not file_info: + raise HTTPException(status_code=404, detail="File not found") + + # Determine lastModified: prefer session last_activity for active + # sessions (mirrors the /files/{session_id} detail=summary logic). + last_modified = None + try: + session = await session_service.get_session(session_id) + if session: + if session.status == SessionStatus.ACTIVE: + last_modified = datetime.now(UTC) + elif session.last_activity: + act = session.last_activity + if isinstance(act, str): + act = datetime.fromisoformat(act) + if act.tzinfo is None: + act = act.replace(tzinfo=UTC) + last_modified = act + except Exception: + pass + + if last_modified is None: + last_modified = file_info.created_at + if isinstance(last_modified, str): + last_modified = datetime.fromisoformat(last_modified) + if last_modified.tzinfo is None: + last_modified = last_modified.replace(tzinfo=UTC) + + last_modified_str = last_modified.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + return { + "name": f"{session_id}/{file_id}", + "lastModified": last_modified_str, + } + + except HTTPException: + raise + except Exception as e: + logger.error( + "Failed to get session object info", + session_id=session_id, + file_id=file_id, + error=str(e), + ) + raise HTTPException(status_code=404, detail="File not found") + + @router.delete("/files/{session_id}/{file_id}") async def delete_file(session_id: str, file_id: str, file_service: FileServiceDep = None): """Delete a file from the session - LibreChat compatible.""" diff --git a/tests/unit/test_librechat_contract.py b/tests/unit/test_librechat_contract.py index f5b5eaf..2bac755 100644 --- a/tests/unit/test_librechat_contract.py +++ b/tests/unit/test_librechat_contract.py @@ -21,7 +21,7 @@ import pytest -from src.api.files import list_files, upload_file, upload_files_batch +from src.api.files import get_session_object, list_files, upload_file, upload_files_batch from src.models.exec import FileRef, RequestFile # --------------------------------------------------------------------------- @@ -340,3 +340,104 @@ async def test_list_files_detail_full_emits_storage_session_id(self): assert len(result) == 1 assert result[0]["storage_session_id"] == "sess-1" assert result[0]["session_id"] == "sess-1" + + +class TestGetSessionObject: + """Tests for GET /sessions/{session_id}/objects/{file_id}. + + LibreChat's ``getSessionInfo`` (process.js) calls this endpoint to check + whether a previously-uploaded file is still active. It expects a JSON + response with ``lastModified``. A 404 triggers a re-upload cycle. + """ + + @pytest.mark.asyncio + async def test_returns_last_modified_for_existing_file(self): + from datetime import UTC, datetime + + from src.models.files import FileInfo + from src.models.session import Session, SessionStatus + + file_info = FileInfo( + file_id="file-abc", + filename="data.csv", + size=42, + content_type="text/csv", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + path="/data.csv", + ) + file_service = MagicMock() + file_service.get_file_info = AsyncMock(return_value=file_info) + + session = MagicMock() + session.status = SessionStatus.ACTIVE + session.last_activity = datetime(2026, 1, 2, tzinfo=UTC) + + session_service = MagicMock() + session_service.get_session = AsyncMock(return_value=session) + + result = await get_session_object( + session_id="sess-1", + file_id="file-abc", + kind="user", + id="user-123", + version=None, + file_service=file_service, + session_service=session_service, + ) + + assert "lastModified" in result + assert result["name"] == "sess-1/file-abc" + # Active session should yield a recent timestamp (not None) + assert result["lastModified"].endswith("Z") + + @pytest.mark.asyncio + async def test_returns_404_when_file_not_found(self): + from fastapi import HTTPException + + file_service = MagicMock() + file_service.get_file_info = AsyncMock(return_value=None) + session_service = MagicMock() + + with pytest.raises(HTTPException) as exc: + await get_session_object( + session_id="sess-1", + file_id="nonexistent", + kind="user", + id="user-123", + version=None, + file_service=file_service, + session_service=session_service, + ) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_accepts_kind_id_version_params_without_error(self): + """The kind/id/version query params must be accepted (no 422).""" + from datetime import UTC, datetime + + from src.models.files import FileInfo + + file_info = FileInfo( + file_id="fid", + filename="script.py", + size=10, + content_type="text/x-python", + created_at=datetime(2026, 5, 1, tzinfo=UTC), + path="/script.py", + ) + file_service = MagicMock() + file_service.get_file_info = AsyncMock(return_value=file_info) + session_service = MagicMock() + session_service.get_session = AsyncMock(return_value=None) + + result = await get_session_object( + session_id="s1", + file_id="fid", + kind="skill", + id="skill-42", + version=3, + file_service=file_service, + session_service=session_service, + ) + + assert "lastModified" in result From 1b1cc70642fb9aa78bcb2fbc6c43e45b3d1f87dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gil=20Assun=C3=A7=C3=A3o?= Date: Fri, 29 May 2026 18:33:16 +0100 Subject: [PATCH 2/2] fix(review): address PR #71 review comments - Rename 'id' param to 'resource_id' with alias='id' to avoid shadowing builtin - Log warning on session retrieval failure instead of silent except pass - Return 500 for unexpected errors instead of misleading 404 - Assert ACTIVE branch timestamp is close to now in test - Add test case for inactive session with last_activity --- src/api/files.py | 12 ++++--- tests/unit/test_librechat_contract.py | 48 ++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/api/files.py b/src/api/files.py index ae291a5..a69ac30 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -577,7 +577,7 @@ async def get_session_object( session_id: str, file_id: str, kind: str | None = Query(None, description="Resource kind: 'skill', 'agent', or 'user'"), - id: str | None = Query(None, description="Resource id (userId / agentId / skillId)"), + resource_id: str | None = Query(None, alias="id", description="Resource id (userId / agentId / skillId)"), version: int | None = Query(None, description="Resource version (only meaningful for kind=skill)"), file_service: FileServiceDep = None, session_service: SessionServiceDep = None, @@ -612,8 +612,12 @@ async def get_session_object( if act.tzinfo is None: act = act.replace(tzinfo=UTC) last_modified = act - except Exception: - pass + except Exception as exc: + logger.warning( + "Failed to retrieve session for lastModified derivation, falling back to file created_at", + session_id=session_id, + error=str(exc), + ) if last_modified is None: last_modified = file_info.created_at @@ -638,7 +642,7 @@ async def get_session_object( file_id=file_id, error=str(e), ) - raise HTTPException(status_code=404, detail="File not found") + raise HTTPException(status_code=500, detail="Internal server error") @router.delete("/files/{session_id}/{file_id}") diff --git a/tests/unit/test_librechat_contract.py b/tests/unit/test_librechat_contract.py index 2bac755..8a72796 100644 --- a/tests/unit/test_librechat_contract.py +++ b/tests/unit/test_librechat_contract.py @@ -379,7 +379,7 @@ async def test_returns_last_modified_for_existing_file(self): session_id="sess-1", file_id="file-abc", kind="user", - id="user-123", + resource_id="user-123", version=None, file_service=file_service, session_service=session_service, @@ -387,8 +387,10 @@ async def test_returns_last_modified_for_existing_file(self): assert "lastModified" in result assert result["name"] == "sess-1/file-abc" - # Active session should yield a recent timestamp (not None) + # Active session should yield a timestamp close to now (not file's created_at) assert result["lastModified"].endswith("Z") + parsed = datetime.fromisoformat(result["lastModified"].replace("Z", "+00:00")) + assert abs((datetime.now(UTC) - parsed).total_seconds()) < 5 @pytest.mark.asyncio async def test_returns_404_when_file_not_found(self): @@ -403,7 +405,7 @@ async def test_returns_404_when_file_not_found(self): session_id="sess-1", file_id="nonexistent", kind="user", - id="user-123", + resource_id="user-123", version=None, file_service=file_service, session_service=session_service, @@ -434,10 +436,48 @@ async def test_accepts_kind_id_version_params_without_error(self): session_id="s1", file_id="fid", kind="skill", - id="skill-42", + resource_id="skill-42", version=3, file_service=file_service, session_service=session_service, ) assert "lastModified" in result + + @pytest.mark.asyncio + async def test_returns_last_activity_for_inactive_session(self): + """Inactive session with last_activity should use that timestamp.""" + from datetime import UTC, datetime + + from src.models.files import FileInfo + from src.models.session import Session, SessionStatus + + file_info = FileInfo( + file_id="file-abc", + filename="data.csv", + size=42, + content_type="text/csv", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + path="/data.csv", + ) + file_service = MagicMock() + file_service.get_file_info = AsyncMock(return_value=file_info) + + session = MagicMock() + session.status = SessionStatus.TERMINATED + session.last_activity = datetime(2026, 3, 15, 10, 30, 0, tzinfo=UTC) + + session_service = MagicMock() + session_service.get_session = AsyncMock(return_value=session) + + result = await get_session_object( + session_id="sess-1", + file_id="file-abc", + kind="user", + resource_id="user-123", + version=None, + file_service=file_service, + session_service=session_service, + ) + + assert result["lastModified"] == "2026-03-15T10:30:00.000Z"