diff --git a/apps/elara-nexus/backend/app/api/router.py b/apps/elara-nexus/backend/app/api/router.py index ea1d3a1..d29a736 100644 --- a/apps/elara-nexus/backend/app/api/router.py +++ b/apps/elara-nexus/backend/app/api/router.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.api.schemas import ( + AgentStatusResponse, BoardCreateRequest, BoardDetailResponse, BoardPatchRequest, @@ -34,6 +35,7 @@ from app.infra.llm.litellm_client import LiteLlmClient from app.infra.telemetry.langfuse import LangfuseTracer from app.repositories.sqlalchemy_repo import SqlAlchemyRepository +from app.services.agent_service import AgentService from app.services.board_service import BoardService from app.services.chat_service import ChatService from app.services.memory_service import MemoryService @@ -77,6 +79,18 @@ def me(session: Session = Depends(get_db_session)) -> MeResponse: return MeResponse(id=user.id, email=user.email, name=user.name) +@router.get("/agent/status", response_model=AgentStatusResponse) +def agent_status(session: Session = Depends(get_db_session)) -> AgentStatusResponse: + service = AgentService(SqlAlchemyRepository(session)) + status = service.get_status() + return AgentStatusResponse( + status=status["status"], + subagents=status["subagents"], + activeRuns=status["activeRuns"], + lastRunAt=parse_iso(status["lastRunAt"]) if status["lastRunAt"] is not None else None, + ) + + @router.get("/boards", response_model=list[BoardResponse]) def list_boards(session: Session = Depends(get_db_session)) -> list[BoardResponse]: service = BoardService(SqlAlchemyRepository(session)) @@ -211,6 +225,24 @@ def create_chat_session( ) +@router.get("/chat/sessions", response_model=list[ChatSessionResponse]) +def list_chat_sessions( + session: Session = Depends(get_db_session), + settings: Settings = Depends(get_settings), +) -> list[ChatSessionResponse]: + repo = SqlAlchemyRepository(session) + service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings)) + sessions = service.list_sessions() + return [ + ChatSessionResponse( + id=item["id"], + title=item["title"], + createdAt=parse_iso(item["createdAt"]), + ) + for item in sessions + ] + + @router.post("/chat/sessions/{session_id}/messages", response_model=ChatMessageResponse) def add_chat_message( session_id: str, @@ -220,7 +252,14 @@ def add_chat_message( ) -> ChatMessageResponse: repo = SqlAlchemyRepository(session) service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings)) - item = service.add_message(session_id=session_id, role=payload.role, content=payload.content) + try: + item = service.add_message( + session_id=session_id, + role=payload.role, + content=payload.content, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc run = item.get("run") return ChatMessageResponse( id=item["id"], @@ -240,7 +279,10 @@ def list_chat_messages( ) -> list[ChatMessageResponse]: repo = SqlAlchemyRepository(session) service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings)) - messages = service.list_messages(session_id=session_id) + try: + messages = service.list_messages(session_id=session_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc return [ ChatMessageResponse( id=item["id"], diff --git a/apps/elara-nexus/backend/app/api/schemas.py b/apps/elara-nexus/backend/app/api/schemas.py index 73da206..5b61ca5 100644 --- a/apps/elara-nexus/backend/app/api/schemas.py +++ b/apps/elara-nexus/backend/app/api/schemas.py @@ -15,6 +15,13 @@ class MeResponse(BaseModel): name: str +class AgentStatusResponse(BaseModel): + status: str + subagents: list[str] + activeRuns: int + lastRunAt: datetime | None + + class BoardCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=255) diff --git a/apps/elara-nexus/backend/app/domain/dtos.py b/apps/elara-nexus/backend/app/domain/dtos.py index 57b666f..f69e4af 100644 --- a/apps/elara-nexus/backend/app/domain/dtos.py +++ b/apps/elara-nexus/backend/app/domain/dtos.py @@ -63,6 +63,13 @@ class ChatMessageData(TypedDict): run: NotRequired[ChatRunData | None] +class AgentStatusData(TypedDict): + status: str + subagents: list[str] + activeRuns: int + lastRunAt: str | None + + class MemoryDocumentData(TypedDict): id: str title: str diff --git a/apps/elara-nexus/backend/app/main.py b/apps/elara-nexus/backend/app/main.py index 9ee5232..edf1d6b 100644 --- a/apps/elara-nexus/backend/app/main.py +++ b/apps/elara-nexus/backend/app/main.py @@ -1,5 +1,6 @@ import logging -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware @@ -22,7 +23,14 @@ logger = logging.getLogger(__name__) settings = get_settings() -app = FastAPI(title="Elara Nexus Backend", version="0.0.1") + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + init_db() + yield + + +app = FastAPI(title="Elara Nexus Backend", version="0.0.1", lifespan=lifespan) app.add_middleware(RequestTimeoutMiddleware, timeout_seconds=settings.app_request_timeout_seconds) app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.app_max_request_body_bytes) @@ -54,9 +62,4 @@ async def handle_unexpected(_request: Request, exc: Exception) -> JSONResponse: return JSONResponse(status_code=500, content={"detail": "Internal server error"}) -@app.on_event("startup") -def on_startup() -> None: - init_db() - - app.include_router(router) diff --git a/apps/elara-nexus/backend/app/repositories/sqlalchemy_repo.py b/apps/elara-nexus/backend/app/repositories/sqlalchemy_repo.py index 6f68a7e..a175efc 100644 --- a/apps/elara-nexus/backend/app/repositories/sqlalchemy_repo.py +++ b/apps/elara-nexus/backend/app/repositories/sqlalchemy_repo.py @@ -172,6 +172,13 @@ def create_chat_session(self, title: str) -> ChatSession: self.session.refresh(session) return session + def get_chat_session(self, session_id: str) -> ChatSession | None: + return self.session.get(ChatSession, session_id) + + def list_chat_sessions(self) -> list[ChatSession]: + stmt = select(ChatSession).order_by(ChatSession.created_at.desc()) + return list(self.session.scalars(stmt).all()) + def add_chat_message(self, session_id: str, role: str, content: str) -> ChatMessage: message = ChatMessage(session_id=session_id, role=role, content=content) self.session.add(message) @@ -212,6 +219,14 @@ def update_run_status(self, run_id: str, status: RunStatus) -> Run | None: self.session.refresh(run) return run + def list_runs_by_status(self, status: RunStatus) -> list[Run]: + stmt = select(Run).where(Run.status == status.value).order_by(Run.created_at.asc()) + return list(self.session.scalars(stmt).all()) + + def latest_run(self) -> Run | None: + stmt = select(Run).order_by(Run.created_at.desc()) + return self.session.scalar(stmt) + def create_memory_document(self, title: str, content: str, source_ref: str) -> MemoryDocument: doc = MemoryDocument(title=title, content=content, source_ref=source_ref) self.session.add(doc) diff --git a/apps/elara-nexus/backend/app/services/agent_service.py b/apps/elara-nexus/backend/app/services/agent_service.py new file mode 100644 index 0000000..999583c --- /dev/null +++ b/apps/elara-nexus/backend/app/services/agent_service.py @@ -0,0 +1,25 @@ +from app.domain.dtos import AgentStatusData +from app.domain.types import RunStatus +from app.repositories.sqlalchemy_repo import SqlAlchemyRepository + + +class AgentService: + def __init__(self, repo: SqlAlchemyRepository) -> None: + self.repo = repo + + def get_status(self) -> AgentStatusData: + running_runs = self.repo.list_runs_by_status(RunStatus.running) + latest_run = self.repo.latest_run() + subagents = sorted( + { + f"chat:{run.model}" if run.model else "chat:unknown-model" + for run in running_runs + } + ) + + return { + "status": "active" if running_runs else "idle", + "subagents": subagents, + "activeRuns": len(running_runs), + "lastRunAt": latest_run.created_at.isoformat() if latest_run is not None else None, + } diff --git a/apps/elara-nexus/backend/app/services/chat_service.py b/apps/elara-nexus/backend/app/services/chat_service.py index 5ffb213..7a8cfad 100644 --- a/apps/elara-nexus/backend/app/services/chat_service.py +++ b/apps/elara-nexus/backend/app/services/chat_service.py @@ -24,7 +24,20 @@ def create_session(self, title: str) -> ChatSessionData: "createdAt": session.created_at.isoformat(), } + def list_sessions(self) -> list[ChatSessionData]: + return [ + { + "id": session.id, + "title": session.title, + "createdAt": session.created_at.isoformat(), + } + for session in self.repo.list_chat_sessions() + ] + def add_message(self, session_id: str, role: str, content: str) -> ChatMessageData: + if self.repo.get_chat_session(session_id) is None: + raise ValueError("Session not found") + message = self.repo.add_chat_message(session_id=session_id, role=role, content=content) run_payload: ChatRunData | None = None @@ -38,10 +51,14 @@ def add_message(self, session_id: str, role: str, content: str) -> ChatMessageDa trace_id=trace.trace_id, ) try: + prior_messages = self.repo.list_chat_messages(session_id) + prompt_messages = [ + {"role": prior.role, "content": prior.content} + for prior in prior_messages + if prior.role in {"user", "assistant"} + ] reply = self.llm_client.generate_reply( - [ - {"role": "user", "content": content}, - ] + prompt_messages ) self.repo.add_chat_message( session_id=session_id, role="assistant", content=reply.content @@ -70,6 +87,9 @@ def add_message(self, session_id: str, role: str, content: str) -> ChatMessageDa } def list_messages(self, session_id: str) -> list[ChatMessageData]: + if self.repo.get_chat_session(session_id) is None: + raise ValueError("Session not found") + return [ { "id": message.id, diff --git a/apps/elara-nexus/backend/tests/conftest.py b/apps/elara-nexus/backend/tests/conftest.py index aaeb217..6aefc27 100644 --- a/apps/elara-nexus/backend/tests/conftest.py +++ b/apps/elara-nexus/backend/tests/conftest.py @@ -8,10 +8,8 @@ def _configure_env(db_path: Path) -> None: - if "APP_DB_URL" not in os.environ: - os.environ["APP_DB_URL"] = f"sqlite:///{db_path}" - if "APP_DB_ENGINE" not in os.environ: - os.environ["APP_DB_ENGINE"] = "sqlite" + os.environ["APP_DB_URL"] = f"sqlite:///{db_path}" + os.environ["APP_DB_ENGINE"] = "sqlite" os.environ["APP_AUTH_TOKEN"] = "test-token" os.environ["APP_CORS_ORIGINS"] = "http://localhost:3000" os.environ["APP_VECTOR_DIMENSIONS"] = "8" diff --git a/apps/elara-nexus/backend/tests/integration/test_chat_memory_api.py b/apps/elara-nexus/backend/tests/integration/test_chat_memory_api.py index 2d6eb77..04a2557 100644 --- a/apps/elara-nexus/backend/tests/integration/test_chat_memory_api.py +++ b/apps/elara-nexus/backend/tests/integration/test_chat_memory_api.py @@ -1,13 +1,24 @@ import pytest from fastapi.testclient import TestClient +from app.infra.llm.litellm_client import LlmReply + @pytest.mark.integration def test_chat_flow(client: TestClient, auth_headers: dict[str, str]) -> None: + list_empty = client.get("/api/v1/chat/sessions", headers=auth_headers) + assert list_empty.status_code == 200 + assert list_empty.json() == [] + create = client.post("/api/v1/chat/sessions", json={"title": "Plan"}, headers=auth_headers) assert create.status_code == 200 session = create.json() + list_sessions = client.get("/api/v1/chat/sessions", headers=auth_headers) + assert list_sessions.status_code == 200 + assert len(list_sessions.json()) == 1 + assert list_sessions.json()[0]["id"] == session["id"] + send = client.post( f"/api/v1/chat/sessions/{session['id']}/messages", json={"role": "user", "content": "Hello"}, @@ -69,3 +80,113 @@ def test_chat_assistant_message_and_missing_memory_doc( missing = client.get("/api/v1/memory/documents/not-real", headers=auth_headers) assert missing.status_code == 404 + + +@pytest.mark.integration +def test_chat_missing_session_returns_not_found( + client: TestClient, auth_headers: dict[str, str] +) -> None: + missing_send = client.post( + "/api/v1/chat/sessions/missing/messages", + json={"role": "user", "content": "Hello"}, + headers=auth_headers, + ) + assert missing_send.status_code == 404 + assert missing_send.json()["detail"] == "Session not found" + + missing_list = client.get("/api/v1/chat/sessions/missing/messages", headers=auth_headers) + assert missing_list.status_code == 404 + assert missing_list.json()["detail"] == "Session not found" + + +@pytest.mark.integration +def test_chat_completion_uses_conversation_history( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_payloads: list[list[dict[str, str]]] = [] + + def _fake_generate_reply( + _self: object, messages: list[dict[str, str]] + ) -> LlmReply: + captured_payloads.append(messages) + return LlmReply(content="stub-response", provider="litellm", model="test-model") + + monkeypatch.setattr( + "app.infra.llm.litellm_client.LiteLlmClient.generate_reply", + _fake_generate_reply, + ) + + create = client.post("/api/v1/chat/sessions", json={"title": "Context"}, headers=auth_headers) + assert create.status_code == 200 + session = create.json() + + first_send = client.post( + f"/api/v1/chat/sessions/{session['id']}/messages", + json={"role": "user", "content": "first prompt"}, + headers=auth_headers, + ) + assert first_send.status_code == 200 + + second_send = client.post( + f"/api/v1/chat/sessions/{session['id']}/messages", + json={"role": "user", "content": "second prompt"}, + headers=auth_headers, + ) + assert second_send.status_code == 200 + + assert len(captured_payloads) == 2 + assert captured_payloads[0] == [{"role": "user", "content": "first prompt"}] + assert captured_payloads[1] == [ + {"role": "user", "content": "first prompt"}, + {"role": "assistant", "content": "stub-response"}, + {"role": "user", "content": "second prompt"}, + ] + + +@pytest.mark.integration +def test_agent_status_is_idle_when_no_runs( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get("/api/v1/agent/status", headers=auth_headers) + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "idle" + assert payload["subagents"] == [] + assert payload["activeRuns"] == 0 + assert payload["lastRunAt"] is None + + +@pytest.mark.integration +def test_agent_status_reports_active_when_run_is_still_running( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _skip_run_completion(_self: object, _run_id: str, _status: object) -> None: + return None + + monkeypatch.setattr( + "app.repositories.sqlalchemy_repo.SqlAlchemyRepository.update_run_status", + _skip_run_completion, + ) + + create = client.post("/api/v1/chat/sessions", json={"title": "Runtime"}, headers=auth_headers) + assert create.status_code == 200 + session = create.json() + + send = client.post( + f"/api/v1/chat/sessions/{session['id']}/messages", + json={"role": "user", "content": "start run"}, + headers=auth_headers, + ) + assert send.status_code == 200 + + status = client.get("/api/v1/agent/status", headers=auth_headers) + assert status.status_code == 200 + payload = status.json() + assert payload["status"] == "active" + assert payload["activeRuns"] == 1 + assert payload["subagents"] == ["chat:gpt-4o-mini"] + assert payload["lastRunAt"] is not None diff --git a/apps/elara-nexus/backend/tests/unit/test_db_repo_and_types.py b/apps/elara-nexus/backend/tests/unit/test_db_repo_and_types.py index 538f577..463d10b 100644 --- a/apps/elara-nexus/backend/tests/unit/test_db_repo_and_types.py +++ b/apps/elara-nexus/backend/tests/unit/test_db_repo_and_types.py @@ -108,6 +108,10 @@ def test_sqlalchemy_repository_core_flows(session: Session) -> None: assert len(repo.task_history(task.id)) >= 3 chat = repo.create_chat_session("ops") + repo.create_chat_session("ops-2") + sessions = repo.list_chat_sessions() + assert len(sessions) == 2 + assert sessions[0].title == "ops-2" user_message = repo.add_chat_message(chat.id, "user", "hello") assert len(repo.list_chat_messages(chat.id)) == 1 diff --git a/apps/elara-nexus/frontend/src/lib/api/client.ts b/apps/elara-nexus/frontend/src/lib/api/client.ts index fac82b6..f57709c 100644 --- a/apps/elara-nexus/frontend/src/lib/api/client.ts +++ b/apps/elara-nexus/frontend/src/lib/api/client.ts @@ -120,6 +120,10 @@ export class ApiClient { }) } + listChatSessions(): Promise { + return this.request('/api/v1/chat/sessions') + } + sendChatMessage(sessionId: string, content: string): Promise { return this.request(`/api/v1/chat/sessions/${sessionId}/messages`, { method: 'POST', diff --git a/apps/elara-nexus/frontend/src/routes/-index.smoke.test.tsx b/apps/elara-nexus/frontend/src/routes/-index.smoke.test.tsx index fe5eb5a..6c309a5 100644 --- a/apps/elara-nexus/frontend/src/routes/-index.smoke.test.tsx +++ b/apps/elara-nexus/frontend/src/routes/-index.smoke.test.tsx @@ -181,6 +181,12 @@ describe('route smoke coverage', () => { headers: { 'Content-Type': 'application/json' }, }) } + if (url.endsWith('/api/v1/chat/sessions') && method === 'GET') { + return new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } if (url.endsWith('/api/v1/chat/sessions') && method === 'POST') { return new Response(JSON.stringify({ id: 'session-1', title: 'Primary Session', createdAt: new Date().toISOString() }), { status: 200, diff --git a/apps/elara-nexus/frontend/src/routes/chat.tsx b/apps/elara-nexus/frontend/src/routes/chat.tsx index 0ce0613..f317c02 100644 --- a/apps/elara-nexus/frontend/src/routes/chat.tsx +++ b/apps/elara-nexus/frontend/src/routes/chat.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { AgentStatusPanel } from '@/features/agent/AgentStatusPanel' import { ChatPanel } from '@/features/chat/ChatPanel' @@ -16,9 +16,8 @@ export function ChatPage() { const [activeSessionId, setActiveSessionId] = useState(null) const [sessionError, setSessionError] = useState('') const [creatingSession, setCreatingSession] = useState(false) - const initializedRef = useRef(false) - const createSession = async () => { + const createSession = useCallback(async () => { setCreatingSession(true) setSessionError('') try { @@ -31,16 +30,31 @@ export function ChatPage() { } finally { setCreatingSession(false) } - } + }, [client, sessions.length]) - useEffect(() => { - if (initializedRef.current) { - return + const loadSessions = useCallback(async () => { + setSessionError('') + try { + const existing = await client.listChatSessions() + setSessions(existing) + + if (existing.length > 0) { + setActiveSessionId(existing[0]?.id ?? null) + return + } + + const created = await client.createChatSession('Session 1') + setSessions([created]) + setActiveSessionId(created.id) + } catch (err) { + setSessionError(err instanceof Error ? err.message : 'Failed to load sessions') } - initializedRef.current = true - void createSession() }, [client]) + useEffect(() => { + void loadSessions() + }, [loadSessions]) + return (