Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions chatbot-core/api/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,37 @@ class MessageHistoryResponse(BaseModel):
session_id: str
messages: List[MessageItem]


class SessionInfo(BaseModel):
"""
Basic metadata for a single active session.

Fields:
session_id (str): The session identifier.
message_count (int): Number of messages exchanged in the session.
last_accessed (str): ISO-8601 timestamp of last activity.
"""
session_id: str
message_count: int
last_accessed: str


class SessionListResponse(BaseModel):
"""
Response model for listing all active sessions.

Fields:
sessions (List[SessionInfo]): Ordered list of active sessions.
total (int): Total number of active sessions returned.
page (int): Current page number (1-indexed).
page_size (int): Number of sessions per page.
"""
sessions: List[SessionInfo]
total: int
page: int
page_size: int


class QueryType(Enum):
"""
Enum that represents the possible query types:
Expand Down
44 changes: 44 additions & 0 deletions chatbot-core/api/routes/chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
ChatResponse,
DeleteResponse,
MessageHistoryResponse,
SessionInfo,
SessionListResponse,
SessionResponse,
FileAttachment,
SupportedExtensionsResponse,
Expand All @@ -49,6 +51,7 @@
from api.services.memory import (
delete_session,
get_session,
list_sessions,
session_exists,
persist_session,
init_session,
Expand Down Expand Up @@ -150,6 +153,47 @@ async def chatbot_stream(websocket: WebSocket, session_id: str):
# =========================
# Session Management
# =========================
@router.get(
"/sessions",
response_model=SessionListResponse,
)
def get_sessions(
page: int = 1,
page_size: int = 20,
):
"""
List all active chat sessions.

Returns a paginated list of currently active sessions with basic
metadata (ID, message count, and last-accessed timestamp).

Query Parameters:
page (int): 1-indexed page number (default: 1, min: 1).
page_size (int): Sessions per page (default: 20, range: 1-100).

Returns:
SessionListResponse: Paginated session list with total count.
"""
page = max(1, page)
page_size = max(1, min(page_size, 100))

result = list_sessions(page=page, page_size=page_size)
sessions = [
SessionInfo(
session_id=s["session_id"],
message_count=s["message_count"],
last_accessed=s["last_accessed"],
)
for s in result["sessions"]
]
return SessionListResponse(
sessions=sessions,
total=result["total"],
page=result["page"],
page_size=result["page_size"],
)


@router.post(
"/sessions",
response_model=SessionResponse,
Expand Down
44 changes: 44 additions & 0 deletions chatbot-core/api/services/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,50 @@ def set_last_accessed(session_id: str, timestamp: datetime) -> bool:

return False

def list_sessions(page: int = 1, page_size: int = 20) -> dict:
"""
Return a paginated list of all active in-memory sessions with basic metadata.

Each entry includes the session ID, number of messages exchanged, and the
ISO-8601 last-accessed timestamp.

Args:
page (int): 1-indexed page number. Defaults to 1.
page_size (int): Maximum sessions per page. Defaults to 20.

Returns:
dict: Contains ``sessions`` (list of metadata dicts), ``total`` (total
count before pagination), ``page``, and ``page_size``.
"""
with _lock:
all_ids = sorted(_sessions.keys())
total = len(all_ids)

start = (page - 1) * page_size
end = start + page_size
page_ids = all_ids[start:end]

sessions = []
for session_id in page_ids:
session_data = _sessions.get(session_id)
if session_data is None:
continue
message_count = len(session_data["memory"].chat_memory.messages)
last_accessed: datetime = session_data["last_accessed"]
sessions.append({
"session_id": session_id,
"message_count": message_count,
"last_accessed": last_accessed.isoformat(),
})

return {
"sessions": sessions,
"total": total,
"page": page,
"page_size": page_size,
}


def get_session_count() -> int:
"""
Get the total number of active sessions (for testing purposes).
Expand Down
96 changes: 96 additions & 0 deletions chatbot-core/tests/integration/test_chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,99 @@ def get_relevant_documents_output():
"id": "docid",
"chunk_text": "Relevant chunk text."
}],[0.84])


# =========================
# GET /sessions integration tests
# =========================
def test_list_sessions_empty(client):
"""Should return an empty session list when no sessions have been created."""
response = client.get("/sessions")

assert response.status_code == 200
data = response.json()
assert data["sessions"] == []
assert data["total"] == 0
assert data["page"] == 1
assert data["page_size"] == 20


def test_list_sessions_after_create(client):
"""Should include a newly created session in the list."""
create_resp = client.post("/sessions")
session_id = create_resp.json()["session_id"]

response = client.get("/sessions")

assert response.status_code == 200
data = response.json()
assert data["total"] == 1
ids = [s["session_id"] for s in data["sessions"]]
assert session_id in ids


def test_list_sessions_message_count(client, mock_llm_provider, mock_get_relevant_documents):
"""Should report accurate message_count after exchanging messages."""
mock_llm_provider.generate.return_value = "Hello!"
mock_get_relevant_documents.return_value = get_relevant_documents_output()

session_id = client.post("/sessions").json()["session_id"]
client.post(f"/sessions/{session_id}/message", json={"message": "Hi"})

data = client.get("/sessions").json()

session = next(s for s in data["sessions"] if s["session_id"] == session_id)
# 1 human + 1 ai message = 2
assert session["message_count"] == 2


def test_list_sessions_multiple_sessions(client):
"""Should list all active sessions."""
id_a = client.post("/sessions").json()["session_id"]
id_b = client.post("/sessions").json()["session_id"]

data = client.get("/sessions").json()

assert data["total"] == 2
ids = {s["session_id"] for s in data["sessions"]}
assert id_a in ids
assert id_b in ids


def test_list_sessions_excludes_deleted(client):
"""Should not include sessions that have been deleted."""
keep_id = client.post("/sessions").json()["session_id"]
drop_id = client.post("/sessions").json()["session_id"]
client.delete(f"/sessions/{drop_id}")

data = client.get("/sessions").json()

ids = [s["session_id"] for s in data["sessions"]]
assert keep_id in ids
assert drop_id not in ids
assert data["total"] == 1


def test_list_sessions_pagination(client):
"""Should respect page and page_size query parameters."""
for _ in range(3):
client.post("/sessions")

page1 = client.get("/sessions?page=1&page_size=2").json()
page2 = client.get("/sessions?page=2&page_size=2").json()

assert len(page1["sessions"]) == 2
assert len(page2["sessions"]) == 1
assert page1["total"] == 3
assert page2["total"] == 3


def test_list_sessions_response_has_last_accessed(client):
"""Each session entry should include a non-empty last_accessed ISO timestamp."""
client.post("/sessions")

session = client.get("/sessions").json()["sessions"][0]

assert "last_accessed" in session
assert len(session["last_accessed"]) > 0

5 changes: 5 additions & 0 deletions chatbot-core/tests/unit/mocks/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ def mock_delete_session(mocker):
"""Mock the delete_session function."""
return mocker.patch("api.routes.chatbot.delete_session")

@pytest.fixture
def mock_list_sessions(mocker):
"""Mock the list_sessions function."""
return mocker.patch("api.routes.chatbot.list_sessions")

@pytest.fixture
def mock_get_chatbot_reply(mocker):
"""Mock the get_chatbot_reply function."""
Expand Down
Loading